summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBe <be@mixxx.org>2021-07-03 22:14:06 -0500
committerAntoine C <mixxx@acolombier.dev>2023-06-04 17:25:04 +0100
commit18f9fd7e7ed8003b536a514666d78554357b37fa (patch)
tree6743bf77d0eb41901cd010c220ffb8c05f87f357
parent27292f7cece1a54aa414db9badf1db0d184997d4 (diff)
Kontrol S4 Mk3: initial commit
-rw-r--r--res/controllers/Traktor Kontrol S4 MK3.hid.xml17
-rw-r--r--res/controllers/Traktor-Kontrol-S4-MK3.js2040
2 files changed, 2057 insertions, 0 deletions
diff --git a/res/controllers/Traktor Kontrol S4 MK3.hid.xml b/res/controllers/Traktor Kontrol S4 MK3.hid.xml
new file mode 100644
index 0000000000..c9852a1d40
--- /dev/null
+++ b/res/controllers/Traktor Kontrol S4 MK3.hid.xml
@@ -0,0 +1,17 @@
+<?xml version='1.0' encoding='utf-8'?>
+<MixxxControllerPreset mixxxVersion="2.4.0" schemaVersion="1">
+ <info>
+ <name>Traktor Kontrol S4 MK3</name>
+ <author>Be</author>
+ <description>HID Mapping for Traktor Kontrol S4 MK3</description>
+ <manual>native_instruments_traktor_kontrol_s4_mk3</manual>
+ <devices>
+ <product protocol="hid" vendor_id="0x17cc" product_id="0x1720" usage_page="0xff01" usage="0x1" interface_number="0x4" />
+ </devices>
+ </info>
+ <controller id="Traktor">
+ <scriptfiles>
+ <file filename="Traktor-Kontrol-S4-MK3.js" functionprefix="TraktorS4MK3"/>
+ </scriptfiles>
+ </controller>
+</MixxxControllerPreset>
diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js
new file mode 100644
index 0000000000..2c53b65442
--- /dev/null
+++ b/res/controllers/Traktor-Kontrol-S4-MK3.js
@@ -0,0 +1,2040 @@
+/// Copyright (C) 2022 Be <be@mixxx.org>
+///
+/// This mapping is free software; you can redistribute it and/or modify
+/// it under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 2 of the License, or
+/// (at your option) any later version. The full text of the GNU
+/// General Public License, version 2 can be found below. The licenses
+/// of software libraries distributed together with Mixxx can be found
+/// below as well.
+///
+/// In addition to the terms of the GNU General Public License, the following
+/// license terms apply:
+///
+/// By using this mapping, you confirm that you are not Bob Ham, you are in no
+/// way affiliated to Bob Ham, you are not downloading this code on behalf of
+/// Bob Ham or an associate of Bob Ham. To the best of your knowledge, information
+/// and belief this mapping will not make its way into the hands of Bob Ham.
+
+const LEDColors = {
+ off: 0,
+ red: 4,
+ carrot: 8,
+ orange: 12,
+ honey: 16,
+ yellow: 20,
+ lime: 24,
+ green: 28,
+ aqua: 32,
+ celeste: 36,
+ sky: 40,
+ blue: 44,
+ purple: 48,
+ fuscia: 52,
+ magenta: 56,
+ azalea: 60,
+ salmon: 64,
+ white: 68,
+};
+
+/*
+ * USER CONFIGURABLE SETTINGS
+ * Adjust these to your liking
+ */
+
+const deckColors = [
+ LEDColors.red,
+ LEDColors.blue,
+ LEDColors.yellow,
+ LEDColors.purple,
+];
+
+const tempoFaderSoftTakeoverColorLow = LEDColors.white;
+const tempoFaderSoftTakeoverColorHigh = LEDColors.green;
+
+// The LEDs only support 16 base colors. Adding 1 in addition to
+// the normal 2 for Button.prototype.brightnessOn changes the color
+// slightly, so use that get 25 different colors to include the Filter
+// button as a 5th effect chain preset selector.
+const quickEffectPresetColors = [
+ LEDColors.red,
+ LEDColors.blue,
+ LEDColors.yellow,
+ LEDColors.purple,
+ LEDColors.white,
+
+ LEDColors.magenta,
+ LEDColors.azalea,
+ LEDColors.salmon,
+ LEDColors.red + 1,
+
+ LEDColors.sky,
+ LEDColors.celeste,
+ LEDColors.fuscia,
+ LEDColors.blue + 1,
+
+ LEDColors.carrot,
+ LEDColors.orange,
+ LEDColors.honey,
+ LEDColors.yellow + 1,
+
+ LEDColors.lime,
+ LEDColors.aqua,
+ LEDColors.green,
+ LEDColors.purple + 1,
+
+ LEDColors.magenta + 1,
+ LEDColors.azalea + 1,
+ LEDColors.salmon + 1,
+ LEDColors.fuscia + 1,
+];
+
+// assign samplers to the crossfader on startup
+const samplerCrossfaderAssign = true;
+
+/*
+ * HID packet parsing library
+ */
+
+class HIDInputPacket {
+ constructor(reportId) {
+ this.reportId = reportId;
+ this.fields = [];
+ }
+
+ registerCallback(callback, byteOffset, bitOffset, bitLength, signed) {
+ if (typeof callback !== "function") {
+ throw Error("callback must be a function");
+ }
+
+ if (byteOffset === undefined || typeof byteOffset !== "number" || !Number.isInteger(byteOffset)) {
+ throw Error("byteOffset must be 0 or a positive integer");
+ }
+
+ if (bitOffset === undefined) {
+ bitOffset = 0;
+ }
+ if (typeof bitOffset !== "number" || bitOffset < 0 || !Number.isInteger(bitOffset)) {
+ throw Error("bitOffset must be 0 or a positive integer");
+ }
+
+ if (bitLength === undefined) {
+ bitLength = 1;
+ }
+ if (typeof bitLength !== "number" || bitLength < 1 || !Number.isInteger(bitOffset) || bitLength > 32) {
+ throw Error("bitLength must be an integer between 1 and 32");
+ }
+
+ if (signed === undefined) {
+ signed = false;
+ }
+
+ const field = {
+ callback: callback,
+ byteOffset: byteOffset,
+ bitOffset: bitOffset,
+ bitLength: bitLength,
+ oldData: 0
+ };
+ this.fields.push(field);
+
+ return {
+ disconnect: () => {
+ this.fields = this.fields.filter((element) => {
+ return element !== field;
+ });
+ }
+ };
+ }
+
+ handleInput(byteArray) {
+ const view = new DataView(byteArray);
+ if (view.getUint8(0) !== this.reportId) {
+ return;
+ }
+
+ for (const field of this.fields) {
+ const numBytes = Math.ceil(field.bitLength / 8);
+ let data;
+
+ // Little endianness is specified by the HID standard.
+ // The HID standard allows signed integers as well, but I am not aware
+ // of any HID DJ controllers which use signed integers.
+ if (numBytes === 1) {
+ data = view.getUint8(field.byteOffset);
+ } else if (numBytes === 2) {
+ data = view.getUint16(field.byteOffset, true);
+ } else if (numBytes === 3) {
+ data = view.getUint32(field.byteOffset, true) >>> 8;
+ } else if (numBytes === 4) {
+ data = view.getUint32(field.byteOffset, true);
+ } else {
+ throw Error("field bitLength must be between 1 and 32");
+ }
+
+ // The >>> 0 is required for 32 bit unsigned ints to not magically turn negative
+ // because all Numbers are really 32 bit signed floats. Because JavaScript.
+ data = ((data >> field.bitOffset) & (2 ** field.bitLength - 1)) >>> 0;
+
+ if (field.oldData !== data) {
+ field.callback(data);
+ field.oldData = data;
+ }
+ }
+ }
+}
+
+class HIDOutputPacket {
+ constructor(reportId, length) {
+ this.reportId = reportId;
+ this.data = Array(length).fill(0);
+ }
+ send() {
+ controller.send(this.data, null, this.reportId);
+ }
+}
+
+/*
+ * Components library
+ */
+
+class Component {
+ constructor(options) {
+ Object.assign(this, options);
+ if (options !== undefined && typeof options.key === "string") {
+ this.inKey = options.key;
+ this.outKey = options.key;
+ }
+ if (this.unshift !== undefined && typeof this.unshift === "function") {
+ this.unshift();
+ }
+ this.shifted = false;
+ if (this.input !== undefined && typeof this.input === "function"
+ && this.inPacket !== undefined && this.inPacket instanceof HIDInputPacket) {
+ this.inConnect();
+ }
+ this.outConnections = [];
+ this.outConnect();
+ }
+ inConnect(callback) {
+ if (this.inByte === undefined
+ || this.inBit === undefined
+ || this.inBitLength === undefined
+ || this.inPacket === undefined) {
+ return;
+ }
+ if (typeof callback === "function") {
+ this.input = callback;
+ }
+ this.inConnection = this.inPacket.registerCallback(this.input.bind(this), this.inByte, this.inBit, this.inBitLength);
+ }
+ inDisconnect() {
+ if (this.inConnection !== undefined) {
+ this.inConnection.disconnect();
+ }
+ }
+ send(value) {
+ if (this.outPacket !== undefined && this.outByte !== undefined) {
+ this.outPacket.data[this.outByte] = value;
+ this.outPacket.send();
+ }
+ }
+ output(value) {
+ this.send(value);
+ }
+ outConnect() {
+ if (this.outKey !== undefined && this.group !== undefined) {
+ this.outConnections[0] = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+ }
+ }
+ outDisconnect() {
+ for (const connection of this.outConnections) {
+ connection.disconnect();
+ }
+ }
+ outTrigger() {
+ for (const connection of this.outConnections) {
+ connection.trigger();
+ }
+ }
+}
+
+class ComponentContainer {
+ constructor() {}
+ *[Symbol.iterator]() {
+ // can't use for...of here because it would create an infinite loop
+ for (const property in this) {
+ if (Object.prototype.hasOwnProperty.call(this, property)) {
+ const obj = this[property];
+ if (obj instanceof Component) {
+ yield obj;
+ } else if (obj instanceof ComponentContainer) {
+ for (const nestedComponent of obj) {
+ yield nestedComponent;
+ }
+ } else if (Array.isArray(obj)) {
+ for (const objectInArray of obj) {
+ if (objectInArray instanceof Component) {
+ yield objectInArray;
+ } else if (objectInArray instanceof ComponentContainer) {
+ for (const doublyNestedComponent of objectInArray) {
+ yield doublyNestedComponent;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ reconnectComponents(callback) {
+ for (const component of this) {
+ if (component.outDisconnect !== undefined && typeof component.outDisconnect === "function") {
+ component.outDisconnect();
+ }
+ if (callback !== undefined && typeof callback === "function") {
+ callback.call(this, component);
+ }
+ if (component.outConnect !== undefined && typeof component.outConnect === "function") {
+ component.outConnect();
+ }
+ component.outTrigger();
+ }
+ }
+ unshift() {
+ for (const component of this) {
+ if (component.unshift !== undefined && typeof component.unshift === "function") {
+ component.unshift();
+ }
+ component.shifted = false;
+ }
+ }
+ shift() {
+ for (const component of this) {
+ if (component.shift !== undefined && typeof component.shift === "function") {
+ component.shift();
+ }
+ component.shifted = true;
+ }
+ }
+}
+
+/* eslint no-redeclare: "off" */
+class Deck extends ComponentContainer {
+ constructor(decks, colors) {
+ super();
+ if (typeof decks === "number") {
+ this.group = Deck.groupForNumber(decks);
+ } else if (Array.isArray(decks)) {
+ this.decks = decks;
+ this.currentDeckNumber = decks[0];
+ this.group = Deck.groupForNumber(decks[0]);
+ }
+ if (colors !== undefined && Array.isArray(colors)) {
+ this.groupsToColors = {};
+ let index = 0;
+ for (const deck of this.decks) {
+ this.groupsToColors[Deck.groupForNumber(deck)] = colors[index];
+ index++;
+ }
+ this.color = colors[0];
+ }
+ }
+ toggleDeck() {
+ if (this.decks === undefined) {
+ throw Error("toggleDeck can only be used with Decks constructed with an Array of deck numbers, for example [1, 3]");
+ }
+
+ const currentDeckIndex = this.decks.indexOf(this.currentDeckNumber);
+ let newDeckIndex = currentDeckIndex + 1;
+ if (currentDeckIndex >= this.decks.length) {
+ newDeckIndex = 0;
+ }
+
+ this.switchDeck(Deck.groupForNumber(this.decks[newDeckIndex]));
+ }
+ switchDeck(newGroup) {
+ this.group = newGroup;
+ this.color = this.groupsToColors[newGroup];
+ this.reconnectComponents(function(component) {
+ if (component.group === undefined
+ || component.group.search(script.channelRegEx) !== -1) {
+ component.group = newGroup;
+ } else if (component.group.search(script.eqRegEx) !== -1) {
+ component.group = "[EqualizerRack1_" + newGroup + "_Effect1]";
+ } else if (component.group.search(script.quickEffectRegEx) !== -1) {
+ component.group = "[QuickEffectRack1_" + newGroup + "]";
+ }
+
+ component.color = this.groupsToColors[newGroup];
+ });
+ }
+ static groupForNumber(deckNumber) {
+ return "[Channel" + deckNumber + "]";
+ }
+}
+
+class Button extends Component {
+ constructor(options) {
+ super(options);
+ this.off = 0;
+ if (this.longPressTimeOut === undefined) {
+ this.longPressTimeOut = 225; // milliseconds
+ }
+ if (this.inBitLength === undefined) {
+ this.inBitLength = 1;
+ }
+ }
+ output(value) {
+ const brightness = (value > 0) ? this.brightnessOn : this.brightnessOff;
+ this.send(this.color + brightness);
+ }
+}
+
+class PushButton extends Button {
+ constructor(options) {
+ super(options);
+ }
+ input(pressed) {
+ engine.setValue(this.group, this.inKey, pressed);
+ }
+}
+
+class ToggleButton extends Button {
+ constructor(options) {
+ super(options);
+ }
+ input(pressed) {
+ if (pressed) {
+ script.toggleControl(this.group, this.inKey);
+ }
+ }
+}
+
+class PowerWindowButton extends Button {
+ constructor(options) {
+ super(options);
+ this.isLongPressed = false;
+ this.longPressTimer = 0;
+ }
+ input(pressed) {
+ if (pressed) {
+ script.toggleControl(this.group, this.inKey);
+ this.longPressTimer = engine.beginTimer(this.longPressTimeOut, () => {
+ this.isLongPressed = true;
+ this.longPressTimer = 0;
+ }, true);
+ } else {
+ if (this.isLongPressed) {
+ script.toggleControl(this.group, this.inKey);
+ }
+ if (this.longPressTimer !== 0) {
+ engine.stopTimer(this.longPressTimer);
+ }
+ this.longPressTimer = 0;
+ this.isLongPressed = false;
+ }
+ }
+}
+
+class PlayButton extends ToggleButton {
+ constructor(options) {
+ super(options);
+ this.inKey = "play";
+ this.outKey = "play_indicator";
+ this.outConnect();
+ }
+}
+
+class CueButton extends PushButton {
+ constructor(options) {
+ super(options);
+ this.outKey = "cue_indicator";
+ this.outConnect();
+ }
+ unshift() {
+ this.inKey = "cue_default";
+ }
+ shift() {
+ this.inKey = "start_stop";
+ }
+}
+
+class Encoder extends Component {
+ constructor(options) {
+ super(options);
+ this.lastValue = null;
+ }
+ isRightTurn(value) {
+ // detect wrap around
+ const oldValue = this.lastValue;
+ this.lastValue = value;
+ if (oldValue === this.max && value === 0) {
+ return true;
+ }
+ if (oldValue === 0 && value === this.max) {
+ return false;
+ }
+ return value > oldValue;
+ }
+}
+
+class HotcueButton extends PushButton {
+ constructor(options) {
+ super(options);
+ if (this.number === undefined || !Number.isInteger(this.number) || this.number < 1 || this.number > 32) {
+ throw Error("HotcueButton must have a number property of an integer between 1 and 32");
+ }
+ this.outKey = "hotcue_" + this.number + "_enabled";
+ this.colorKey = "hotcue_" + this.number + "_color";
+ this.outConnect();
+ }
+ unshift() {
+ this.inKey = "hotcue_" + this.number + "_activate";
+ }
+ shift() {
+ this.inKey = "hotcue_" + this.number + "_clear";
+ }
+ output(value) {
+ if (value) {
+ this.send(this.color + this.brightnessOn);
+ } else {
+ this.send(0);
+ }
+ }
+ outConnect() {
+ if (undefined !== this.group) {
+ this.outConnections[0] = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+ this.outConnections[1] = engine.makeConnection(this.group, this.colorKey, (colorCode) => {
+ this.color = this.colorMap.getValueForNearestColor(colorCode);
+ this.output(engine.getValue(this.group, this.outKey));
+ });
+ }
+ }
+}
+
+class SamplerButton extends Button {
+ constructor(options) {
+ super(options);
+ if (this.number === undefined || !Number.isInteger(this.number) || this.number < 1 || this.number > 64) {
+ throw Error("SamplerButton must have a number property of an integer between 1 and 64");
+ }
+ this.group = "[Sampler" + this.number + "]";
+ this.outConnect();
+ }
+ input(pressed) {
+ if (!this.shifted) {
+ if (pressed) {
+ if (engine.getValue(this.group, "track_loaded") === 0) {
+ engine.setValue(this.group, "LoadSelectedTrack", 1);
+ } else {
+ engine.setValue(this.group, "cue_gotoandplay", 1);
+ }
+ }
+ } else {
+ if (pressed) {
+ if (engine.getValue(this.group, "play") === 1) {
+ engine.setValue(this.group, "play", 0);
+ } else {
+ engine.setValue(this.group, "eject", 1);
+ }
+ } else {
+ if (engine.getValue(this.group, "play") === 0) {
+ engine.setValue(this.group, "eject", 0);
+ }
+ }
+ }
+ }
+ // This function is connected to multiple Controls, so don't use the value passed in as a parameter.
+ output() {
+ if (engine.getValue(this.group, "track_loaded")) {
+ if (engine.getValue(this.group, "play")) {
+ this.send(this.color + this.brightnessOn);
+ } else {
+ this.send(this.color + this.brightnessOff);
+ }
+ } else {
+ this.send(0);
+ }
+ }
+ outConnect() {
+ if (undefined !== this.group) {
+ this.outConnections[0] = engine.makeConnection(this.group, "play", this.output.bind(this));
+ this.outConnections[1] = engine.makeConnection(this.group, "track_loaded", this.output.bind(this));
+ }
+ }
+}
+
+class IntroOutroButton extends PushButton {
+ constructor(options) {
+ super(options);
+ if (this.cueBaseName === undefined || typeof this.cueBaseName !== "string") {
+ throw Error("must specify cueBaseName as intro_start, intro_end, outro_start, or outro_end");
+ }
+ this.outKey = this.cueBaseName + "_enabled";
+ this.outConnect();
+ }
+ unshift() {
+ this.inKey = this.cueBaseName + "_activate";
+ }
+ shift() {
+ this.inKey = this.cueBaseName + "_clear";
+ }
+ output(value) {
+ if (value) {
+ this.send(this.color + this.brightnessOn);
+ } else {
+ this.send(0);
+ }
+ }
+}
+
+class Pot extends Component {
+ constructor(options) {
+ super(options);
+ this.hardwarePosition = null;
+ }
+ input(value) {
+ const receivingFirstValue = this.hardwarePosition === null;
+ this.hardwarePosition = value / this.max;
+ engine.setParameter(this.group, this.inKey, this.hardwarePosition);
+ if (receivingFirstValue) {
+ engine.softTakeover(this.group, this.inKey, true);
+ }
+ }
+ outDisconnect() {
+ if (this.hardwarePosition !== null) {
+ engine.softTakeover(this.group, this.inKey, true);
+ }
+ engine.softTakeoverIgnoreNextValue(this.group, this.inKey);
+ }
+}
+
+/*
+ * Kontrol S4 Mk3 hardware-specific constants
+ */
+
+Pot.prototype.max = 2**12 - 1;
+Pot.prototype.inBit = 0;
+Pot.prototype.inBitLength = 16;
+
+Encoder.prototype.inBitLength = 4;
+
+// valid range 0 - 3, but 3 makes some colors appear whitish
+Button.prototype.brightnessOff = 0;
+Button.prototype.brightnessOn = 2;
+Button.prototype.colorMap = new ColorMapper({
+ 0xCC0000: LEDColors.red,
+ 0xCC5E00: LEDColors.carrot,
+ 0xCC7800: LEDColors.orange,
+ 0xCC9200: LEDColors.honey,
+
+ 0xCCCC00: LEDColors.yellow,
+ 0x81CC00: LEDColors.lime,
+ 0x00CC00: LEDColors.green,
+ 0x00CC49: LEDColors.aqua,
+
+ 0x00CCCC: LEDColors.celeste,
+ 0x0091CC: LEDColors.sky,
+ 0x0000CC: LEDColors.blue,
+ 0xCC00CC: LEDColors.purple,
+
+ 0xCC0091: LEDColors.fuscia,
+ 0xCC0079: LEDColors.magenta,
+ 0xCC477E: LEDColors.azalea,
+ 0xCC4761: LEDColors.salmon,
+
+ 0xCCCCCC: LEDColors.white,
+});
+
+const wheelRelativeMax = 2**16 - 1;
+const wheelAbsoluteMax = 2879;
+
+const wheelTimerMax = 2**32 - 1;
+const wheelTimerTicksPerSecond = 100000000;
+
+const baseRevolutionsPerMinute = 33 + 1/3;
+const baseRevolutionsPerSecond = baseRevolutionsPerMinute / 60;
+const wheelTicksPerTimerTicksToRevolutionsPerSecond = wheelTimerTicksPerSecond / wheelAbsoluteMax;
+
+const wheelLEDmodes = {
+ off: 0,
+ dimFlash: 1,
+ spot: 2,
+ ringFlash: 3,
+ dimSpot: 4,
+ individuallyAddressable: 5, // set byte 4 to 0 and set byes 8 - 40 to color values
+};
+
+const wheelModes = {
+ jog: 0,
+ vinyl: 1,
+ motor: 2,
+};
+
+// tracks state across input packets
+let wheelTimer = null;
+// This is a global variable so the S4Mk3Deck Components have access
+// to it and it is guaranteed to be calculated before processing
+// input for the Components.
+let wheelTimerDelta = 0;
+
+/*
+ * Kontrol S4 Mk3 hardware specific mapping logic
+ */
+
+// used for buttons whose LEDs only support a single color
+// Don't use dim colors for these because they are hard to tell apart
+// from bright colors.
+const uncoloredButtonOutput = function(value) {
+ if (value) {
+ this.send(127);
+ } else {
+ this.send(0);
+ }
+};
+
+class S4Mk3EffectUnit extends ComponentContainer {
+ constructor(unitNumber, inPackets, outPacket, io) {
+ super();
+ this.group = "[EffectRack1_EffectUnit" + unitNumber + "]";
+
+ this.mixKnob = new Pot({
+ inKey: "mix",
+ group: this.group,
+ inPacket: inPackets[2],
+ inByte: io.mixKnob.inByte,
+ });
+
+ this.knobs = [];
+ this.buttons = [];
+ for (const index of [0, 1, 2]) {
+ const effectGroup = "[EffectRack1_EffectUnit" + unitNumber + "_Effect" + (index + 1) + "]";
+ this.knobs[index] = new Pot({
+ inKey: "meta",
+ group: effectGroup,
+ inPacket: inPackets[2],
+ inByte: io.knobs[index].inByte,
+ });
+ this.buttons[index] = new PowerWindowButton({
+ key: "enabled",
+ group: effectGroup,
+ output: uncoloredButtonOutput,
+ inPacket: inPackets[1],
+ inByte: io.buttons[index].inByte,
+ inBit: io.buttons[index].inBit,
+ outByte: io.buttons[index].outByte,
+ outPacket: outPacket,
+ });
+ }
+
+ for (const component of this) {
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+ }
+}
+
+class S4Mk3Deck extends Deck {
+ constructor(decks, colors, inPackets, outPacket, io) {
+ super(decks, colors);
+
+ this.playButton = new PlayButton({
+ output: uncoloredButtonOutput
+ });
+
+ this.cueButton = new CueButton();
+
+ const rateRanges = [0.04, 0.06, 0.08, 0.10, 0.16, 0.24, 0.5, 0.9];
+ this.syncMasterButton = new ToggleButton({
+ key: "sync_leader",
+ input: function(pressed) {
+ if (pressed) {
+ if (!this.shifted) {
+ script.toggleControl(this.group, this.inKey);
+ } else {
+ // It is possible for the rateRange to be set to a value
+ // that is not in the rateRanges Array, so find the nearest
+ // value in rateRanges.
+ const currentRateRange = engine.getValue(this.group, "rateRange");
+ let previousDiff = null;
+ let newRateRange = rateRanges[0];
+ for (let i = 0; i < rateRanges.length - 1; i++) {
+ const currentDiff = Math.abs(rateRanges[i] - currentRateRange);
+ if (currentDiff < previousDiff || previousDiff === null) {
+ newRateRange = rateRanges[i + 1];
+ }
+ previousDiff = currentDiff;
+ }
+ engine.setValue(this.group, "rateRange", newRateRange);
+ }
+ }
+ },
+ });
+ this.syncButton = new ToggleButton({
+ key: "sync_enabled",
+ input: function(pressed) {
+ if (pressed) {
+ if (!this.shifted) {
+ script.toggleControl(this.group, this.inKey);
+ engine.softTakeover(this.group, "rate", true);
+ } else {
+ // It is possible for the rateRange to be set to a value
+ // that is not in the rateRanges Array, so find the nearest
+ // value in rateRanges.
+ const currentRateRange = engine.getValue(this.group, "rateRange");
+ let previousDiff = null;
+ let newRateRange = rateRanges[0];
+ for (let i = rateRanges.length - 1; i > 0; i--) {
+ const currentDiff = Math.abs(rateRanges[i] - currentRateRange);
+ if (currentDiff < previousDiff || previousDiff === null) {
+ newRateRange = rateRanges[i - 1];
+ }
+ previousDiff = currentDiff;
+ }
+ engine.setValue(this.group, "rateRange", newRateRange);
+ }
+ }
+ },
+ });
+ this.tempoFader = new Pot({
+ inKey: "rate",
+ });
+ this.tempoFaderLED = new Component({
+ outKey: "rate",
+ centered: false,
+ toleranceWindow: 0.001,
+ tempoFader: this.tempoFader,
+ output: function(value) {
+ if (this.tempoFader.hardwarePosition === null) {
+ return;
+ }
+
+ const parameterValue = engine.getParameter(this.group, this.outKey);
+ const diffFromHardware = parameterValue - this.tempoFader.hardwarePosition;
+ if (diffFromHardware > this.toleranceWindow) {
+ this.send(tempoFaderSoftTakeoverColorHigh + Button.prototype.brightnessOn);
+ return;
+ } else if (diffFromHardware < (-1 * this.toleranceWindow)) {
+ this.send(tempoFaderSoftTakeoverColorLow + Button.prototype.brightnessOn);
+ return;
+ }
+
+ const oldCentered = this.centered;
+ if (Math.abs(value) < 0.001) {
+ this.send(this.color + Button.prototype.brightnessOn);
+ // round to precisely 0
+ engine.setValue(this.group, "rate", 0);
+ } else {
+ this.send(0);
+ }
+ }
+ });
+
+ this.reverseButton = new PushButton({
+ key: "reverseroll",
+ output: uncoloredButtonOutput,
+ });
+ this.fluxButton = new PushButton({
+ key: "slip_enabled",
+ output: uncoloredButtonOutput,
+ });
+ this.gridButton = new PushButton({
+ key: "beats_translate_curpos",
+ });
+
+ this.deckButtonLeft = new Button({
+ deck: this,
+ input: function(value) {
+ if (value) {
+ this.deck.switchDeck(Deck.groupForNumber(decks[0]));
+ this.outPacket.data[io.deckButtonOutputByteOffset] = colors[0] + this.brightnessOn;
+ // turn off the other deck selection button's LED
+ this.outPacket.data[io.deckButtonOutputByteOffset+1] = 0;
+ this.outPacket.send();
+ }
+ },
+ });
+ this.deckButtonRight = new Button({
+ deck: this,
+ input: function(value) {
+ if (value) {
+ this.deck.switchDeck(Deck.groupForNumber(decks[1]));
+ // turn off the other deck selection button's LED
+ this.outPacket.data[io.deckButtonOutputByteOffset] = 0;
+ this.outPacket.data[io.deckButtonOutputByteOffset+1] = colors[1] + this.brightnessOn;
+ this.outPacket.send();
+ }
+ },
+ });
+
+ // set deck selection button LEDs
+ outPacket.data[io.deckButtonOutputByteOffset] = colors[0] + Button.prototype.brightnessOn;
+ outPacket.data[io.deckButtonOutputByteOffset+1] = 0;
+ outPacket.send();
+
+ this.shiftButton = new PushButton({
+ deck: this,
+ input: function(pressed) {
+ if (pressed) {
+ this.deck.shift();
+ // This button only has one color.
+ this.send(LEDColors.white + this.brightnessOn);
+ } else {
+ this.deck.unshift();
+ this.send(LEDColors.white + this.brightnessOff);
+ }
+ },
+ });
+
+ this.leftEncoder = new Encoder({
+ deck: this,
+ input: function(value) {
+ const right = this.isRightTurn(value);
+ if (!this.shifted) {
+ if (!this.deck.leftEncoderPress.pressed) {
+ if (right) {
+ script.triggerControl(this.group, "beatjump_forward");
+ } else {
+ script.triggerControl(this.group, "beatjump_backward");
+ }
+ } else {
+ let beatjumpSize = engine.getValue(this.group, "beatjump_size");
+ if (right) {
+ beatjumpSize *= 2;
+ } else {
+ beatjumpSize /= 2;
+ }
+ engine.setValue(this.group, "beatjump_size", beatjumpSize);
+ }
+ } else {
+ // FIXME: temporary hack until jog wheels are working
+ if (right) {
+ engine.setValue(this.group, "jog", 3);
+ // script.triggerControl(this.group, "pitch_up_small");
+ } else {
+ engine.setValue(this.group, "jog", -3);
+ // script.triggerControl(this.group, "pitch_down_small");
+ }
+ }
+ }
+ });
+ this.leftEncoderPress = new PushButton({
+ input: function(pressed) {
+ this.pressed = pressed;
+ if (pressed) {
+ script.toggleControl(this.group, "pitch_adjust_set_default");
+ }
+ },
+ });
+
+ this.rightEncoder = new Encoder({
+ input: function(value) {
+ const right = this.isRightTurn(value);
+ if (!this.shifted) {
+ if (right) {
+ script.triggerControl(this.group, "loop_double");
+ } else {
+ script.triggerControl(this.group, "loop_halve");
+ }
+ } else {
+ if (right) {
+ script.triggerControl(this.group, "beatjump_1_forward");
+ } else {
+ script.triggerControl(this.group, "beatjump_1_backward");
+ }
+ }
+ }
+ });
+ this.rightEncoderPress = new PushButton({
+ input: function(pressed) {
+ if (!pressed) {
+ return;
+ }
+ const loopEnabled = engine.getValue(this.group, "loop_enabled");
+ if (!this.shifted) {
+ script.triggerControl(this.group, "beatloop_activate");
+ } else {
+ if (loopEnabled) {
+ script.triggerControl(this.group, "reloop_andstop");
+ } else {
+ script.triggerControl(this.group, "reloop_toggle");
+ }
+ }
+ },
+ });
+
+ this.libraryEncoder = new Encoder({
+ input: function(value) {
+ const right = this.isRightTurn(value);
+ const previewPlaying = engine.getValue("[PreviewDeck1]", "play");
+ if (previewPlaying) {
+ if (right) {
+ script.triggerControl("[PreviewDeck1]", "beatjump_16_forward");
+ } else {
+ script.triggerControl("[PreviewDeck1]", "beatjump_16_backward");
+ }
+ } else {
+ engine.setValue("[Library]", "MoveVertical", right ? 1 : -1);
+ }
+ }
+ });
+ this.libraryEncoderPress = new ToggleButton({
+ inKey: "LoadSelectedTrack"
+ });
+ this.libraryPlayButton = new PushButton({
+ group: "[PreviewDeck1]",
+ input: function(pressed) {
+ if (pressed) {
+ if (engine.getValue(this.group, "play")) {
+ engine.setValue(this.group, "play", 0);
+ } else {
+ script.triggerControl(this.group, "LoadSelectedTrackAndPlay");
+ }
+ }
+ },
+ outKey: "play",