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);
+ }
+ };
+};