diff --git a/build/nsis/Mixxx.nsi b/build/nsis/Mixxx.nsi index 9914bc0e936a..139287d17b56 100644 --- a/build/nsis/Mixxx.nsi +++ b/build/nsis/Mixxx.nsi @@ -493,11 +493,15 @@ Section "Uninstall" Delete "$INSTDIR\controllers\Stanton SCS.1d.midi.xml" Delete "$INSTDIR\controllers\Stanton SCS.1m.midi.xml" Delete "$INSTDIR\controllers\Stanton SCS.3d.midi.xml" + Delete "$INSTDIR\controllers\Stanton SCS.3d.4deck.midi.xml" Delete "$INSTDIR\controllers\Stanton SCS.3m.midi.xml" + Delete "$INSTDIR\controllers\Stanton SCS.3m.4deck.midi.xml" Delete "$INSTDIR\controllers\Stanton-SCS1d-scripts.js" Delete "$INSTDIR\controllers\Stanton-SCS1m-scripts.js" Delete "$INSTDIR\controllers\Stanton-SCS3d-scripts.js" + Delete "$INSTDIR\controllers\Stanton-SCS3d-4deck-scripts.js" Delete "$INSTDIR\controllers\Stanton-SCS3m-scripts.js" + Delete "$INSTDIR\controllers\Stanton-SCS3m-4deck-scripts.js" Delete "$INSTDIR\controllers\TrakProDJ iPad.midi.xml" Delete "$INSTDIR\controllers\TrakProDJ-iPad-scripts.js" Delete "$INSTDIR\controllers\Traktor Kontrol F1.hid.xml" diff --git a/res/controllers/Stanton SCS.3d.4deck.midi.xml b/res/controllers/Stanton SCS.3d.4deck.midi.xml new file mode 100644 index 000000000000..a3172fbca41f --- /dev/null +++ b/res/controllers/Stanton SCS.3d.4deck.midi.xml @@ -0,0 +1,106 @@ + + + + Stanton SCS.3d 4deck + sbalmer + + Mapping for the Stanton SCS3 system. This part maps the SCS.3d + controller. + + + + + + + + + SCS3D.receive0x800x01 + SCS3D.receive0x900x01 + SCS3D.receive0xB00x01 + SCS3D.receive0xB00x02 + + + SCS3D.receive0x800x03 + SCS3D.receive0x900x03 + SCS3D.receive0xB00x03 + SCS3D.receive0xB00x04 + + + SCS3D.receive0x800x07 + SCS3D.receive0x900x07 + SCS3D.receive0xB00x07 + SCS3D.receive0xB00x08 + + + SCS3D.receive0x800x0C + SCS3D.receive0x900x0C + SCS3D.receive0xB00x0C + SCS3D.receive0xB00x0D + + + SCS3D.receive0x800x0E + SCS3D.receive0x900x0E + SCS3D.receive0xB00x0E + SCS3D.receive0xB00x0F + + + SCS3D.receive0x800x20 + SCS3D.receive0x900x20 + SCS3D.receive0x800x22 + SCS3D.receive0x900x22 + SCS3D.receive0x800x24 + SCS3D.receive0x900x24 + SCS3D.receive0x800x26 + SCS3D.receive0x900x26 + SCS3D.receive0x800x28 + SCS3D.receive0x900x28 + SCS3D.receive0x800x2A + SCS3D.receive0x900x2A + + + SCS3D.receive0x800x2C + SCS3D.receive0x900x2C + SCS3D.receive0x800x2E + SCS3D.receive0x900x2E + SCS3D.receive0x800x30 + SCS3D.receive0x900x30 + SCS3D.receive0x800x32 + SCS3D.receive0x900x32 + + + SCS3D.receive0x800x48 + SCS3D.receive0x900x48 + SCS3D.receive0x800x4A + SCS3D.receive0x900x4A + SCS3D.receive0x800x4C + SCS3D.receive0x900x4C + SCS3D.receive0x800x4E + SCS3D.receive0x900x4E + SCS3D.receive0x800x4F + SCS3D.receive0x900x4F + SCS3D.receive0x800x51 + SCS3D.receive0x900x51 + SCS3D.receive0x800x53 + SCS3D.receive0x900x53 + SCS3D.receive0x800x55 + SCS3D.receive0x900x55 + + + SCS3D.receive0x800x62 + SCS3D.receive0x900x62 + SCS3D.receive0xB00x62 + SCS3D.receive0xB00x63 + + + SCS3D.receive0x800x6D + SCS3D.receive0x900x6D + SCS3D.receive0x800x6E + SCS3D.receive0x900x6E + SCS3D.receive0x800x6F + SCS3D.receive0x900x6F + SCS3D.receive0x800x70 + SCS3D.receive0x900x70 + + + + diff --git a/res/controllers/Stanton SCS.3m.4deck.midi.xml b/res/controllers/Stanton SCS.3m.4deck.midi.xml new file mode 100644 index 000000000000..e4095e1852f5 --- /dev/null +++ b/res/controllers/Stanton SCS.3m.4deck.midi.xml @@ -0,0 +1,343 @@ + + + + Stanton SCS.3m 4deck + sbalmer + + Mapping for the Stanton SCS3 system. This part maps the SCS.3m + controller. Supports four decks. + + + + + + + + + SCS3M.receive + 0x90 + 0x0C + + + + + + + SCS3M.receive0xB00x00 + SCS3M.receive0x900x51 + SCS3M.receive0x800x51 + SCS3M.receive0x900x52 + SCS3M.receive0x800x52 + SCS3M.receive0x900x53 + SCS3M.receive0x800x53 + + SCS3M.receive0xB00x01 + SCS3M.receive0x900x54 + SCS3M.receive0x800x54 + SCS3M.receive0x900x55 + SCS3M.receive0x800x55 + SCS3M.receive0x900x56 + SCS3M.receive0x800x56 + + + SCS3M.receive0xB00x08 + SCS3M.receive0xB00x09 + + + SCS3M.receive0x900x00 + SCS3M.receive0x800x00 + SCS3M.receive0x900x01 + SCS3M.receive0x800x01 + + + SCS3M.receive + 0x80 + 0x05 + + + + + + SCS3M.receive + 0x80 + 0x10 + + + + + + SCS3M.receive + 0x90 + 0x06 + + + + + + SCS3M.receive + 0xB0 + 0x03 + + + + + + SCS3M.receive + 0x80 + 0x0A + + + + + + SCS3M.receive + 0x90 + 0x0B + + + + + + SCS3M.receive + 0x80 + 0x04 + + + + + + SCS3M.receive + 0x80 + 0x0F + + + + + + SCS3M.receive + 0x90 + 0x05 + + + + + + SCS3M.receive + 0xB0 + 0x02 + + + + + + SCS3M.receive + 0x90 + 0x10 + + + + + + SCS3M.receive + 0x80 + 0x09 + + + + + + SCS3M.receive + 0x90 + 0x0A + + + + + + SCS3M.receive + 0xB0 + 0x07 + + + + + + SCS3M.receive + 0x80 + 0x03 + + + + + + SCS3M.receive + 0x80 + 0x0E + + + + + + SCS3M.receive + 0x90 + 0x04 + + + + + + SCS3M.receive + 0x90 + 0x0F + + + + + + SCS3M.receive + 0x80 + 0x08 + + + + + + SCS3M.receive + 0x90 + 0x09 + + + + + + SCS3M.receive + 0xB0 + 0x06 + + + + + + SCS3M.receive + 0x80 + 0x02 + + + + + + SCS3M.receive + 0x80 + 0x0D + + + + + + SCS3M.receive + 0x90 + 0x03 + + + + + + SCS3M.receive + 0x90 + 0x0E + + + + + + SCS3M.receive + 0x80 + 0x07 + + + + + + SCS3M.receive + 0x90 + 0x08 + + + + + + SCS3M.receive + 0xB0 + 0x05 + + + + + + SCS3M.receive + 0x80 + 0x0C + + + + + + SCS3M.receive + 0x90 + 0x02 + + + + + + SCS3M.receive + 0x90 + 0x0D + + + + + + SCS3M.receive + 0xB0 + 0x0A + + + + + + SCS3M.receive + 0x80 + 0x06 + + + + + + SCS3M.receive + 0x90 + 0x07 + + + + + + SCS3M.receive + 0xB0 + 0x04 + + + + + + SCS3M.receive + 0x80 + 0x0B + + + + + + + + diff --git a/res/controllers/Stanton-SCS3d-4deck-scripts.js b/res/controllers/Stanton-SCS3d-4deck-scripts.js new file mode 100644 index 000000000000..8c265a0dda63 --- /dev/null +++ b/res/controllers/Stanton-SCS3d-4deck-scripts.js @@ -0,0 +1,1837 @@ +"use strict"; +//////////////////////////////////////////////////////////////////////// +// JSHint configuration // +//////////////////////////////////////////////////////////////////////// +/* global engine */ +/* global print */ +/* global midi */ +/* global SCS3D:true */ +/* jshint -W097 */ +/* jshint laxbreak: true */ +//////////////////////////////////////////////////////////////////////// + +// Issues +// - Each deck rembembers the mode it was in, confusing? Would it be better to +// keep the current mode on deck switch? + +// Useful tinkering commands, channel reset and flat mode +// amidi -p hw:1 -S F00001600200F7 +// amidi -p hw:1 -S F00001601000F7 + +SCS3D = {}; + +SCS3D.init = function(id) { + this.device = this.Device(); + this.agent = this.Agent(this.device); + this.agent.start(); +}; + +SCS3D.shutdown = function() { + SCS3D.agent.stop(); +}; + +SCS3D.receive = function(channel, control, value, status) { + SCS3D.agent.receive(status, control, value); +}; + + +/* Midi map of the SCS.3d device + * + * Thanks to Sean M. Pappalardo for the original SCS3 mappings. + */ +SCS3D.Device = function() { + var NoteOn = 0x90; + var NoteOff = 0x80; + var CC = 0xB0; + + var black = 0x00; + var blue = 0x02; + var red = 0x01; + var purple = blue | red; + + function Logo() { + var id = 0x7A; + return { + on: [NoteOn, id, 0x01], + off: [NoteOn, id, 0x00] + }; + } + + function Decklight(id) { + return function(value) { + return [NoteOn, id, +value]; // value might be boolean, coerce to int + }; + } + + function Meter(id, lights) { + var ctrl = []; + var i = 0; + for (; i < lights; i++) { + ctrl[i] = [NoteOn, id + lights - i - 1]; + } + return ctrl; + } + + function Light(id) { + return { + bits: function(bits) { + return [NoteOn, id, bits]; + }, + black: [NoteOn, id, black], + blue: [NoteOn, id, blue], + red: [NoteOn, id, red], + purple: [NoteOn, id, purple], + }; + } + + function Slider(id, meterid, lights) { + return { + meter: Meter(meterid, lights), + slide: { + abs: [CC, id], + rel: [CC, id + 1], + + }, + touch: [NoteOn, id], + release: [NoteOff, id] + }; + } + + function LightSlider(id, meterid, lights) { + var slider = Slider(id, meterid, lights); + var redled = meterid - 2; + var blueled = meterid - 1; + + // Contrary to the other lights, the pitch light as separate addresses for red and blue + slider.light = { + red: { + on: [NoteOn, redled, 1], + off: [NoteOn, redled, 0] + }, + blue: { + on: [NoteOn, blueled, 1], + off: [NoteOn, blueled, 0] + } + }; + return slider; + } + + function Touch(id, lightid) { + if (!lightid) lightid = id; + return { + light: Light(lightid), + touch: [NoteOn, id], + release: [NoteOff, id] + }; + } + + + // Stanton changed button mode in newer devices. + // Originally, in button mode, the three columns held 4 "buttons" + // each. Hitting one of those without accidentally touching another requires + // very accurate motor control beyond the capabilities of a DJ (even when + // sober). Later versions of the device send only two buttons per column + // (top/bottom), these are easy to hit. + // + // So not only do we need two id to map to the same field, we want to + // control multiple lights for this control as well. This is why we're using + // the plural here. The respective functions expect() and tell() know about + // this, see demux(). + function Field(ids, lightids) { + return { + touch: [NoteOn, ids], + release: [NoteOff, ids], + light: Light(lightids) + }; + } + + return { + modeset: { + // Byte three is the channel + version: [0xF0, 0x7E, 0x00, 0x06, 0x01, 0xF7], + flat: [0xF0, 0x00, 0x01, 0x60, 0x10, 0x00, 0xF7], + circle: [0xF0, 0x00, 0x01, 0x60, 0x01, 0x00, 0xF7], + slider: [0xF0, 0x00, 0x01, 0x60, 0x01, 0x03, 0xF7], + button: [0xF0, 0x00, 0x01, 0x60, 0x01, 0x04, 0xF7], + // Byte 6 sets the channel + channel: [0xF0, 0x00, 0x01, 0x60, 0x02, 0x00, 0xF7] + }, + logo: Logo(), + decklight: [ + Decklight(0x71), // A + Decklight(0x72) // B + ], + gain: Slider(0x07, 0x34, 9), + pitch: LightSlider(0x03, 0x3F, 9), + mode: { + fx: Touch(0x20), + loop: Touch(0x22), + vinyl: Touch(0x24), + eq: Touch(0x26), + trig: Touch(0x28), + deck: Touch(0x2A), + }, + top: { + left: Touch(0x2C), + right: Touch(0x2E) + }, + slider: { + circle: Slider(0x62, 0x5d, 16), + left: Slider(0x0C, 0x48, 7), + middle: Slider(0x01, 0x56, 7), + right: Slider(0x0E, 0x4F, 7) + }, + field: [ + Touch([0x48, 0x4A], [0x61, 0x62, 0x63]), + Touch([0x4C, 0x4E], [0x5E, 0x5F, 0x60]), + Touch([0x4F, 0x51], [0x66, 0x67, 0x68]), + Touch([0x53, 0x55], [0x69, 0x6A, 0x6B]), + Touch(0x01, [0x64, 0x65, 0x5D, 0x6C]) + ], + bottom: { + left: Touch(0x30), + right: Touch(0x32) + }, + button: { + play: Touch(0x6D), + cue: Touch(0x6E), + sync: Touch(0x6F), + tap: Touch(0x70), + } + }; +}; + +// debugging helper +var printmess = function(message, text) { + var i; + var s = ''; + + for (i in message) { + s = s + ('0' + message[i].toString(16)).slice(-2); + } + print("Midi " + s + (text ? ' ' + text : '')); +}; + + + +SCS3D.Comm = function() { + // Build a control identifier (CID) from the first two message bytes. + function CID(message) { + return (message[0] << 8) + message[1]; + } + + // Static state of the LED, indexed by CID + // This keeps the desired state before modifiers, so that adding + // or removing modifiers is possible without knowing the base state. + var base = {}; + + // Modifier functions over base, indexed by CID + // The functions receive the last byte of the message and return the + // modified value. + var mask = {}; + + // List of masks that depend on time + var ticking = {}; + + // CID that may need updating + var dirty = {}; + + // Last sent messages, indexed by CID + var actual = {}; + + // Tick counter + var ticks = 0; + + // Handler functions indexed by CID + var receivers = {}; + + // List of handlers for control changes from the engine + var watched = {}; + + // Last sent SYSEX message + var actual_sysex = []; + + // List of functions to call on each tick + var repeats = []; + + function send() { + for (var cid in dirty) { + var message = base[cid]; + if (!message) continue; // As long as no base is set, don't send anything + + var last = actual[cid]; + + var value = +message[2]; + if (mask[cid]) { + value = mask[cid](value, ticks); + } + if (last === undefined || last !== value) { + midi.sendShortMsg(message[0], message[1], value); + actual[cid] = value; + } + } + dirty = {}; + } + + return { + base: function(message, force) { + var cid = CID(message); + + base[cid] = message; + dirty[cid] = true; + + if (force) { + delete actual[cid]; + } + }, + + mask: function(message, mod, changes) { + var cid = CID(message); + mask[cid] = mod; + dirty[cid] = true; + + if (changes) ticking[cid] = true; + }, + + unmask: function(message) { + var cid = CID(message); + if (mask[cid]) { + delete mask[cid]; + dirty[cid] = true; + } + }, + + repeat: function(rep) { + repeats.push(rep); + }, + + tick: function() { + for (var i in repeats) { + repeats[i](ticks); + } + for (var cid in ticking) { + dirty[cid] = true; + } + send(); + ticks += 1; + }, + + clear: function() { + receivers = {}; + ticking = {}; + repeats = []; + for (var cid in mask) { + dirty[cid] = true; + } + mask = {}; + // base and actual are not cleared + + // I'd like to disconnect all controls on clear, but that doesn't + // work when using closure callbacks. So we just don't listen to + // those + for (var ctrl in watched) { + if (watched.hasOwnProperty(ctrl)) { + watched[ctrl] = []; + } + } + }, + + expect: function(message, handler) { + if (!message || message.length < 2) print("ERROR: invalid message to expect: " + message); + var cid = CID(message); + if (receivers[cid]) return; // Don't steal + receivers[cid] = handler; + }, + + receive: function(type, control, value) { + var cid = CID([type, control]); + var handler = receivers[cid]; + if (handler) { + handler(value); + send(); + } + }, + + watch: function(channel, control, handler) { + var ctrl = channel + control; + + if (!watched[ctrl]) { + watched[ctrl] = []; + engine.connectControl(channel, control, function(value, group, control) { + var handlers = watched[ctrl]; + if (handlers.length) { + // Fetching parameter value is easier than mapping to [0..1] range ourselves + value = engine.getParameter(group, control); + + var i = 0; + for (; i < handlers.length; i++) { + handlers[i](value); + } + send(); + } + }); + } + + watched[ctrl].push(handler); + + engine.trigger(channel, control); + }, + + sysex: function(message) { + if (message.length === actual_sysex.length) { + var same = true; + for (var i in message) { + same = same && message[i] === actual_sysex[i]; + } + if (same) return; + } + + midi.sendSysexMsg(message, message.length); + actual_sysex = message; + } + }; +}; + + +// Create a function that sets the rate of each channel by the timing between +// calls +SCS3D.Syncopath = function() { + // Lists of last ten taps, per deck, in epoch milliseconds + var deckTaps = {}; + + return function(channel) { + var now = new Date().getTime(); + var taps = deckTaps[channel] || []; + + var last = taps[0] || 0; + var delta = now - last; + + // Reset when taps are stale + if (delta > 2000) { + deckTaps[channel] = [now]; + return; + } + + taps.unshift(now); + taps = taps.slice(0, 8); // Keep last eight + deckTaps[channel] = taps; + + // Don't set rate until we have enough taps + if (taps.length < 3) return; + + // Calculate average bpm + var intervals = taps.length - 1; + var beatLength = (taps[0] - taps[intervals]) / intervals; + var bpm = 60000 / beatLength; // millis to 1/minutes + + // The desired pitch rate depends on the BPM of the track + var rate = bpm / engine.getValue(channel, "file_bpm"); + + // Balk on outlandish rates + if (isNaN(rate) || rate < 0.05 || rate > 50) return; + + // Translate rate into pitch slider position + // This depends on the configured range of the slider + var pitchPos = (rate - 1) / engine.getValue(channel, "rateRange"); + + engine.setValue(channel, "rate", pitchPos); + }; +}; + + +SCS3D.Agent = function(device) { + + // Multiple controller ID may be specified in the MIDI messages used + // internally. The output functions will demux and run the same action on + // both messages. + // + // demux(function(message) { print message; })(['hello', ['me', 'you']]) + // -> hello,me + // -> hello,you + function demux(action) { + return function(message, nd) { + if (!message || message.length < 2) { + print("ERROR: demux over invalid message: " + message); + return false; + } + var changed = false; + if (message[1].length) { + var i; + for (i in message[1]) { + var demuxd = [message[0], message[1][i], message[2]]; + changed = action(demuxd, nd) || changed; + } + } else { + changed = action(message, nd); + } + return changed; + }; + } + + var comm = SCS3D.Comm(); + var taps = SCS3D.Syncopath(); + + function expect(control, handler) { + demux(function(control) { + comm.expect(control, handler); + })(control); + } + + function watch(channel, control, handler) { + comm.watch(channel, control, handler); + } + + function watchmulti(controls, handler) { + var values = {}; + var wait = 0; + + var watchPos = function(valuePos) { + watch(controls[k][0], controls[k][1], function(value) { + values[valuePos] = value; + + // Call handler once all values are collected + // The simplistic wait countdown works because watch() + // triggers all controls and they answer in series + if (wait > 1) { + wait -= 1; + } else { + handler(values); + } + }); + }; + + for (var k in controls) { + wait += 1; + watchPos(k); + } + } + + // Send MIDI message to device + // Param message: list of three MIDI bytes + // Param force: send value regardless of last recorded state + var tell = demux(function(message, force) { + comm.base(message, force); + }); + + // Map engine values in the range [0..1] to lights + // translator maps from [0..1] to a midi message (three bytes) + function patch(translator) { + return function(value) { + tell(translator(value)); + }; + } + + // Patch multiple + function patchleds(translator) { + return function(value) { + var msgs = translator(value); + for (var i in msgs) { + if (msgs.hasOwnProperty(i)) tell(msgs[i]); + } + }; + } + + function binarylight(off, on) { + return function(value) { + tell(value ? on : off); + }; + } + + // Return a handler that lights one LED depending on value + function Needle(lights) { + var range = lights.length - 1; + return function(value) { + // Where's the needle? + // On the first light for zero values, on the last for one. + var pos = null; + if (value !== null) { + pos = Math.min(range, Math.round(value * range)); + } + + var i = 0; + for (; i <= range; i++) { + var light = lights[i]; + var on = i === pos; + tell([light[0], light[1], +on]); + } + }; + } + + // Return a handler that lights LED from the center of the meter + function Centerbar(lights) { + var count = lights.length; + var range = count - 1; + var center = Math.round(count / 2) - 1; // Zero-based + return function(value) { + var pos = Math.max(0, Math.min(range, Math.round(value * range))); + var left = Math.min(center, pos); + var right = Math.max(center, pos); + var i = 0; + for (; i < count; i++) { + var light = lights[i]; + var on = i >= left && i <= right; + tell([light[0], light[1], +on]); + } + }; + } + + + function CircleCenterbar(lights) { + var count = lights.length; + var range = count - 1; + var center = range / 2; // Zero-based + return function(value) { + var pos = Math.max(0, Math.min(range, (1 - value) * range)); + var left = Math.min(center, pos); + var right = Math.max(center, pos); + + var i = 0; + for (; i < count; i++) { + var light = lights[i]; + var on = i >= left && i <= right; + tell([light[0], light[1], +on]); + } + }; + } + + // Return a handler that lights LED from the bottom of the meter + // For zero values no light is turned on + function Bar(lights) { + var count = lights.length; + var range = count - 1; + return function(value) { + var pos; + if (value === 0) { + pos = -1; // no light + } else { + pos = Math.max(0, Math.min(range, Math.round(value * range))); + } + var i = 0; + for (; i < lights.length; i++) { + var light = lights[i]; + var on = i <= pos; + tell([light[0], light[1], +on]); + } + }; + } + + // Create a function that returns a constant value + var constant = function(val) { + var constant = val; + return function() { + return constant; + }; + }; + + // Light leds according to function + function lightsmask(lights, maskfunc) { + var mask = function(nr) { + return function(value, ticks) { + return maskfunc(lights.length, nr, value, ticks); + }; + }; + + for (var nr in lights) { + var light = lights[lights.length - 1 - nr]; + comm.mask(light, mask(nr), true); + } + } + + // Light leds in the circle according to pattern + // Pattern is a two-dimensional array 3 x 7 of bools + function centerlights(pattern, rate) { + var slidernames = ['left', 'middle', 'right']; + for (var y in slidernames) { + var lights = device.slider[slidernames[y]].meter; + for (var x in lights) { + var light = lights[lights.length - 1 - x]; + var pat = pattern[x][y]; + if (pat.length) { + // It moves! + comm.mask([light[0], light[1]], Blinker(rate, pat), true); + } else { + comm.mask([light[0], light[1]], constant(pat), false); + } + } + } + } + + + // Create a function that returns the value or its boolean inverse + // First parameter controls the blink rate where bigger is slower + // (starts at 1; 2 is half the speed) + // Second parameter provides a blink pattern which is a list of bits + function Blinker(rate, pattern) { + return function(value, ticks) { + return pattern[Math.floor(ticks / rate) % pattern.length] ? !value : value; + }; + } + + var blinken = { + fast: new Blinker(1, [1, 0]), + heartbeat: new Blinker(1, [1, 0, 1, 0, 0, 0, 0, 0, 0]), + }; + + // Show a spinning light in remembrance of analog technology + function spinLight(channel, warnDuration) { + watchmulti({ + 'position': [channel, 'playposition'], + 'duration': [channel, 'duration'], + 'play': [channel, 'play'], + 'play_indicator': [channel, 'play_indicator'], + 'rate': [channel, 'rate'], + 'range': [channel, 'rateRange'] + }, function(values) { + // Duration is not rate-corrected + var duration = values.duration; + + // Which means the seconds we get are not rate-corrected either. + // They tick faster for higher rates. + var seconds = duration * values.position; + + // 33⅓rpm = 100 / 3 / 60 rounds/second = 1.8 seconds/round + var rounds = seconds / 1.8; + + // Fractional part is needle's position in the circle + // Light addressing starts bottom left, add offset so it starts at top like the spinnies + var needle = (rounds + 0.5) % 1; + + var lights = device.slider.circle.meter; + var count = lights.length; + var pos = false; + + // Don't show position indicator when the end is reached + if (values.play_indicator) { + pos = count - Math.floor(needle * count) - 1; // Zero-based index + } + + // Add a warning indicator for the last seconds of a song + var left = duration - seconds; + + // Because the seconds are not rate-corrected, we must scale + // warnDuration according to pitch rate. + var scaledWarnDuration = warnDuration + warnDuration * ((values.rate - 0.5) * 2 * values.range); + var warnPos = false; + if (values.play_indicator && left < scaledWarnDuration) { + // Add a blinking light that runs a tad slower so the needle + // will reach it when the track runs out + var warnOffset = count - Math.floor(count * (left / scaledWarnDuration)); + warnPos = (pos + warnOffset) % count; + } + + var i = 0; + for (; i < count; i++) { + if (i === warnPos) { + comm.mask(lights[i], values.play ? blinken.heartbeat : blinken.fast, true); + } else if (i === pos) { + comm.mask(lights[i], function(value) { + return !value; + }); // Invert + } else { + comm.unmask(lights[i]); + } + } + }); + } + + + function both(c1, c2) { + return function(value) { + c1(value); + c2(value); + }; + } + + function invert(handler) { + return function(value) { + return handler(1 - value); + }; + } + + + // absolute control + function set(channel, control) { + return function(value) { + engine.setParameter(channel, control, + value / 127 + ); + }; + } + + + // use circle as a fader with dead zone to avoid accidental cutover + function circleset(channel, control) { + return function(value) { + var turned = (value / 127 + 0.5) % 1; + var centered = (turned - 0.5) * 20 / 16; + var normalized = Math.max(0, Math.min(1, centered + 0.5)); + engine.setParameter(channel, control, normalized); + }; + } + + function setConst(channel, control, value) { + return function() { + engine.setParameter(channel, control, value); + }; + } + + function reset(channel, control) { + return function() { + engine.reset(channel, control); + }; + } + + // relative control + function budge(channel, control, scale) { + var length = 128 / (scale || 1); + return function(offset) { + engine.setValue(channel, control, + engine.getValue(channel, control) + (offset - 64) / length + ); + }; + } + + // switch + function toggle(channel, control) { + return function() { + engine.setValue(channel, control, !engine.getValue(channel, control)); + }; + } + + function Switch() { + var engaged = false; + + function change(state) { + var prev = engaged; + engaged = !!state; // Coerce to bool + return engaged !== prev; + } + return { + 'change': function(state) { + return change(state); + }, + 'engage': function() { + return change(true); + }, + 'cancel': function() { + return change(false); + }, + 'toggle': function() { + return change(!engaged); + }, + 'engaged': function() { + return engaged; + }, + 'choose': function(off, on) { + return engaged ? on : off; + } + }; + } + + function Modeswitch(presetMode, patches) { + var engaged = presetMode; + + return { + engage: function(newMode) { + return function() { + if (engaged === newMode) return false; + engaged = newMode; + return true; + }; + }, + engaged: function() { + return engaged; + }, + patch: function() { + return patches[engaged]; + } + }; + } + + function MultiModeswitch(presetMode, modePatches) { + var engagedMode = presetMode; + var engagedPatch = modePatches[engagedMode][0]; + + // For every mode, keep the patch that was engaged last + var engaged = {}; + engaged[presetMode] = engagedPatch; + + var heldMode = false; + var heldPatch = false; + var lastHold = 0; + + return { + hold: function(newHeldMode) { + return function() { + heldMode = newHeldMode; + heldPatch = engaged[heldMode]; + if (!heldPatch) heldPatch = modePatches[heldMode][0]; + + lastHold = new Date().getTime(); + return true; + }; + }, + release: function(releasedMode) { + return function() { + if (releasedMode === heldMode || releasedMode === true) { + if (new Date().getTime() - lastHold < 200) { + // The button was just touched, not held + var patches = modePatches[heldMode]; + if (engagedMode === heldMode) { + // Cycle to the next patch + engagedPatch = patches[(patches.indexOf(engagedPatch) + 1) % patches.length]; + engaged[heldMode] = engagedPatch; + } else { + // Switch to the mode + engagedMode = heldMode; + engagedPatch = heldPatch; + engaged[heldMode] = heldPatch; + } + } + } + heldMode = false; + heldPatch = false; + return true; + }; + }, + held: function() { + return heldMode; + }, + engaged: function() { + return engagedMode; + }, + active: function() { + return heldPatch || engagedPatch; + } + }; + } + + + var ExpectHeld = function(ctrl, whenShort, whenHeld) { + var lastHold = 0; + expect(ctrl.touch, function() { + lastHold = new Date().getTime(); + }); + expect(ctrl.release, function(val) { + if (new Date().getTime() > lastHold + 1000) { + return whenHeld(val); + } else { + return whenShort(val); + } + }); + }; + + // The current deck + // Deck 1: 0b00 + // Deck 2: 0b01 + // Deck 3: 0b10 + // Deck 4: 0b11 + var deck = 0; // Deck 1 is preset + + // Glean current channels from control value + function gleanChannel(value) { + var readDeck; + var changed = false; + + // check third bit and proceed if it's set + // otherwise the control is assumed not to carry deck information + if (value & 0x4) { + // I don't often get the pleasure to work with bits + // Sure a simple if() cluster would be more clear + + // Get side we're on (1 == right) + var side = deck & 1; + + // Which bit to read (read bit 2 for right) + var altBit = 1 << side; + + // Whether the main or the alt deck is selected on the SCS3M + var alt = !!(value & altBit); + + // construct new deck value + var newDeck = side | alt << 1; + + changed = newDeck !== deck; + deck = newDeck; + } + + if (changed) { + // Prevent stuck mode buttons on deck switch + mode[0].release(true); + mode[1].release(true); + mode[2].release(true); + mode[3].release(true); + } + return changed; + } + + function repatch(handler) { + return function(value) { + var changed = handler(value); + if (changed) { + comm.clear(); + patchage(); + } + }; + } + + var buttons = [device.top.left, device.top.right, device.bottom.left, device.bottom.right]; + + var deckLights = function() { + for (var i in buttons) { + tell(buttons[i].light[deck === +i ? 'red' : 'black']); + } + }; + + var FxPatch = function(nr) { + return function(channel, held) { + var effectunit = '[EffectRack1_EffectUnit' + (nr + 1) + ']'; + var effectunit_effect = '[EffectRack1_EffectUnit' + (nr + 1) + '_Effect1]'; + comm.sysex(device.modeset.slider); + + // The first three effect parameters are mapped onto the circle sliders + var params = { + 'parameter1': device.slider.left, + 'parameter2': device.slider.middle, + 'parameter3': device.slider.right, + }; + + var sliderPatch = function(slider, paramName) { + expect(slider.slide.abs, set(effectunit_effect, paramName)); + + // When the control is available for this effect unit, we + // show a needle indicator on the meter + var updater = Needle(slider.meter); + watchmulti({ + loaded: [effectunit_effect, paramName+'_loaded'], + value: [effectunit_effect, paramName] + }, function(param) { + if (param.loaded) { + updater(param.value); + } else { + // Don't display the needle + updater(null); + } + }); + }; + + for (var paramName in params) { + sliderPatch(params[paramName], paramName); + } + + if (held) { + // change effect when slider is touched + // Because the slider release does not tell us where it was released, we read the direction from the slide events + var direction = 1; + expect(device.pitch.slide.abs, function(value) { + direction = value < 64 ? 1 : -1; + }); + expect(device.pitch.release, function() { + setConst(effectunit, 'chain_selector', direction)(); + }); + + Bar(device.pitch.meter)(0); // Turn off pitch bar lights + lightsmask(device.pitch.meter, function(count, nr, value, ticks) { + var range = (count) / 2; + var center = Math.round(count / 2) - 1; // Zero-based + var lighted = Math.abs(nr - center) === ticks % range; + return lighted ? !value : value; + }); + } else { + // Pitch slider controls wetness + tell(device.pitch.light.red.off); + tell(device.pitch.light.blue.off); + watch(effectunit, 'mix', Bar(device.pitch.meter)); + expect(device.pitch.slide.abs, set(effectunit, 'mix')); + } + + // Button light color: + // When effect is assigned to deck: blue + // When effect is the currently active: red + // May be both + var fxlight = function(light, active) { + return function(enabled) { + var color = enabled ? (active ? 'purple' : 'blue') : (active ? 'red' : 'black'); + tell(light[color]); + }; + }; + + for (var i in buttons) { + var button = buttons[i]; + var unit = (+i + 1); // coerce i to num + var assigned_effectunit = '[EffectRack1_EffectUnit' + unit + ']'; + var effectunit_enable = 'group_' + channel + '_enable'; + if (held) { + expect(button.touch, repatch(toggle(assigned_effectunit, effectunit_enable))); + watch(assigned_effectunit, effectunit_enable, fxlight(button.light, deck === +i)); + } else { + var activate = repatch(effectModes[channel].engage(i)); + expect(button.touch, activate); + watch(assigned_effectunit, effectunit_enable, fxlight(button.light, nr === +i)); + } + } + }; + }; + + // Active effect mode per channel + var effectPatches = [FxPatch(0), FxPatch(1), FxPatch(2), FxPatch(3)]; + var effectModes = { + '[Channel1]': Modeswitch(0, effectPatches), + '[Channel2]': Modeswitch(1, effectPatches), + '[Channel3]': Modeswitch(2, effectPatches), + '[Channel4]': Modeswitch(3, effectPatches) + }; + + var FxSuperPatch = function(nr) { + return function(channel, held) { + var effectunit = '[EffectRack1_EffectUnit' + (nr + 1) + ']'; + comm.sysex(device.modeset.circle); + watch(effectunit, 'super1', CircleCenterbar(device.slider.circle.meter)); + expect(device.slider.circle.slide.abs, circleset(effectunit, 'super1')); + }; + }; + + // Active effect mode per channel + var effectSuperPatches = [FxSuperPatch(0), FxSuperPatch(1), FxSuperPatch(2), FxSuperPatch(3)]; + var effectSuperModes = { + '[Channel1]': Modeswitch(0, effectSuperPatches), + '[Channel2]': Modeswitch(1, effectSuperPatches), + '[Channel3]': Modeswitch(2, effectSuperPatches), + '[Channel4]': Modeswitch(3, effectSuperPatches) + }; + + + function fxpatch(channel, held) { + tell(device.mode.fx.light.red); + effectModes[channel].patch()(channel, held); + } + + function fxsuperpatch(channel, held) { + tell(device.mode.fx.light.blue); + effectSuperModes[channel].patch()(channel, held); + } + + function eqpatch(channel, held) { + comm.sysex(device.modeset.slider); + tell(device.mode.eq.light.red); + pitchPatch(channel); + + var eff = "[EqualizerRack1_" + channel + "_Effect1]"; + watch(eff, 'parameter1', Centerbar(device.slider.left.meter)); + watch(eff, 'parameter2', Centerbar(device.slider.middle.meter)); + watch(eff, 'parameter3', Centerbar(device.slider.right.meter)); + + var op = held ? reset : set; + expect(device.slider.left.slide.abs, op(eff, 'parameter1')); + expect(device.slider.middle.slide.abs, op(eff, 'parameter2')); + expect(device.slider.right.slide.abs, op(eff, 'parameter3')); + + + deckLights(); + } + + function LoopPatch(rolling) { + return function(channel) { + // Keeps the current loop length + var currentLen = false; + + var cancel = function() { + if (currentLen) setConst(channel, 'reloop_exit', 1)(); + currentLen = false; + }; + + var setup = function(engage, cancelIfEngaged) { + comm.sysex(device.modeset.circle); + tell(rolling ? device.mode.loop.light.blue : device.mode.loop.light.red); + pitchPatch(channel); + deckLights(); + beatJump(channel); + + // Available loop lengths are powers of two in the range [-5..6] + var lengths = ['0.03125', '0.0625', '0.125', '0.25', '0.5', '1', '2', '4', '8', '16', '32', '64']; + expect(device.slider.circle.slide.abs, function(value) { + // Map to range [-63..64] where 0 is top center + var lr = ((value + 64) % 128 - 63); + + // Map the circle slider position to a loop length + var exp = Math.ceil(Math.max(-5, Math.min(6, lr / 8))); + var len = lengths[4 + exp]; // == Math.pow(2, exp); + + if (len === undefined) return; + if (len === currentLen) return; + currentLen = len; + + if (rolling) { + set(channel, 'beatlooproll_' + len + '_activate')(true); + engage(); + } else { + set(channel, 'beatloop_' + len + '_activate')(true); + } + }); + + var engineControls = {}; + lengths.forEach(function(len, index) { + engineControls[index] = [channel, 'beatloop_' + len + '_enabled']; + }); + watchmulti(engineControls, function(values) { + var activeIndex = false; + lengths.forEach(function(len, index) { + if (values[index]) { + currentLen = len; + activeIndex = index; + } + }); + if (activeIndex === false) { + // Turn off all lights + Bar(device.slider.circle.meter)(0); + currentLen = false; + } else { + Centerbar(device.slider.circle.meter)( + (12.5 - activeIndex) / 16 + ); + } + }); + + // Rolling loops are released as soon as the finger is taken off the circle + // All loops are released when touching center + // This covers the case when you are in rolling mode but a nonrolling loop is playing and you want to release that one by touching center + if (rolling) { + expect(device.slider.circle.release, cancelIfEngaged); + expect(device.slider.middle.release, cancel); + } else { + // In normal loop mode, touching center when the loop is + // not active engages it + expect(device.slider.middle.release, setConst(channel, 'reloop_exit', 1)); + } + + watchmulti({ + enabled: [channel, 'loop_enabled'], + position: [channel, 'playposition'], + start: [channel, 'loop_start_position'], + end: [channel, 'loop_end_position'], + samples: [channel, 'track_samples'] + }, function(values) { + if (values.enabled) { + var samplepos = values.position * values.samples; + var pos = Math.floor((samplepos - values.start) / (values.end - values.start) * 8); + centerlights([ + [0, 0, 0], + [0, pos === 0 ? 0 : 1, 0], + [pos === 7 ? 0 : 1, 0, pos === 1 ? 0 : 1], + [pos === 6 ? 0 : 1, 0, pos === 2 ? 0 : 1], + [pos === 5 ? 0 : 1, 0, pos === 3 ? 0 : 1], + [0, pos === 4 ? 0 : 1, 0], + [0, 0, 0] + ], 2); + } else { + centerlights([ + [0, 0, 0], + [0, 0, 0], + [1, 0, rolling ? 0 : 1], + [1, 1, 1], + [1, 0, rolling ? 0 : 1], + [0, 0, 0], + [0, 0, 0] + ], 2); + } + }); + + }; + + if (rolling) { + // The rolling loop will be canceled as soon as you release the circle + // or switch to another mode. + Autocancel('rolling', setup, cancel); + } else { + // Normal loops are canceled only by touching center + setup(false, false); + } + }; + } + + // Keep track of hotcue to reset on layout changes or when another hotcue + // becomes active. + var resetHotcue = false; + + /* Patch circle buttons to five hotcues + * + * left top = hotcue 1 + * left bottom = hotcue 2 + * right top = hotcue 3 + * right bottom = hotcue 4 + * center strip = hotcue 5 + * + * The trigset parameter selects the set of hotcues to + * activate. Passing 0 selects hotcues 1 through 5, passing 2 + * selects hotcues 11 through 15. + */ + function Trigpatch(trigset) { + var touchRelease = function(channel, field, control) { + // We need to send a zero when the control is released again. + // But this may happen after another control is touched. + // So the reset must happen whenever + // 1. this trigger button is released + // 2. another trigger button is touched + // 3. repatch() happens + // + // To avoid sending spurious resets we only do reset once after a + // touch. Unfortunately there is a border case we can't cover + // without intricate logic. For older devices two fields are + // merged into one but we receive note on then off when sliding + // between the two. + var cocked = 0; + + var release = function() { + if (cocked === 1) engine.setValue(channel, control, 0); + if (cocked > 0) cocked -= 1; + }; + expect(field.touch, function() { + // resetHotcue might be set by another hotcue + if (cocked === 0) { + if (resetHotcue) resetHotcue(); + engine.setValue(channel, control, 1); + } + resetHotcue = function() { + release(); + resetHotcue = false; + }; + cocked += 1; + + }); + expect(field.release, release); + }; + + return function(channel, held) { + comm.sysex(device.modeset.button); + tell(device.mode.trig.light.bits(trigset + 1)); + pitchPatch(channel); + deckLights(); + + var i = 0; + var offset = trigset * 5; + for (; i < 5; i++) { + var hotcue = offset + i + 1; + var field = device.field[i]; + if (held) { + expect(field.touch, setConst(channel, 'hotcue_' + hotcue + '_clear', true)); + } else { + touchRelease(channel, field, 'hotcue_' + hotcue + '_activate'); + } + watch(channel, 'hotcue_' + hotcue + '_enabled', binarylight(field.light.black, field.light.red)); + } + + var t = held ? [1, 0] : 1; + centerlights([ + [0, 0, 0], + [t, trigset === 2 ? t : 0, t], + [0, trigset === 1 ? t : 0, 0], + [0, t, 0], + [0, trigset === 1 ? t : 0, 0], + [t, trigset === 2 ? t : 0, t], + [0, 0, 0] + ], 1); + }; + } + + // On mode switch, temporary loops and rate changes must be canceled + // This dictionary keeps the canceling callbacks + var autocancel = {}; + + // Registrar for modes that have temporary states to be canceled on mode changes + // Arguments: + // name: register autocanceling under this name + // Only the last canceling operation registered under this name will be called + // setup: setup function that configures the mode + // this functions gets passed two arguments: engage and cancelIfEngaged. + // when the temp mode is activated, setup should call engage() + // when the temp mode should be canceled, cancelIfEngaged() which in turn will call the cancel callback passed to the registrar if (and only if) the temp mode was activated. + // cancel: function to call to cancel the temp mode + function Autocancel(name, setup, cancel) { + // Nausea: The feeling you're implementing overcomplicated logic + var engage = function() { + autocancel[name] = cancel; + }; + var cancelIfEngaged = function() { + if (autocancel[name]) autocancel[name](); + delete autocancel[name]; + }; + setup(engage, cancelIfEngaged); + } + + function needleDrop(channel) { + // Needledrop into track + expect(device.slider.circle.slide.abs, circleset(channel, "playposition")); + watch(channel, "playposition", invert(Bar(device.slider.circle.meter))); + } + + function beatJump(channel) { + expect(device.top.left.touch, setConst(channel, 'beatjump_1_backward', 1)); + expect(device.top.right.touch, setConst(channel, 'beatjump_1_forward', 1)); + } + + function scratchpatch(channel, held) { + comm.sysex(device.modeset.circle); + tell(device.mode.vinyl.light.blue); + pitchPatch(channel); + + // The four buttons select pitch slider mode when vinyl is held + if (held) { + pitchModeSelect(); + needleDrop(channel); + } else { + deckLights(); + } + + var lights = function(forward) { + centerlights([ + [forward ? 1 : 0, 1, forward ? 0 : 1], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [forward ? 0 : 1, 1, forward ? 1 : 0] + ], 1); + }; + lights(true); + + // HACK ugly logic to avoid restructuring + if (held) return; + + var channelno = parseInt(channel[8], 10); // Extract channelno to integer + Autocancel('scratch', function(engage, cancelIfEngaged) { + expect(device.slider.circle.touch, function() { + engage(); + engine.scratchEnable(channelno, 128, 33 + 1 / 3, 1 / 8, 1 / 8 / 32); + }); + expect(device.slider.middle.touch, function() { + engage(); + engine.scratchEnable(channelno, 128, 33 + 1 / 3, 1 / 16, 1 / 16 / 32); + }); + expect(device.slider.circle.slide.rel, function(val) { + engine.scratchTick(channelno, val - 64); + lights(val > 63); + }); + expect(device.slider.middle.slide.rel, function(val) { + engine.scratchTick(channelno, val - 64); + lights(val > 63); + }); + expect(device.slider.circle.release, cancelIfEngaged); + expect(device.slider.middle.release, cancelIfEngaged); + }, function() { + engine.scratchDisable(channelno, true); + }); + } + + + /* Patch the circle for beatmatching. + * Sliding on the center bar will temporarily raise or lower the rate by a + * fixed amount. The circle slider functions as a slow jog wheel. + */ + function vinylpatch(channel, held) { + comm.sysex(device.modeset.circle); + pitchPatch(channel); + beatJump(channel); + + // The four buttons select pitch slider mode when vinyl is held + if (held) { + pitchModeSelect(); + needleDrop(channel); + } else { + deckLights(); + + expect(device.slider.circle.slide.rel, function(value) { + var playing = engine.getValue(channel, 'play'); + var amount = (value - 64) / 64; + var jogAmount = playing ? amount * 10 : amount; + engine.setValue(channel, 'jog', jogAmount); + }); + } + + Autocancel('temprate', function(engage, cancel) { + expect(device.slider.middle.slide.abs, function(value) { + engage(); + engine.setParameter(channel, 'rate_temp_down', value < 57); + engine.setParameter(channel, 'rate_temp_up', value > 69); + }); + expect(device.slider.middle.release, cancel); + }, function() { + engine.setParameter(channel, 'rate_temp_down', false); + engine.setParameter(channel, 'rate_temp_up', false); + }); + + watchmulti({ + 'down': [channel, 'rate_temp_down'], + 'up': [channel, 'rate_temp_up'] + }, function(values) { + var dir = (values.up - values.down) / 2 + 0.5; + Centerbar(device.slider.left.meter)(dir - 0.1); + Centerbar(device.slider.middle.meter)(dir + 0.1); + Centerbar(device.slider.right.meter)(dir - 0.1); + }); + + Autocancel('fast', function(engage, cancel) { + expect(device.bottom.left.touch, both(engage, setConst(channel, 'back', 1))); + expect(device.bottom.left.release, cancel); + expect(device.bottom.right.touch, both(engage, setConst(channel, 'fwd', 1))); + expect(device.bottom.right.release, cancel); + }, function() { + setConst(channel, 'back', 0)(); + setConst(channel, 'fwd', 0)(); + }); + } + + + /* Patch the circle for library browsing + * Touching the center bar loads the highlighted track into the deck. + * Sliding om the circle changes the highlighted track up or down. + * + * Holding the deck button allows changing the active deck by pressing + * one of the four buttons around the circle. + */ + function deckpatch(channel, held) { + comm.sysex(device.modeset.circle); + pitchPatch(channel); + deckLights(); + + if (held) { + tell(device.mode.deck.light.purple); + var setDeck = function(newDeck) { + return function() { + var changed = deck !== newDeck; + if (changed) { + deck = newDeck; + + // see gleanChannel() for what we're doing here + var deckState = engine.getValue('[PreviewDeck1]', 'quantize'); + if (deckState & 0x4) { + var side = deck & 1; + var altBit = 1 << side; + var alt = !!(deck & 2); + + // shit gives me headaches, so I'm going to leave it like this + deckState = (deckState & ~altBit) | (alt << side); + engine.setValue('[PreviewDeck1]', 'quantize', deckState); + } + } + return changed; + }; + }; + expect(device.top.left.touch, repatch(setDeck(0))); + expect(device.top.right.touch, repatch(setDeck(1))); + expect(device.bottom.left.touch, repatch(setDeck(2))); + expect(device.bottom.right.touch, repatch(setDeck(3))); + } else { + tell(device.mode.deck.light.red); + expect(device.top.left.touch, setConst('[Playlist]', 'SelectPrevPlaylist', 1)); + expect(device.bottom.left.touch, setConst('[Playlist]', 'SelectNextPlaylist', 1)); + expect(device.top.right.touch, setConst('[Playlist]', 'SelectPrevTrack', 1)); + expect(device.bottom.right.touch, setConst('[Playlist]', 'SelectNextTrack', 1)); + } + + expect(device.slider.middle.release, function() { + engine.setValue(channel, 'LoadSelectedTrack', true); + }); + + expect(device.slider.circle.slide.rel, function(value) { + engine.setValue('[Playlist]', 'SelectTrackKnob', (value - 64)); + }); + + watch(channel, 'play', function(play) { + if (play) { + centerlights([ + [1, 1, 1], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0], + [0, 1, 0] + ], 1); + } else { + centerlights([ + [0, [1, 1, 1, 1, 1, 1, 0, 1, 1], 0], + [1, [1, 1, 1, 1, 0, 0, 1, 1, 1], 1], + [1, [1, 1, 1, 0, 0, 1, 1, 1, 1], 1], + [0, [1, 1, 0, 0, 1, 1, 1, 1, 1], 0], + [0, [1, 0, 0, 1, 1, 1, 1, 1, 1], 0], + [0, [1, 0, 1, 1, 1, 1, 1, 1, 1], 0], + [0, [0, 1, 1, 1, 1, 1, 1, 1, 1], 0] + ], 1); + } + }); + } + + var modeMap = { + 'fx': [fxpatch, fxsuperpatch], + 'eq': [eqpatch], + 'loop': [LoopPatch(false), LoopPatch(true)], + 'trig': [Trigpatch(0), Trigpatch(1), Trigpatch(2)], + 'vinyl': [vinylpatch, scratchpatch], + 'deck': [deckpatch], + }; + + + // mode for each channel + var mode = { + 0: MultiModeswitch('deck', modeMap), + 1: MultiModeswitch('deck', modeMap), + 2: MultiModeswitch('deck', modeMap), + 3: MultiModeswitch('deck', modeMap) + }; + + // Setup a process that keeps sliding a control by a rate that can be changed + var Sliding = function(channel, control) { + var slidingRate = 0; + var budge = function() { + if (slidingRate !== 0) { + engine.setValue(channel, control, + engine.getValue(channel, control) + slidingRate + ); + } + }; + comm.repeat(budge); + return function(newVal) { + var initial = slidingRate === 0; + slidingRate = newVal; + if (initial) budge(); + }; + }; + + var pitchModeSelect = function() { + var activePitchMode = pitchMode[deck]; + var engagedMode = activePitchMode.engaged(); + var pitchButtons = { + 'absrate': device.top.left, + 'pitch': device.top.right, + 'rate': device.bottom.left, + 'relpitch': device.bottom.right, + }; + for (var modeName in pitchButtons) { + var pitchButton = pitchButtons[modeName]; + expect(pitchButton.touch, repatch(activePitchMode.engage(modeName))); + tell(pitchButton.light[engagedMode === modeName ? 'blue' : 'black']); + } + }; + + var pitchModeMap = { + rate: function(channel, held) { + tell(device.pitch.light.red.on); + tell(device.pitch.light.blue.on); + watch(channel, 'rate', Centerbar(device.pitch.meter)); + + if (held) { + expect(device.pitch.slide.abs, reset(channel, 'rate')); + } else { + var setRate = Sliding(channel, 'rate'); + expect(device.pitch.slide.abs, function(value) { + var rate = (value - 63) / 32; + var sign = rate > 0 ? 1 : -1; + setRate( + sign * 0.01 * Math.pow(Math.abs(rate), 3) + ); + }); + expect(device.pitch.release, function(value) { + setRate(0); + }); + } + }, + absrate: function(channel, held) { + tell(device.pitch.light.red.on); + tell(device.pitch.light.blue.off); + watch(channel, 'rate', Centerbar(device.pitch.meter)); + + if (held) { + expect(device.pitch.slide.abs, reset(channel, 'rate')); + } else { + expect(device.pitch.slide.abs, set(channel, 'rate')); + } + }, + relpitch: function(channel, held) { + tell(device.pitch.light.red.off); + tell(device.pitch.light.blue.off); + watch(channel, 'pitch', Centerbar(device.pitch.meter)); + + if (held) { + expect(device.pitch.slide.rel, reset(channel, 'pitch')); + } else { + expect(device.pitch.slide.rel, budge(channel, 'pitch')); + } + }, + pitch: function(channel, held) { + tell(device.pitch.light.red.off); + tell(device.pitch.light.blue.on); + watch(channel, 'pitch', Centerbar(device.pitch.meter)); + if (held) { + expect(device.pitch.slide.rel, reset(channel, 'pitch')); + } else { + var direction = 1; + expect(device.pitch.slide.abs, function(val) { + direction = val < 64 ? -1 : 1; + }); + expect(device.pitch.release, function() { + engine.setValue(channel, 'pitch', Math.round(engine.getValue(channel, 'pitch') + direction)); + }); + } + } + }; + + // pitch slider mode per channel + var pitchMode = { + 0: Modeswitch('absrate', pitchModeMap), + 1: Modeswitch('absrate', pitchModeMap), + 2: Modeswitch('absrate', pitchModeMap), + 3: Modeswitch('absrate', pitchModeMap) + }; + + var pitchPatch = function(channel) { + var activePitchMode = pitchMode[deck]; + activePitchMode.patch()(channel, mode[deck].held() === 'vinyl'); + }; + + function patchage() { + tell(device.logo.on); + + var channelno = deck + 1; + var channel = '[Channel' + channelno + ']'; + + tell(device.decklight[0](!(deck & 1))); + tell(device.decklight[1](deck & 1)); + + for (var name in autocancel) { + autocancel[name](); + } + autocancel = {}; + if (resetHotcue) resetHotcue(); + + var activeMode = mode[deck]; + + // reset gain when EQ is held + var gainOp = activeMode.held() === 'eq' ? reset : budge; + expect(device.gain.slide.rel, gainOp(channel, 'pregain')); + watch(channel, 'pregain', Needle(device.gain.meter)); + + tell(device.mode.fx.light.black); + tell(device.mode.eq.light.black); + tell(device.mode.loop.light.black); + tell(device.mode.trig.light.black); + tell(device.mode.vinyl.light.black); + tell(device.mode.deck.light.black); + tell(device.mode[activeMode.engaged()].light.red); + if (activeMode.held()) tell(device.mode[activeMode.held()].light.purple); + expect(device.mode.fx.touch, repatch(activeMode.hold('fx'))); + expect(device.mode.fx.release, repatch(activeMode.release('fx'))); + expect(device.mode.eq.touch, repatch(activeMode.hold('eq'))); + expect(device.mode.eq.release, repatch(activeMode.release('eq'))); + expect(device.mode.loop.touch, repatch(activeMode.hold('loop'))); + expect(device.mode.loop.release, repatch(activeMode.release('loop'))); + expect(device.mode.trig.touch, repatch(activeMode.hold('trig'))); + expect(device.mode.trig.release, repatch(activeMode.release('trig'))); + expect(device.mode.vinyl.touch, repatch(activeMode.hold('vinyl'))); + expect(device.mode.vinyl.release, repatch(activeMode.release('vinyl'))); + expect(device.mode.deck.touch, repatch(activeMode.hold('deck'))); + expect(device.mode.deck.release, repatch(activeMode.release('deck'))); + + // Reset circle lights + Bar(device.slider.circle.meter)(0); + Bar(device.slider.left.meter)(0); + Bar(device.slider.middle.meter)(0); + Bar(device.slider.right.meter)(0); + + // Call the patch function for the active mode + activeMode.active()(channel, activeMode.held()); + + expect(device.button.play.touch, toggle(channel, 'play')); + watch(channel, 'play_indicator', binarylight( + device.button.play.light.black, + device.button.play.light.red)); + + + expect(device.button.cue.touch, setConst(channel, 'cue_default', true)); + expect(device.button.cue.release, setConst(channel, 'cue_default', false)); + watch(channel, 'cue_indicator', binarylight( + device.button.cue.light.black, + device.button.cue.light.red)); + + // Sync button, red when sync lock is on + watch(channel, 'sync_enabled', binarylight( + device.button.sync.light.black, + device.button.sync.light.red)); + + if (activeMode.held() === 'vinyl') { + // When VINYL is held, SYNC adjusts the beatgrid + // When the track is playing, the beatgrid is synced to the other + // track (we assume the tracks were beatmatched and the other + // track's grid is fine). When the track is not playing, we align + // the grid with the current cursor position. + + // We don't know which control we have to release until + // the button is pressed, because the behaviour depends on + // whether the track is playing or not + var affectedSyncControl; + + Autocancel('beatsync', + function(engage, cancelIfEngaged) { + expect(device.button.sync.release, cancelIfEngaged); + expect(device.button.sync.touch, function() { + affectedSyncControl = engine.getValue(channel, 'play') ? 'beats_translate_match_alignment' : 'beats_translate_curpos'; + engine.setValue(channel, affectedSyncControl, true); + engage(); + }); + }, + function() { + engine.setValue(channel, affectedSyncControl, false); + } + ); + } else { + // Hold to toggle sync lock + ExpectHeld(device.button.sync, + setConst(channel, 'beatsync', true), + toggle(channel, 'sync_enabled') + ); + } + + expect(device.button.tap.touch, function() { + taps(channel); + }); + watch(channel, 'beat_active', binarylight(device.button.tap.light.black, device.button.tap.light.red)); + + spinLight(channel, 30); + + // Read deck state from unrelated control which may be set by the 3m + // Among all the things WRONG about this, two stand out: + // 1. The control is not meant to transmit this information. + // 2. A value > 1 is expected from a control which is just a toggle (suggesting a binary value) + // This may fail at any future or past version of Mixxx and you have only me to blame for it. + watch('[PreviewDeck1]', 'quantize', repatch(gleanChannel)); + } + + var timer = false; + + return { + start: function() { + // Tell it to use channel 0 and set it to flat mode + comm.sysex(device.modeset.channel); + comm.sysex(device.modeset.flat); + + // Initial setup + patchage(); + if (!timer) timer = engine.beginTimer(100, comm.tick); + }, + receive: comm.receive, + stop: function() { + // No need for stopTimer() because it's done automatically + + // Forget all mods + comm.clear(); + + // Send off-message to all light addresses + var i = 0; + for (; i < 0x80; i++) { + tell([0x90, i, 0]); + } + tell(device.logo.on); // Turn the logo back on + comm.tick(); // The last tick, causes sending of messages + } + }; +}; diff --git a/res/controllers/Stanton-SCS3m-4deck-scripts.js b/res/controllers/Stanton-SCS3m-4deck-scripts.js new file mode 100644 index 000000000000..4ec3339d308c --- /dev/null +++ b/res/controllers/Stanton-SCS3m-4deck-scripts.js @@ -0,0 +1,940 @@ +"use strict"; +//////////////////////////////////////////////////////////////////////// +// JSHint configuration // +//////////////////////////////////////////////////////////////////////// +/* global engine */ +/* global print */ +/* global midi */ +/* global SCS3M:true */ +/* jshint -W097 */ +/* jshint -W084 */ +/* jshint laxbreak: true */ +//////////////////////////////////////////////////////////////////////// + +// manually test messages +// amidi -p hw:1 -S F00001601501F7 # flat mode +// amidi -p hw:1 -S 900302 # 90: note on, 03: id of a touch button, 02: red LED + +SCS3M = { + // The device remembers the selected EQ/FX mode per deck + // and switches to that mode on deck-switch. Set this to + // false if you prefer the mode to stay the same on + // deck-switch. + eqModePerDeck: true, +}; + +SCS3M.init = function(id) { + this.device = this.Device(); + this.agent = this.Agent(this.device); + this.agent.start(); +}; + +SCS3M.shutdown = function() { + SCS3M.agent.stop(); +}; + +SCS3M.receive = function(channel, control, value, status) { + SCS3M.agent.receive(status, control, value); +}; + +/* Midi map of the SCS.3m device + * + * Thanks to Sean M. Pappalardo for the original SCS3 mappings. + */ +SCS3M.Device = function() { + var NoteOn = 0x90; + var NoteOff = 0x80; + var CC = 0xB0; + var CM = 0xBF; /* this is used for slider mode changes (absolute/relative, sending a control change on channel 16!?) */ + + var black = 0x00; + var blue = 0x01; + var red = 0x02; + var purple = blue | red; + + function Logo() { + var id = 0x69; + return { + on: [NoteOn, id, 0x01], + off: [NoteOn, id, 0x00] + }; + } + + function Meter(id, lights) { + function plain(value) { + if (value <= 0.0) return 1; + if (value >= 1.0) return lights; + return 1 + Math.round(value * (lights - 1)); + } + + function clamped(value) { + if (value <= 0.0) return 1; + if (value >= 1.0) return lights; + return Math.round(value * (lights - 2) + 1.5); + } + + function zeroclamped(value) { + if (value <= 0.0) return 0; + if (value >= 1.0) return lights; + return Math.round(value * (lights - 1) + 0.5); + } + return { + needle: function(value) { + return [CC, id, plain(value)]; + }, + centerbar: function(value) { + return [CC, id, 0x14 + clamped(value)]; + }, + bar: function(value) { + return [CC, id, 0x28 + zeroclamped(value)]; + }, + expand: function(value) { + return [CC, id, 0x3C + zeroclamped(value)]; + }, + }; + } + + function Slider(id, lights, fields) { + var touchfields = {}; + for (var fieldn in fields) { + touchfields[fieldn] = { + touch: [NoteOn, fields[fieldn]], + release: [NoteOff, fields[fieldn]], + }; + } + return { + meter: Meter(id, lights), + slide: [CC, id], + mode: { + absolute: [[CM, id, 0x70], [CM, id, 0x7F]], + relative: [[CM, id, 0x71], [CM, id, 0x7F]], + }, + field: touchfields, + }; + } + + function Light(id) { + return { + black: [NoteOn, id, black], + blue: [NoteOn, id, blue], + red: [NoteOn, id, red], + purple: [NoteOn, id, purple], + }; + } + + function Touch(id) { + return { + light: Light(id), + touch: [NoteOn, id], + release: [NoteOff, id] + }; + } + + function Side(side) { + function either(left, right) { + return ('left' === side) ? left : right; + } + + function Deck() { + // The left deck button has a higher address than the right button, + // for all the other controls the left one has a lower address. + // I wonder why. + var id = either(0x10, 0x0F); + return { + light: function(bits) { + return [NoteOn, id, (bits[0] ? 1 : 0) | (bits[1] ? 2 : 0)]; + }, + touch: [NoteOn, id], + release: [NoteOff, id] + }; + } + + function Pitch() { + return Slider(either(0x00, 0x01), 7, { + left: either(0x51, 0x54), + middle: either(0x52, 0x55), + right: either(0x53, 0x56), + }); + } + + function Eq() { + return { + low: Slider(either(0x02, 0x03), 7), + mid: Slider(either(0x04, 0x05), 7), + high: Slider(either(0x06, 0x07), 7), + }; + } + + function Modes() { + return { + fx: Touch(either(0x0A, 0x0B)), + eq: Touch(either(0x0C, 0x0D)) + }; + } + + function Gain() { + return Slider(either(0x08, 0x09), 7); + } + + function Touches() { + return [ + Touch(either(0x00, 0x01)), + Touch(either(0x02, 0x03)), + Touch(either(0x04, 0x05)), + Touch(either(0x06, 0x07)), + ]; + } + + function Phones() { + return Touch(either(0x08, 0x09)); + } + + return { + deck: Deck(), + pitch: Pitch(), + eq: Eq(), + modes: Modes(), + gain: Gain(), + touches: Touches(), + phones: Phones(), + meter: Meter(either(0x0C, 0x0D), 7) + }; + } + + return { + factory: [0xF0, 0x00, 0x01, 0x60, 0x40, 0xF7], + flat: [0xF0, 0x00, 0x01, 0x60, 0x15, 0x01, 0xF7], + lightsoff: [CC, 0x7B, 0x00], + logo: Logo(), + left: Side('left'), + right: Side('right'), + master: Touch(0x0E), + crossfader: Slider(0x0A, 11) + }; +}; + +// debugging helper +var printmess = function(message, text) { + var i; + var s = ''; + + for (i in message) { + s = s + ('0' + message[i].toString(16)).slice(-2); + } + print("Midi " + s + (text ? ' ' + text : '')); +}; + +SCS3M.Agent = function(device) { + // Cache last sent bytes to avoid sending duplicates. + // The second byte of each message (controller id) is used as key to hold + // the last sent message for each controller. + var last = {}; + + // Queue of messages to send delayed after modeset() + var loading = false; + var throttling = false; + var pipe = []; + + // Handlers for received messages + var receivers = {}; + + // Connected engine controls + var watched = {}; + + function clear() { + receivers = {}; + pipe = []; + + // I'd like to disconnect everything on clear, but that doesn't work when using closure callbacks, I guess I'd have to pass the callback function as string name + // I'd have to invent function names for all handlers + // Instead I'm not gonna bother and just let the callbacks do nothing + for (var ctrl in watched) { + if (watched.hasOwnProperty(ctrl)) { + watched[ctrl] = []; + } + } + } + + // This function receives Midi messages from the SCS.3m + // See the XML mapping for all the messages caught + function receive(type, control, value) { + var address = (type << 8) + control; + var handler = receivers[address]; + if (handler) { + handler(value); + } + } + + // Register a handler to listen for messages + // control: an array with at least two message bytes (type and control id) + // handler: callback function that will be called each time a value is received + function expect(control, handler) { + var address = (control[0] << 8) + control[1]; + receivers[address] = handler; + } + + function watchRegister(channel, control, handler) { + // Indirection through a registry that keeps all watched controls + var ctrl = channel + control; + + if (!watched[ctrl]) { + watched[ctrl] = []; + engine.connectControl(channel, control, function(value, group, control) { + var handlers = watched[ctrl]; + for (var i in handlers) { + handlers[i](); + } + }); + } + + watched[ctrl].push(handler); + } + + // Register a handler for changes in engine values + // This is an abstraction over engine.getParameter() + function watch(channel, control, handler) { + watchRegister(channel, control, function() { + handler(engine.getParameter(channel, control)); + }); + + if (loading) { + // ugly UGLY workaround + // The device does not light meters again if they haven't changed from last value before resetting flat mode + // so we send each control some bullshit values which causes awful flicker during startup + // The trigger will then set things straight + handler(100); + handler(-100); + } + + engine.trigger(channel, control); + } + + // Register a handler for multiple engine values. It will be called + // everytime one of the values changes. + // controls: list of channel/control pairs to watch + // handler: will receive list of control values as parameter in same order + function watchmulti(controls, handler) { + var values = []; + var watchControl = function(controlpos, controlgroup) { + var channel = controlgroup[0]; + var control = controlgroup[1]; + values[controlpos] = engine.getParameter(channel, control); + watchRegister(channel, control, function() { + values[controlpos] = engine.getParameter(channel, control); + handler(values); + }); + }; + + for (var i in controls) { + watchControl(i, controls[i]); + } + handler(values); + } + + + // Send MIDI message to device + // Param message: list of three MIDI bytes + // Param force: send value regardless of last recorded state + // Param extra: do not record message as last state + // Returns whether the massage was sent + // False is returned if the mesage was sent before. + function send(message, force, extra) { + if (!message){ + print("SCS3 warning: send function received invalid message"); + return; // :-( + } + + var address = (message[0] << 8) + message[1]; + + if (!force && last[address] === message[2]) { + return false; // Not repeating same message + } + + midi.sendShortMsg(message[0], message[1], message[2]); + + // Record message as sent, unless it as was a mode setting termination message + if (!extra) { + last[address] = message[2]; + } + return true; + } + + // Wrapper function for send() that delays messages after modesetting + function tell(message) { + if (throttling) { + pipe.push(message); + return; + } + + send(message); + } + + // Send modeset messages to the device + // + // messages: list of one or two messages to send + // + // Either provide a pair of messages to set a slider to a different + // mode, or send just one long message in the list. Transmission of + // subsequent messages will be delayed to give the device some time to apply + // the changes. + function modeset(messages) { + var sent = true; + + // Modeset messages are comprised of the actual modeset message and + // a termination message that must be sent after. + var message = messages[0]; + + if (message) { + if (message.length > 3) { + midi.sendSysexMsg(message, message.length); + } else { + sent = send(message); + if (sent && messages[1]) { + // Only send termination message when modeset message was sent + send(messages[1], true, true); + } + } + } + + if (sent) { + // after modesetting, we have to wait for the device to settle + if (!throttling) { + throttling = engine.beginTimer(20, flushModeset); + } + } + } + + var flushModeset = function() { + var message; + + // Now we can flush the rest of the messages. + // On init, some controls are left unlit if the messages are sent + // without delay. The causes are unclear. Sending only a few messages + // per tick seems to work ok. + var limit = 5; // Determined experimentally + while (pipe.length) { + message = pipe.shift(); + send(message); + if (loading && limit-- < 1) return; + } + + if (throttling) engine.stopTimer(throttling); + throttling = false; + loading = false; + }; + + // Map engine values in the range [0..1] to lights + // translator maps from [0..1] to a midi message (three bytes) + function patch(translator) { + return function(value) { + tell(translator(value)); + }; + } + + // Cut off at 0.01 because it drops off very slowly + function vupatch(translator) { + return function(value) { + value = value * 1.01 - 0.01; + tell(translator(value)); + }; + } + + // accelerate away from 0.5 so that small changes become visible faster + function offcenter(translator) { + return function(value) { + // If you want to adjust it, fiddle with the exponent (second argument to pow()) + return translator(Math.pow(Math.abs(value - 0.5) * 2, 0.6) / (value < 0.5 ? -2 : 2) + 0.5); + }; + } + + function binarylight(off, on) { + return function(value) { + tell(value ? on : off); + }; + } + + // absolute control + function set(channel, control) { + return function(value) { + engine.setParameter(channel, control, + value / 127 + ); + }; + } + + function setconst(channel, control, value) { + return function() { + engine.setParameter(channel, control, value); + }; + } + + function reset(channel, control) { + return function() { + engine.reset(channel, control); + }; + } + + // relative control + function budge(channel, control, factor) { + if (factor === undefined) factor = 1; + var mult = factor / 128; + + return function(offset) { + engine.setValue(channel, control, + engine.getValue(channel, control) + (offset - 64) * mult + ); + }; + } + + // switch + function toggle(channel, control) { + return function() { + engine.setValue(channel, control, !engine.getValue(channel, control)); + }; + } + + function Switch() { + var engaged = false; + + return { + 'change': function(state) { + state = !!(state); + var changed = engaged !== state; + engaged = state; + return changed; + }, + 'engage': function() { + engaged = true; + }, + 'cancel': function() { + engaged = false; + }, + 'toggle': function() { + engaged = !engaged; + }, + 'engaged': function() { + return engaged; + }, + 'choose': function(off, on) { + return engaged ? on : off; + } + }; + } + + function HoldLimit(limit) { + var start = false; + + var early = function() { + return limit > new Date() - start; + }; + + return { + 'hold': function() { + start = new Date(); + }, + + 'releaseTrigger': function(onEarly) { + return function() { + if (!start) return; + if (early()) { + onEarly(); + } + start = false; + }; + }, + + 'early': function() { + if (!start) return; + return early(); + }, + + 'late': function() { + if (!start) return; + return early(); + }, + + 'held': function() { + return !!start; + }, + + 'choose': function(normalVal, heldVal) { + return start ? heldVal : normalVal; + } + }; + } + + // HoldDelayedSwitches can be engaged, and they can be held. + // A switch that is held for less than 200 ms will toggle. + // After 200ms it will enter held-mode. + function HoldDelayedSwitch() { + var sw = Switch(); + + var held = false; + var heldBegin = false; + + sw.hold = function(onHeld) { + return function() { + heldBegin = true; + var switchExpire = engine.beginTimer(200, function() { + engine.stopTimer(switchExpire); + if (heldBegin) { + heldBegin = false; + held = true; + if (onHeld) onHeld(); + } + }); + }; + }; + + sw.release = function() { + if (heldBegin) sw.toggle(); + held = false; + heldBegin = false; + }; + + sw.held = function() { + return held; + }; + + return sw; + } + + function Multiswitch(preset, initialSubMode) { + var engaged = preset; + var subMode = initialSubMode; + return { + 'engage': function(pos) { + engaged = pos; + if (engaged !== preset) { + subMode = pos; + } + }, + 'engageSub': function() { + engaged = subMode; + }, + 'cancel': function() { + engaged = preset; + }, + 'engaged': function(pos) { + return engaged === pos; + }, + 'choose': function(pos, off, on) { + return (engaged === pos) ? on : off; + } + }; + } + + var master = Switch(); // Whether master key is held + var deck = { + left: HoldDelayedSwitch(), // off: channel1, on: channel3 + right: HoldDelayedSwitch() // off: channel2, on: channel4 + }; + + var overlayA = Multiswitch('eq', 0); + var overlayB = Multiswitch('eq', SCS3M.eqModePerDeck ? 1 : 0); + var overlayC; + var overlayD; + if (SCS3M.eqModePerDeck) { + overlayC = Multiswitch('eq', 2); + overlayD = Multiswitch('eq', 3); + } else { + overlayC = overlayA; + overlayD = overlayB; + } + + var overlay = { + left: [overlayA, overlayC], + right: [overlayB, overlayD], + }; + + var eqheld = { + left: Switch(), + right: Switch() + }; + + var fxHeld = { + left: HoldLimit(200), + right: HoldLimit(200) + }; + + var touchheld = { + left: Multiswitch('none'), + right: Multiswitch('none') + }; + + function remap() { + clear(); + patchage(); + } + + // Remap for chainig with handlers + function repatch(handler) { + return function(value) { + var ret = handler(value); + remap(); + return ret; + }; + } + + function patchage() { + + function Side(side) { + var part = device[side]; + var deckside = deck[side]; + + // Switch deck/channel when button is touched + expect(part.deck.touch, deckside.hold(remap)); + expect(part.deck.release, repatch(deckside.release)); + + function either(left, right) { + return (side === 'left') ? left : right; + } + + var channelno = deck[side].choose(either(1, 2), either(3, 4)); + var channel = '[Channel' + channelno + ']'; + var effectchannel = '[QuickEffectRack1_' + channel + ']'; + var eqsideheld = eqheld[side]; + var touchsideheld = touchheld[side]; + var sideoverlay = overlay[side][deckside.choose(0, 1)]; + + // Light the corresponding deck (channel 1: A, channel 2: B, channel 3: C, channel 4: D) + // Make the lights blink on each beat + function beatlight(translator, activepos, held) { + return function(bits) { + bits = bits.slice(); // clone + if (held) { + // When the switch his held, light both LED + // turn them off when beat is active + bits[0] = !bits[0]; + bits[1] = !bits[1]; + } else { + // Invert the bit for the light that should be on + bits[activepos] = !bits[activepos]; + } + return translator(bits); + }; + } + watchmulti([ + ['[Channel' + either(1, 2) + ']', 'beat_active'], + ['[Channel' + either(3, 4) + ']', 'beat_active'], + ], patch(beatlight(part.deck.light, deckside.choose(0, 1), deckside.held()))); + + if (!master.engaged()) { + if (sideoverlay.engaged('eq')) { + modeset(part.pitch.mode.relative); + expect(part.pitch.slide, eqsideheld.choose( + budge(effectchannel, 'super1', 0.5), + reset(effectchannel, 'super1') + )); + watch(effectchannel, 'super1', offcenter(patch(part.pitch.meter.centerbar))); + } + } + + if (sideoverlay.engaged('eq')) { + var eff = "[EqualizerRack1_" + channel + "_Effect1]"; + var op = eqsideheld.choose(set, reset); + expect(part.eq.low.slide, op(eff, 'parameter1')); + expect(part.eq.mid.slide, op(eff, 'parameter2')); + expect(part.eq.high.slide, op(eff, 'parameter3')); + + watch(eff, 'parameter1', patch(offcenter(part.eq.low.meter.centerbar))); + watch(eff, 'parameter2', patch(offcenter(part.eq.mid.meter.centerbar))); + watch(eff, 'parameter3', patch(offcenter(part.eq.high.meter.centerbar))); + } + + expect(part.modes.eq.touch, repatch(function() { + eqsideheld.engage(); + sideoverlay.cancel(); + })); + expect(part.modes.eq.release, repatch(eqsideheld.cancel)); + tell(part.modes.eq.light[eqsideheld.choose(sideoverlay.choose('eq', 'blue', 'red'), 'purple')]); + + var fxHeldSide = fxHeld[side]; + + var fxMap = function(tnr) { + var softbutton = part.touches[tnr]; + var fxchannel = channel; + if (master.engaged()) { + fxchannel = either('[Headphone]', '[Master]'); + } + var effectunit = '[EffectRack1_EffectUnit' + (tnr + 1) + ']'; + var effectunit_enable = 'group_' + fxchannel + '_enable'; + var effectunit_effect = '[EffectRack1_EffectUnit' + (tnr + 1) + '_Effect1]'; + + if (fxHeldSide.held() || master.engaged()) { + expect(softbutton.touch, toggle(effectunit, effectunit_enable)); + } else { + expect(softbutton.touch, repatch(function() { + sideoverlay.engage(tnr); + touchsideheld.engage(tnr); + })); + } + expect(softbutton.release, repatch(touchsideheld.cancel)); + + if (sideoverlay.engaged(tnr)) { + watch(effectunit, effectunit_enable, binarylight( + softbutton.light.red, + softbutton.light.purple) + ); + } else { + watch(effectunit, effectunit_enable, binarylight( + softbutton.light.black, + softbutton.light.blue) + ); + } + + if (sideoverlay.engaged(tnr)) { + // Select effect by touching top slider when button is held + // Otherwise the top slider controls effect wet/dry + if (touchsideheld.engaged(tnr)) { + tell(part.pitch.meter.expand(0.3)); + expect( + part.pitch.field.left.touch, + setconst(effectunit, 'chain_selector', 1) + ); + expect( + part.pitch.field.right.touch, + setconst(effectunit, 'chain_selector', -1) + ); + } else { + modeset(part.pitch.mode.absolute); + expect(part.pitch.slide, eqsideheld.choose( + set(effectunit, 'mix'), + reset(effectunit, 'mix') + )); + watch(effectunit, 'mix', patch(part.pitch.meter.bar)); + } + + expect(part.eq.high.slide, fxHeldSide.choose( + set(effectunit_effect, 'parameter3'), + reset(effectunit_effect, 'parameter3') + )); + expect(part.eq.mid.slide, fxHeldSide.choose( + set(effectunit_effect, 'parameter2'), + reset(effectunit_effect, 'parameter2') + )); + expect(part.eq.low.slide, fxHeldSide.choose( + set(effectunit_effect, 'parameter1'), + reset(effectunit_effect, 'parameter1') + )); + watch(effectunit_effect, 'parameter3', patch(part.eq.high.meter.needle)); + watch(effectunit_effect, 'parameter2', patch(part.eq.mid.meter.needle)); + watch(effectunit_effect, 'parameter1', patch(part.eq.low.meter.needle)); + } + }; + + for (var tnr = 0; tnr < 4; tnr++) { + fxMap(tnr); + } + + expect(part.modes.fx.touch, + repatch(fxHeldSide.hold) + ); + expect(part.modes.fx.release, + repatch(fxHeldSide.releaseTrigger(sideoverlay.engageSub)) + ); + tell(part.modes.fx.light[fxHeldSide.choose( + sideoverlay.choose('eq', 'red', 'blue'), + 'purple' + )]); + + + if (!master.engaged()) { + if (deckside.held()) { + modeset(part.gain.mode.relative); + expect(part.gain.slide, eqsideheld.choose( + budge(channel, 'pregain'), + reset(channel, 'pregain') + )); + watch(channel, 'pregain', patch(offcenter(part.gain.meter.needle))); + } else { + modeset(part.gain.mode.absolute); + expect(part.gain.slide, set(channel, 'volume')); + watch(channel, 'volume', patch(part.gain.meter.bar)); + } + } + + watch(channel, 'pfl', binarylight(part.phones.light.blue, part.phones.light.red)); + expect(part.phones.touch, toggle(channel, 'pfl')); + + if (deckside.held()) { + expect(device.crossfader.slide, set(channel, "playposition")); + watch(channel, "playposition", patch(device.crossfader.meter.needle)); + } + + if (!master.engaged()) { + watch(channel, 'VuMeter', vupatch(part.meter.bar)); + } + } + + // Light the logo and let it go out to signal an overload + watch("[Master]", 'audio_latency_overload', binarylight( + device.logo.on, + device.logo.off + )); + + Side('left'); + Side('right'); + + tell(device.master.light[master.choose('blue', 'purple')]); + expect(device.master.touch, repatch(master.engage)); + expect(device.master.release, repatch(master.cancel)); + if (master.engaged()) { + modeset(device.left.pitch.mode.absolute); + watch("[Master]", "headMix", patch(device.left.pitch.meter.centerbar)); + expect(device.left.pitch.slide, + eqheld.left.engaged() ? reset('[Master]', 'headMix') : set('[Master]', 'headMix') + ); + + modeset(device.right.pitch.mode.absolute); + watch("[Master]", "balance", patch(device.right.pitch.meter.centerbar)); + expect(device.right.pitch.slide, + eqheld.right.engaged() ? reset('[Master]', 'balance') : set('[Master]', 'balance') + ); + + modeset(device.left.gain.mode.relative); + watch("[Master]", "headVolume", patch(device.left.gain.meter.centerbar)); + expect(device.left.gain.slide, budge('[Master]', 'headVolume')); + + modeset(device.right.gain.mode.relative); + watch("[Master]", "volume", patch(device.right.gain.meter.centerbar)); + expect(device.right.gain.slide, budge('[Master]', 'volume')); + + watch("[Master]", "VuMeterL", vupatch(device.left.meter.bar)); + watch("[Master]", "VuMeterR", vupatch(device.right.meter.bar)); + } + + if (deck.left.held() || deck.right.held()) { + // Needledrop handled in Side() + } else { + expect(device.crossfader.slide, set("[Master]", "crossfader")); + watch("[Master]", "crossfader", patch(device.crossfader.meter.centerbar)); + } + + // Communicate currently selected channel of each deck so SCS3d can read it + // THIS USES A CONTROL FOR ULTERIOR PURPOSES AND IS VERY NAUGHTY INDEED + engine.setValue('[PreviewDeck1]', 'quantize', + 0x4 // Setting bit three communicates that we're sending deck state + | deck.left.engaged() // left side is in bit one + | deck.right.engaged() << 1 // right side bit two + ); + watch('[PreviewDeck1]', 'quantize', function(deckState) { + var changed = deck.left.change(deckState & 1) || deck.right.change(deckState & 2); + if (changed) repatch(function() {})(); + }); + } + + return { + start: function() { + loading = true; + modeset([device.flat]); + patchage(); + }, + receive: receive, + stop: function() { + clear(); + tell(device.lightsoff); + send(device.logo.on, true); + } + }; +};