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 000000000000..02b9b5b93639
--- /dev/null
+++ b/res/controllers/Traktor Kontrol S4 MK3.hid.xml
@@ -0,0 +1,17 @@
+
+
+
+ Traktor Kontrol S4 MK3
+ Be, A. Colombier
+ HID Mapping for Traktor Kontrol S4 MK3
+ native_instruments_traktor_kontrol_s4_mk3
+
+
+
+
+
+
+
+
+
+
diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js
new file mode 100644
index 000000000000..b20056345aa7
--- /dev/null
+++ b/res/controllers/Traktor-Kontrol-S4-MK3.js
@@ -0,0 +1,3157 @@
+/// Created by Be and A. Colombier
+
+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,
+};
+
+
+// This define the sequence of color to use for pad button when in keyboard mode. This should make them look like an actual keyboard keyboard octave, except for C, which is green to help spotting it.
+const KeyboardColors = [
+ LedColors.green,
+ LedColors.off,
+ LedColors.white,
+ LedColors.off,
+ LedColors.white,
+ LedColors.white,
+ LedColors.off,
+ LedColors.white,
+ LedColors.off,
+ LedColors.white,
+ LedColors.off,
+ LedColors.white,
+];
+
+/*
+ * USER CONFIGURABLE SETTINGS
+ * Adjust these to your liking
+ */
+
+const DeckColors = [
+ LedColors.red,
+ LedColors.blue,
+ LedColors.yellow,
+ LedColors.purple,
+];
+
+const LibrarySortableColumns = [
+ script.LIBRARY_COLUMNS.ARTIST,
+ script.LIBRARY_COLUMNS.TITLE,
+ script.LIBRARY_COLUMNS.BPM,
+ script.LIBRARY_COLUMNS.KEY,
+ script.LIBRARY_COLUMNS.DATETIME_ADDED,
+];
+
+const LoopWheelMoveFactor = 50;
+const LoopEncoderMoveFactor = 500;
+const LoopEncoderShiftmoveFactor = 2500;
+
+const TempoFaderSoftTakeoverColorLow = LedColors.white;
+const TempoFaderSoftTakeoverColorHigh = LedColors.green;
+
+// Define whether or not to keep LED that have only one color (reverse, flux, play, shift) dimmed if they are inactive.
+// 'true' will keep them dimmed, 'false' will turn them off. Default: true
+const KeepLEDWithOneColorDimedWhenInactive = true;
+
+// Keep both deck select buttons backlit and do not fully turn off the inactive deck button.
+// 'true' will keep the unseclected deck dimmed, 'false' to fully turn it off. Default: true
+const KeepDeckSelectDimmed = true;
+
+// Define whether the keylock is mapped when doing "shift+master" (on press) or "shift+sync" (on release since long push copies the key)".
+// 'true' will use "sync+master", 'false' will use "shift+sync". Default: false
+const UseKeylockOnMaster = false;
+
+// Define whether the grid button would blink when the playback is going over a detcted beat. Can help to adjust beat grid.
+// Default: false
+const GridButtonBlinkOverBeat = false;
+
+// Wheel led blinking if reaching the end of track warning (default 30 seconds, can be changed in the settings, under "Waveforms" > "End of track warning").
+// Default: true
+const WheelLedBlinkOnTrackEnd = true;
+
+// When shifting either decks, the mixer will control microphones or auxiliary lines. If there is both a mic and an configure on the same channel, the mixer will control the auxiliary.
+// Default: false
+const MixerControlsMixAuxOnShift = false;
+
+// Define how many wheel moves are sampled to compute the speed. The more you have, the more the speed is accurate, but the
+// less responsive it gets in Mixxx. Default: 5
+const WheelSpeedSample = 3;
+
+// Make the sampler tab a beatlooproll tab instead
+// Default: false
+const UseBeatloopRollInsteadOfSampler = false;
+
+// Predefined beatlooproll sizes. Note that if you use AddLoopHalveAndDoubleOnBeatloopRollTab, the first and
+// last size will be ignored
+const BeatLoopRolls = [1/16, 1/8, 1/4, 1/2, 1, 2, 4, 8];
+
+// Make the two last button on the beatlooproll pad halve or double the loop size. This will take away the 1/16 and 8 loop size.
+// Default: true
+const AddLoopHalveAndDoubleOnBeatloopRollTab = true;
+
+// Define the speed of the jogwheel. This will impact the speed of the LED playback indicator, the sratch, and the speed of
+// the motor if enable. Recommended value are 33 + 1/3 or 45.
+// Default: 33 + 1/3
+const BaseRevolutionsPerMinute = 33 + 1/3;
+
+// Define whether or not to use motors.
+// This is a BETA feature! Please use at your own risk. Setting this off means that below settings are inactive
+// Default: false
+const UseMotors = false;
+
+// Define how many wheel moves are sampled to compute the speed when using the motor. This is helpful to mitigate delay that
+// occurs in communication as well as Mixxx limitation to 20ms latency.
+// The more you have, the more the speed is accurate.
+// less responsive it gets in Mixxx. Default: 20
+const TurnTableSpeedSample = 20;
+
+// Define how much the wheel will resist. It is a similar setting that the Grid+Wheel in Tracktor
+// Value must defined between 0 to 1. 0 is very tight, 1 is very loose.
+// Default: 0.5
+const TightnessFactor = 0.5;
+
+// Define how much force can the motor use. This defines how much the wheel will "fight" you when you block it in TT mode
+// This will also affect how quick the wheel starts spinning when enabling motor mode, or starting a deck with motor mode on
+const MaxWheelForce = 25000; // Traktor seems to cap the max value at 60000, which just sounds insane
+
+
+
+// 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.green,
+ LedColors.blue,
+ LedColors.yellow,
+ LedColors.orange,
+ 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.honey,
+ LedColors.yellow + 1,
+
+ LedColors.lime,
+ LedColors.aqua,
+ 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;
+
+const MotorWindUpMilliseconds = 1200;
+const MotorWindDownMilliseconds = 900;
+
+/*
+ * HID report parsing library
+ */
+class HIDInputReport {
+ constructor(reportId) {
+ this.reportId = reportId;
+ this.fields = [];
+ }
+
+ registerCallback(callback, byteOffset, bitOffset = 0, bitLength = 1, defaultOldData = undefined) {
+ if (typeof callback !== "function") {
+ throw Error("callback must be a function");
+ }
+
+ if (!Number.isInteger(byteOffset)) {
+ throw Error("byteOffset must be 0 or a positive integer");
+ }
+ if (!Number.isInteger(bitOffset) || bitOffset < 0) {
+ throw Error("bitOffset must be 0 or a positive integer");
+ }
+ if (!Number.isInteger(bitOffset) || bitLength < 1 || bitLength > 32) {
+ throw Error("bitLength must be an integer between 1 and 32");
+ }
+
+ const field = {
+ callback: callback,
+ byteOffset: byteOffset,
+ bitOffset: bitOffset,
+ bitLength: bitLength,
+ oldData: defaultOldData
+ };
+ this.fields.push(field);
+
+ return {
+ disconnect: () => {
+ this.fields = this.fields.filter((element) => {
+ return element !== field;
+ });
+ }
+ };
+ }
+
+ handleInput(reportData) {
+ const view = new DataView(reportData);
+
+ 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 HIDOutputReport {
+ constructor(reportId, length) {
+ this.reportId = reportId;
+ this.data = new Uint8Array(length).fill(0);
+ }
+ send() {
+ controller.sendOutputReport(this.reportId, this.data.buffer);
+ }
+}
+
+/*
+ * Components library
+ */
+
+class Component {
+ constructor(options) {
+ if (options) {
+ Object.keys(options).forEach(function(key) {
+ if (options[key] === undefined) { delete options[key]; }
+ });
+ Object.assign(this, options);
+ }
+ this.outConnections = [];
+ if (typeof this.key === "string") {
+ this.inKey = this.key;
+ this.outKey = this.key;
+ }
+ if (typeof this.unshift === "function" && this.unshift.length === 0) {
+ this.unshift();
+ }
+ this.shifted = false;
+ if (typeof this.input === "function" && this.inReport instanceof HIDInputReport && this.inReport.length === 0) {
+ this.inConnect();
+ }
+ this.outConnect();
+ }
+ inConnect(callback) {
+ if (this.inByte === undefined
+ || this.inBit === undefined
+ || this.inBitLength === undefined
+ || this.inReport === undefined) {
+ return;
+ }
+ if (typeof callback === "function") {
+ this.input = callback;
+ }
+ this.inConnection = this.inReport.registerCallback(this.input.bind(this), this.inByte, this.inBit, this.inBitLength, this.oldDataDefault);
+ }
+ inDisconnect() {
+ if (this.inConnection !== undefined) {
+ this.inConnection.disconnect();
+ }
+ }
+ send(value) {
+ if (this.outReport !== undefined && this.outByte !== undefined) {
+ this.outReport.data[this.outByte] = value;
+ this.outReport.send();
+ }
+ }
+ output(value) {
+ this.send(value);
+ }
+ outConnect() {
+ if (this.outKey !== undefined && this.group !== undefined) {
+ const connection = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+ // This is useful for case where effect would have been fully disabled in Mixxx. This appears to be the case during unit tests.
+ if (connection) {
+ this.outConnections[0] = connection;
+ } else {
+ console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ }
+ outDisconnect() {
+ for (const connection of this.outConnections) {
+ connection.disconnect();
+ }
+ this.outConnections = [];
+ }
+ outTrigger() {
+ for (const connection of this.outConnections) {
+ connection.trigger();
+ }
+ }
+}
+class ComponentContainer extends Component {
+ constructor() {
+ super();
+ }
+ *[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 (Array.isArray(obj)) {
+ for (const objectInArray of obj) {
+ if (objectInArray instanceof Component) {
+ yield objectInArray;
+ }
+ }
+ }
+ }
+ }
+ }
+ reconnectComponents(callback) {
+ for (const component of this) {
+ if (typeof component.outDisconnect === "function" && component.outDisconnect.length === 0) {
+ component.outDisconnect();
+ }
+ if (typeof callback === "function" && callback.length === 1) {
+ callback.call(this, component);
+ }
+ if (typeof component.outConnect === "function" && component.outConnect.length === 0) {
+ component.outConnect();
+ }
+ component.outTrigger();
+ if (typeof component.unshift === "function" && component.unshift.length === 0) {
+ component.unshift();
+ }
+ }
+ }
+ unshift() {
+ for (const component of this) {
+ if (typeof component.unshift === "function" && component.unshift.length === 0) {
+ component.unshift();
+ }
+ component.shifted = false;
+ }
+ this.shifted = false;
+ }
+ shift() {
+ for (const component of this) {
+ if (typeof component.shift === "function" && component.shift.length === 0) {
+ component.shift();
+ }
+ component.shifted = true;
+ }
+ this.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];
+ }
+ this.secondDeckModes = null;
+ }
+ 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) {
+ const currentModes = {
+ wheelMode: this.wheelMode,
+ moveMode: this.moveMode,
+ };
+
+ engine.setValue(this.group, "scratch2_enable", false);
+ this.group = newGroup;
+ this.color = this.groupsToColors[newGroup];
+
+ if (this.secondDeckModes !== null) {
+ this.wheelMode = this.secondDeckModes.wheelMode;
+ this.moveMode = this.secondDeckModes.moveMode;
+
+ if (this.wheelMode === wheelModes.motor) {
+ engine.beginTimer(MotorWindUpMilliseconds, function() {
+ engine.setValue(newGroup, "scratch2_enable", true);
+ }, true);
+ }
+ }
+
+ if (currentModes.wheelMode === wheelModes.motor) {
+ this.wheelTouch.touched = true;
+ engine.beginTimer(MotorWindDownMilliseconds, () => {
+ this.wheelTouch.touched = false;
+ }, true);
+ }
+ 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];
+ });
+ this.secondDeckModes = currentModes;
+ }
+ static groupForNumber(deckNumber) {
+ return `[Channel${deckNumber}]`;
+ }
+}
+
+class Button extends Component {
+ constructor(options) {
+ options.oldDataDefault = 0;
+
+ super(options);
+
+ if (this.input === undefined) {
+ this.input = this.defaultInput;
+ if (typeof this.input === "function"
+ && this.inReport instanceof HIDInputReport
+ && this.input.length === 0) {
+ this.inConnect();
+ }
+ }
+
+ if (this.longPressTimeOutMillis === undefined) {
+ this.longPressTimeOutMillis = 225;
+ }
+ if (this.indicatorIntervalMillis === undefined) {
+ this.indicatorIntervalMillis = 350;
+ }
+ this.longPressTimer = 0;
+ this.indicatorTimer = 0;
+ this.indicatorState = false;
+ this.isLongPress = false;
+ if (this.inBitLength === undefined) {
+ this.inBitLength = 1;
+ }
+ }
+ setKey(key) {
+ this.inKey = key;
+ if (key === this.outKey) {
+ return;
+ }
+ this.outDisconnect();
+ this.outKey = key;
+ this.outConnect();
+ this.outTrigger();
+ }
+ setGroup(group) {
+ if (group === this.group) {
+ return;
+ }
+ this.outDisconnect();
+ this.group = group;
+ this.outConnect();
+ this.outTrigger();
+ }
+ output(value) {
+ if (this.indicatorTimer !== 0) {
+ return;
+ }
+ const brightness = (value > 0) ? this.brightnessOn : this.brightnessOff;
+ this.send((this.color || LedColors.white) + brightness);
+ }
+ indicatorCallback() {
+ this.indicatorState = !this.indicatorState;
+ this.send((this.indicatorColor || this.color || LedColors.white) + (this.indicatorState ? this.brightnessOn : this.brightnessOff));
+ }
+ indicator(on) {
+ if (on && this.indicatorTimer === 0) {
+ this.outDisconnect();
+ this.indicatorTimer = engine.beginTimer(this.indicatorIntervalMillis, this.indicatorCallback.bind(this));
+ } else if (!on && this.indicatorTimer !== 0) {
+ engine.stopTimer(this.indicatorTimer);
+ this.indicatorTimer = 0;
+ this.indicatorState = false;
+ this.outConnect();
+ this.outTrigger();
+ }
+ }
+ defaultInput(pressed) {
+ if (pressed) {
+ this.isLongPress = false;
+ if (typeof this.onShortPress === "function" && this.onShortPress.length === 0) { this.onShortPress(); }
+ if ((typeof this.onLongPress === "function" && this.onLongPress.length === 0) || (typeof this.onLongRelease === "function" && this.onLongRelease.length === 0)) {
+ this.longPressTimer = engine.beginTimer(this.longPressTimeOutMillis, () => {
+ this.isLongPress = true;
+ this.longPressTimer = 0;
+ if (typeof this.onLongPress !== "function") { return; }
+ this.onLongPress(this);
+ }, true);
+ }
+ } else if (this.isLongPress) {
+ if (typeof this.onLongRelease === "function" && this.onLongRelease.length === 0) { this.onLongRelease(); }
+ } else {
+ if (this.longPressTimer !== 0) {
+ engine.stopTimer(this.longPressTimer);
+ this.longPressTimer = 0;
+ }
+ if (typeof this.onShortRelease === "function" && this.onShortRelease.length === 0) { this.onShortRelease(); }
+ }
+ }
+}
+
+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);
+ }
+ onShortPress() {
+ script.toggleControl(this.group, this.inKey, true);
+ }
+}
+
+class TriggerButton extends Button {
+ constructor(options) {
+ super(options);
+ }
+ onShortPress() {
+ engine.setValue(this.group, this.inKey, true);
+ }
+ onShortRelease() {
+ engine.setValue(this.group, this.inKey, false);
+ }
+}
+
+class PowerWindowButton extends Button {
+ constructor(options) {
+ super(options);
+ this.isLongPressed = false;
+ this.longPressTimer = 0;
+ }
+ onShortPress() {
+ script.toggleControl(this.group, this.inKey);
+ }
+ onLongRelease() {
+ script.toggleControl(this.group, this.inKey);
+ }
+}
+
+class PlayButton extends Button {
+ constructor(options) {
+ // Prevent accidental ejection/duplication accident
+ options.longPressTimeOutMillis = 800;
+ super(options);
+ this.inKey = "play";
+ this.outKey = "play_indicator";
+ this.outConnect();
+ }
+ onShortPress() {
+ script.toggleControl(this.group, this.inKey, true);
+ }
+ onLongPress() {
+ if (this.shifted) {
+ engine.setValue(this.group, this.inKey, false);
+ script.triggerControl(this.group, "eject");
+ } else if (!engine.getValue(this.group, this.inKey)) {
+ script.triggerControl(this.group, "CloneFromDeck");
+ }
+ }
+}
+
+class CueButton extends PushButton {
+ constructor(options) {
+ super(options);
+ this.outKey = "cue_indicator";
+ this.outConnect();
+ }
+ unshift() {
+ this.inKey = "cue_default";
+ }
+ shift() {
+ this.inKey = "start_stop";
+ }
+ input(pressed) {
+ if (this.deck.moveMode === moveModes.keyboard && !this.deck.keyboardPlayMode) {
+ this.deck.assignKeyboardPlayMode(this.group, this.inKey);
+ } else if (this.deck.wheelMode === wheelModes.motor && engine.getValue(this.group, "play") && pressed) {
+ engine.setValue(this.group, "cue_goto", pressed);
+ } else {
+ engine.setValue(this.group, this.inKey, pressed);
+ if (this.deck.wheelMode === wheelModes.motor) {
+ engine.setValue(this.group, "scratch2_enable", false);
+ engine.beginTimer(MotorWindDownMilliseconds, function() {
+ engine.setValue(this.group, "scratch2_enable", true);
+ }, true);
+ }
+ }
+ }
+}
+
+class Encoder extends Component {
+ constructor(options) {
+ super(options);
+ this.lastValue = null;
+ }
+ input(value) {
+ const oldValue = this.lastValue;
+ this.lastValue = value;
+
+ if (oldValue === null || typeof this.onChange !== "function") {
+ // This scenario happens at the controller initialisation. No real input to proceed
+ return;
+ }
+ let isRight;
+ if (oldValue === this.max && value === 0) {
+ isRight = true;
+ } else if (oldValue === 0 && value === this.max) {
+ isRight = false;
+ } else {
+ isRight = value > oldValue;
+ }
+ this.onChange(isRight);
+ }
+}
+
+/*
+ * Represent a pad button that interact with a hotcue (set, activate or clear)
+ */
+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`;
+ }
+ input(pressed) {
+ engine.setValue(this.group, "scratch2_enable", false);
+ engine.setValue(this.group, this.inKey, pressed);
+ }
+ output(value) {
+ if (value) {
+ this.send(this.color + this.brightnessOn);
+ } else {
+ this.send(LedColors.off);
+ }
+ }
+ outConnect() {
+ if (undefined !== this.group) {
+ const connection0 = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+ if (connection0) {
+ this.outConnections[0] = connection0;
+ } else {
+ console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`);
+ }
+ const connection1 = engine.makeConnection(this.group, this.colorKey, (colorCode) => {
+ this.color = this.colorMap.getValueForNearestColor(colorCode);
+ this.output(engine.getValue(this.group, this.outKey));
+ });
+ if (connection1) {
+ this.outConnections[1] = connection1;
+ } else {
+ console.warn(`Unable to connect ${this.group}.${this.colorKey}' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ }
+}
+
+/*
+ * Represent a pad button that acts as a keyboard key. Depending the deck keyboard mode, it will either change the key, or play the cue with the button's key
+ */
+class KeyboardButton extends PushButton {
+ constructor(options) {
+ super(options);
+ if (this.number === undefined || !Number.isInteger(this.number) || this.number < 1 || this.number > 8) {
+ throw Error("KeyboardButton must have a number property of an integer between 1 and 8");
+ }
+ if (this.deck === undefined) {
+ throw Error("KeyboardButton must have a deck attached to it");
+ }
+ this.outConnect();
+ }
+ unshift() {
+ this.outTrigger();
+ }
+ shift() {
+ this.outTrigger();
+ }
+ input(pressed) {
+ const offset = this.deck.keyboardOffset - (this.shifted ? 8 : 0);
+ if (this.number + offset < 1 || this.number + offset > 24) {
+ return;
+ }
+ if (pressed) {
+ engine.setValue(this.group, "key", this.number + offset);
+ }
+ if (this.deck.keyboardPlayMode !== null) {
+ if (this.deck.keyboardPlayMode.activeKey && pressed) {
+ engine.setValue(this.deck.keyboardPlayMode.group, "cue_goto", pressed);
+ } else if (!this.deck.keyboardPlayMode.activeKey || this.deck.keyboardPlayMode.activeKey === this) {
+ script.toggleControl(this.deck.keyboardPlayMode.group, this.deck.keyboardPlayMode.action, true);
+ }
+ if (!pressed && this.deck.keyboardPlayMode.activeKey === this) {
+ this.deck.keyboardPlayMode.activeKey = undefined;
+ } else if (pressed) {
+ this.deck.keyboardPlayMode.activeKey = this;
+ }
+ }
+ }
+ output(value) {
+ const offset = this.deck.keyboardOffset - (this.shifted ? 8 : 0);
+ const colorIdx = (this.number - 1 + offset) % KeyboardColors.length;
+ const color = KeyboardColors[colorIdx];
+ if (this.number + offset < 1 || this.number + offset > 24) {
+ this.send(0);
+ } else {
+ this.send(color + (value ? this.brightnessOn : this.brightnessOff));
+ }
+ }
+ outConnect() {
+ if (undefined !== this.group) {
+ const connection = engine.makeConnection(this.group, "key", (key) => {
+ const offset = this.deck.keyboardOffset - (this.shifted ? 8 : 0);
+ this.output(key === this.number + offset);
+ });
+ if (connection) {
+ this.outConnections[0] = connection;
+ } else {
+ console.warn(`Unable to connect ${this.group}.key' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ }
+}
+
+/*
+ * Represent a pad button that will trigger a pre-defined beatloop size as set in BeatLoopRolls.
+ */
+class BeatLoopRollButton extends TriggerButton {
+ constructor(options) {
+ if (options.number === undefined || !Number.isInteger(options.number) || options.number < 0 || options.number > 7) {
+ throw Error("BeatLoopRollButton must have a number property of an integer between 0 and 7");
+ }
+ if (options.number <= 5 || !AddLoopHalveAndDoubleOnBeatloopRollTab) {
+ options.key = "beatlooproll_"+BeatLoopRolls[AddLoopHalveAndDoubleOnBeatloopRollTab ? options.number + 1 : options.number]+"_activate";
+ options.onShortPress = function() {
+ if (!this.deck.beatloopSize) {
+ this.deck.beatloopSize = engine.getValue(this.group, "beatloop_size");
+ }
+ engine.setValue(this.group, this.inKey, true);
+ };
+ options.onShortRelease = function() {
+ engine.setValue(this.group, this.inKey, false);
+ if (this.deck.beatloopSize) {
+ engine.setValue(this.group, "beatloop_size", this.deck.beatloopSize);
+ this.deck.beatloopSize = undefined;
+ }
+ };
+ } else if (options.number === 6) {
+ options.key = "loop_halve";
+ } else {
+ options.key = "loop_double";
+ }
+ super(options);
+ if (this.deck === undefined) {
+ throw Error("BeatLoopRollButton must have a deck attached to it");
+ }
+
+ this.outConnect();
+ }
+ output(value) {
+ if (this.number <= 5 || !AddLoopHalveAndDoubleOnBeatloopRollTab) {
+ this.send(LedColors.white + (value ? this.brightnessOn : this.brightnessOff));
+ } else {
+ this.send(this.color);
+ }
+ }
+}
+
+/*
+ * Represent a pad button that interact with a sampler (load, play/pause, cue, eject)
+ */
+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();
+ }
+ onShortPress() {
+ if (!this.shifted) {
+ if (engine.getValue(this.group, "track_loaded") === 0) {
+ engine.setValue(this.group, "LoadSelectedTrack", 1);
+ } else {
+ engine.setValue(this.group, "cue_gotoandplay", 1);
+ }
+ } else {
+ if (engine.getValue(this.group, "play") === 1) {
+ engine.setValue(this.group, "play", 0);
+ } else {
+ engine.setValue(this.group, "eject", 1);
+ }
+ }
+ }
+ onShortRelease() {
+ if (this.shifted) {
+ 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) {
+ const connection0 = engine.makeConnection(this.group, "play", this.output.bind(this));
+ if (connection0) {
+ this.outConnections[0] = connection0;
+ } else {
+ console.warn(`Unable to connect ${this.group}.play' to the controller output. The control appears to be unavailable.`);
+ }
+ const connection1 = engine.makeConnection(this.group, "track_loaded", this.output.bind(this));
+ if (connection1) {
+ this.outConnections[1] = connection1;
+ } else {
+ console.warn(`Unable to connect ${this.group}.track_loaded' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ }
+}
+
+/*
+ * Represent a pad button that interact with a intro/extra special markers (set, activate, clear)
+ */
+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;
+ this.shiftedHardwarePosition = null;
+
+ if (this.input === undefined) {
+ this.input = this.defaultInput;
+ }
+ }
+ setGroupKey(group, key) {
+ this.inKey = key;
+ if (key === this.outKey && group === this.group) {
+ return;
+ }
+ this.outDisconnect();
+ this.group = group;
+ this.outKey = key;
+ this.outConnect();
+ }
+ defaultInput(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);
+ super.outDisconnect();
+ }
+}
+
+class Mixer extends ComponentContainer {
+ constructor(inReports, outReports) {
+ super();
+
+ this.outReport = outReports[128];
+
+ this.mixerColumnDeck1 = new S4Mk3MixerColumn(1, inReports, outReports[128],
+ {
+ saveGain: {inByte: 11, inBit: 0, outByte: 80},
+ effectUnit1Assign: {inByte: 2, inBit: 3, outByte: 78},
+ effectUnit2Assign: {inByte: 2, inBit: 4, outByte: 79},
+ gain: {inByte: 16},
+ eqHigh: {inByte: 44},
+ eqMid: {inByte: 46},
+ eqLow: {inByte: 48},
+ quickEffectKnob: {inByte: 64},
+ quickEffectButton: {},
+ volume: {inByte: 2},
+ pfl: {inByte: 7, inBit: 3, outByte: 77},
+ crossfaderSwitch: {inByte: 17, inBit: 4},
+ }
+ );
+ this.mixerColumnDeck2 = new S4Mk3MixerColumn(2, inReports, outReports[128],
+ {
+ saveGain: {inByte: 11, inBit: 1, outByte: 84},
+ effectUnit1Assign: {inByte: 2, inBit: 5, outByte: 82},
+ effectUnit2Assign: {inByte: 2, inBit: 6, outByte: 83},
+ gain: {inByte: 18},
+ eqHigh: {inByte: 50},
+ eqMid: {inByte: 52},
+ eqLow: {inByte: 54},
+ quickEffectKnob: {inByte: 66},
+ volume: {inByte: 4},
+ pfl: {inByte: 7, inBit: 6, outByte: 81},
+ crossfaderSwitch: {inByte: 17, inBit: 2},
+ }
+ );
+ this.mixerColumnDeck3 = new S4Mk3MixerColumn(3, inReports, outReports[128],
+ {
+ saveGain: {inByte: 2, inBit: 1, outByte: 88},
+ effectUnit1Assign: {inByte: 2, inBit: 0, outByte: 86},
+ effectUnit2Assign: {inByte: 2, inBit: 2, outByte: 87},
+ gain: {inByte: 14},
+ eqHigh: {inByte: 38},
+ eqMid: {inByte: 40},
+ eqLow: {inByte: 42},
+ quickEffectKnob: {inByte: 62},
+ volume: {inByte: 6},
+ pfl: {inByte: 7, inBit: 2, outByte: 85},
+ crossfaderSwitch: {inByte: 17, inBit: 6},
+ }
+ );
+ this.mixerColumnDeck4 = new S4Mk3MixerColumn(4, inReports, outReports[128],
+ {
+ saveGain: {inByte: 11, inBit: 2, outByte: 92},
+ effectUnit1Assign: {inByte: 2, inBit: 7, outByte: 90},
+ effectUnit2Assign: {inByte: 11, inBit: 7, outByte: 91},
+ gain: {inByte: 20},
+ eqHigh: {inByte: 56},
+ eqMid: {inByte: 58},
+ eqLow: {inByte: 60},
+ quickEffectKnob: {inByte: 68},
+ volume: {inByte: 8},
+ pfl: {inByte: 7, inBit: 7, outByte: 89},
+ crossfaderSwitch: {inByte: 17, inBit: 0},
+ }
+ );
+
+ this.firstPressedFxSelector = null;
+ this.secondPressedFxSelector = null;
+ this.comboSelected = false;
+
+ const fxSelectsInputs = [
+ {inByte: 8, inBit: 5},
+ {inByte: 8, inBit: 1},
+ {inByte: 8, inBit: 6},
+ {inByte: 8, inBit: 0},
+ {inByte: 8, inBit: 7},
+ ];
+ this.fxSelects = [];
+ // FX SELECT buttons: Filter, 1, 2, 3, 4
+ for (const i of [0, 1, 2, 3, 4]) {
+ this.fxSelects[i] = new FXSelect(
+ Object.assign(fxSelectsInputs[i], {
+ number: i + 1,
+ mixer: this,
+ })
+ );
+ }
+
+ const quickEffectInputs = [
+ {inByte: 7, inBit: 0, outByte: 46},
+ {inByte: 7, inBit: 5, outByte: 47},
+ {inByte: 7, inBit: 1, outByte: 48},
+ {inByte: 7, inBit: 4, outByte: 49},
+ ];
+ this.quickEffectButtons = [];
+ // FX SELECT buttons: 1, 2, 3, 4
+ for (const i of [0, 1, 2, 3]) {
+ this.quickEffectButtons[i] = new QuickEffectButton(
+ Object.assign(quickEffectInputs[i], {
+ number: i + 1,
+ mixer: this,
+ })
+ );
+ }
+ this.resetFxSelectorColors();
+
+ this.quantizeButton = new Button({
+ input: function(pressed) {
+ if (pressed) {
+ this.globalQuantizeOn = !this.globalQuantizeOn;
+ for (let deckIdx = 1; deckIdx <= 4; deckIdx++) {
+ engine.setValue(`[Channel${deckIdx}]`, "quantize", this.globalQuantizeOn);
+ }
+ this.send(this.globalQuantizeOn ? 127 : 0);
+ }
+ },
+ globalQuantizeOn: false,
+ inByte: 11,
+ inBit: 6,
+ outByte: 93,
+ });
+
+ this.crossfader = new Pot({
+ group: "[Master]",
+ inKey: "crossfader",
+ inByte: 0,
+ inReport: inReports[2],
+ });
+ this.crossfaderCurveSwitch = new Component({
+ inByte: 18,
+ inBit: 0,
+ inBitLength: 2,
+ input: function(value) {
+ switch (value) {
+ case 0x00: // Picnic Bench / Fast Cut
+ engine.setValue("[Mixer Profile]", "xFaderMode", 0);
+ engine.setValue("[Mixer Profile]", "xFaderCalibration", 0.9);
+ engine.setValue("[Mixer Profile]", "xFaderCurve", 7.0);
+ break;
+ case 0x01: // Constant Power
+ engine.setValue("[Mixer Profile]", "xFaderMode", 1);
+ engine.setValue("[Mixer Profile]", "xFaderCalibration", 0.3);
+ engine.setValue("[Mixer Profile]", "xFaderCurve", 0.6);
+ break;
+ case 0x02: // Additive
+ engine.setValue("[Mixer Profile]", "xFaderMode", 0);
+ engine.setValue("[Mixer Profile]", "xFaderCalibration", 0.4);
+ engine.setValue("[Mixer Profile]", "xFaderCurve", 0.9);
+ }
+ },
+ });
+
+ for (const component of this) {
+ if (component.inReport === undefined) {
+ component.inReport = inReports[1];
+ }
+ component.outReport = this.outReport;
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+
+ let lightQuantizeButton = true;
+ for (let deckIdx = 1; deckIdx <= 4; deckIdx++) {
+ if (!engine.getValue(`[Channel${deckIdx}]`, "quantize")) {
+ lightQuantizeButton = false;
+ }
+ }
+ this.quantizeButton.send(lightQuantizeButton ? 127 : 0);
+ this.quantizeButton.globalQuantizeOn = lightQuantizeButton;
+ }
+
+ calculatePresetNumber() {
+ if (this.firstPressedFxSelector === this.secondPressedFxSelector || this.secondPressedFxSelector === null) {
+ return this.firstPressedFxSelector;
+ }
+ let presetNumber = 5 + (4 * (this.firstPressedFxSelector - 1)) + this.secondPressedFxSelector;
+ if (this.secondPressedFxSelector > this.firstPressedFxSelector) {
+ presetNumber--;
+ }
+ return presetNumber;
+ }
+
+ resetFxSelectorColors() {
+ for (const selector of [1, 2, 3, 4, 5]) {
+ this.outReport.data[49 + selector] = QuickEffectPresetColors[selector - 1] + Button.prototype.brightnessOn;
+ }
+ this.outReport.send();
+ }
+}
+
+class FXSelect extends Button {
+ constructor(options) {
+ super(options);
+
+ if (this.mixer === undefined) {
+ throw Error("The mixer must be specified");
+ }
+ }
+
+ onShortPress() {
+ if (this.mixer.firstPressedFxSelector === null) {
+ this.mixer.firstPressedFxSelector = this.number;
+ for (const selector of [1, 2, 3, 4, 5]) {
+ if (selector !== this.number) {
+ let presetNumber = 5 + (4 * (this.mixer.firstPressedFxSelector - 1)) + selector;
+ if (selector > this.number) {
+ presetNumber--;
+ }
+ this.outReport.data[49 + selector] = QuickEffectPresetColors[presetNumber - 1] + this.brightnessOn;
+ }
+ }
+ this.outReport.send();
+ } else {
+ this.mixer.secondPressedFxSelector = this.number;
+ }
+
+ }
+
+ onShortRelease() {
+ // After a second selector was released, avoid loading a different preset when
+ // releasing the first pressed selector.
+ if (this.mixer.comboSelected && this.number === this.mixer.firstPressedFxSelector) {
+ this.mixer.comboSelected = false;
+ this.mixer.firstPressedFxSelector = null;
+ this.mixer.secondPressedFxSelector = null;
+ this.mixer.resetFxSelectorColors();
+ return;
+ }
+ // If mixer.firstPressedFxSelector === null, it was reset by the input handler for
+ // a QuickEffect enable button to load the preset for only one deck.
+ if (this.mixer.firstPressedFxSelector !== null) {
+ for (const deck of [1, 2, 3, 4]) {
+ const presetNumber = this.mixer.calculatePresetNumber();
+ engine.setValue(`[QuickEffectRack1_[Channel${deck}]]`, "loaded_chain_preset", presetNumber + 1);
+ }
+ }
+ if (this.mixer.firstPressedFxSelector === this.number) {
+ this.mixer.firstPressedFxSelector = null;
+ this.mixer.resetFxSelectorColors();
+ }
+ if (this.mixer.secondPressedFxSelector !== null) {
+ this.mixer.comboSelected = true;
+ }
+ this.mixer.secondPressedFxSelector = null;
+ }
+
+}
+
+
+class QuickEffectButton extends Button {
+ constructor(options) {
+ super(options);
+ if (this.mixer === undefined) {
+ throw Error("The mixer must be specified");
+ }
+ if (this.number === undefined || !Number.isInteger(this.number) || this.number < 1) {
+ throw Error("number attribute must be an integer >= 1");
+ }
+ this.group = `[QuickEffectRack1_[Channel${this.number}]]`;
+ this.outConnect();
+ }
+ onShortPress() {
+ if (this.mixer.firstPressedFxSelector === null) {
+ script.toggleControl(this.group, "enabled");
+ } else {
+ const presetNumber = this.mixer.calculatePresetNumber();
+ this.color = QuickEffectPresetColors[presetNumber - 1];
+ engine.setValue(this.group, "loaded_chain_preset", presetNumber + 1);
+ this.mixer.firstPressedFxSelector = null;
+ this.mixer.secondPressedFxSelector = null;
+ this.mixer.resetFxSelectorColors();
+ }
+ }
+ onLongRelease() {
+ if (this.mixer.firstPressedFxSelector === null) {
+ script.toggleControl(this.group, "enabled");
+ }
+ }
+ output(enabled) {
+ if (enabled) {
+ this.send(this.color + this.brightnessOn);
+ } else {
+ // It is easy to mistake the dim state for the bright state, so turn
+ // the LED fully off.
+ this.send(this.color + this.brightnessOff);
+ }
+ }
+ presetLoaded(presetNumber) {
+ this.color = QuickEffectPresetColors[presetNumber - 2];
+ this.outConnections[1].trigger();
+ }
+ outConnect() {
+ if (this.group !== undefined) {
+ const connection0 = engine.makeConnection(this.group, "loaded_chain_preset", this.presetLoaded.bind(this));
+ if (connection0) {
+ this.outConnections[0] = connection0;
+ } else {
+ console.warn(`Unable to connect ${this.group}.loaded_chain_preset' to the controller output. The control appears to be unavailable.`);
+ }
+ const connection1 = engine.makeConnection(this.group, "enabled", this.output.bind(this));
+ if (connection1) {
+ this.outConnections[1] = connection1;
+ } else {
+ console.warn(`Unable to connect ${this.group}.enabled' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ }
+}
+
+/*
+ * 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.uncoloredOutput = function(value) {
+ if (this.indicatorTimer !== 0) {
+ return;
+ }
+ const color = (value > 0) ? (this.color || LedColors.white) + this.brightnessOn : LedColors.off;
+ this.send(color);
+};
+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; // One tick every 10ns
+
+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
+};
+
+// The mode available, which the wheel can be used for.
+const wheelModes = {
+ jog: 0,
+ vinyl: 1,
+ motor: 2,
+ loopIn: 3,
+ loopOut: 4,
+};
+
+const moveModes = {
+ beat: 0,
+ bpm: 1,
+ grid: 2,
+ keyboard: 3,
+};
+
+// tracks state across input reports
+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
+ */
+
+class S4Mk3EffectUnit extends ComponentContainer {
+ constructor(unitNumber, inReports, outReport, io) {
+ super();
+ this.group = `[EffectRack1_EffectUnit${unitNumber}]`;
+ this.unitNumber = unitNumber;
+ this.focusedEffect = null;
+
+ this.mixKnob = new Pot({
+ inKey: "mix",
+ group: this.group,
+ inReport: inReports[2],
+ inByte: io.mixKnob.inByte,
+ });
+
+ this.mainButton = new PowerWindowButton({
+ unit: this,
+ inReport: inReports[1],
+ inByte: io.mainButton.inByte,
+ inBit: io.mainButton.inBit,
+ outByte: io.mainButton.outByte,
+ outReport: outReport,
+ shift: function() {
+ this.group = this.unit.group;
+ this.outKey = "group_[Master]_enable";
+ this.outConnect();
+ this.outTrigger();
+ },
+ unshift: function() {
+ this.outDisconnect();
+ this.outKey = undefined;
+ this.group = undefined;
+ this.output(false);
+ },
+ input: function(pressed) {
+ if (!this.shifted) {
+ for (const index of [0, 1, 2]) {
+ const effectGroup = `[EffectRack1_EffectUnit${unitNumber}_Effect${index + 1}]`;
+ engine.setValue(effectGroup, "enabled", pressed);
+ }
+ this.output(pressed);
+ } else if (pressed) {
+ if (this.unit.focusedEffect !== null) {
+ this.unit.setFocusedEffect(null);
+ } else {
+ script.toggleControl(this.unit.group, "group_[Master]_enable");
+ this.shift();
+ }
+ }
+ }
+ });
+
+ 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,
+ inReport: inReports[2],
+ inByte: io.knobs[index].inByte,
+ });
+ this.buttons[index] = new Button({
+ unit: this,
+ key: "enabled",
+ group: effectGroup,
+ inReport: inReports[1],
+ inByte: io.buttons[index].inByte,
+ inBit: io.buttons[index].inBit,
+ outByte: io.buttons[index].outByte,
+ outReport: outReport,
+ onShortPress: function() {
+ if (!this.shifted || this.unit.focusedEffect !== null) {
+ script.toggleControl(this.group, this.inKey);
+ }
+ },
+ onLongPress: function() {
+ if (this.shifted) {
+ this.unit.setFocusedEffect(index);
+ }
+ },
+ onShortRelease: function() {
+ if (this.shifted && this.unit.focusedEffect === null) {
+ script.triggerControl(this.group, "next_effect");
+ }
+ },
+ onLongRelease: function() {
+ if (!this.shifted) {
+ script.toggleControl(this.group, this.inKey);
+ }
+ }
+ });
+ }
+
+ for (const component of this) {
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+ }
+ indicatorLoop() {
+ this.focusedEffectIndicator = !this.focusedEffectIndicator;
+ this.mainButton.output(true);
+ }
+ setFocusedEffect(effectIdx) {
+ this.mainButton.indicator(effectIdx !== null);
+ this.focusedEffect = effectIdx;
+ engine.setValue(this.group, "show_parameters", this.focusedEffect !== null);
+
+
+ const effectGroup = `[EffectRack1_EffectUnit${this.unitNumber}_Effect${this.focusedEffect + 1}]`;
+ for (const index of [0, 1, 2]) {
+ const unfocusGroup = `[EffectRack1_EffectUnit${this.unitNumber}_Effect${index + 1}]`;
+ this.buttons[index].outDisconnect();
+ this.buttons[index].group = this.focusedEffect === null ? unfocusGroup : effectGroup;
+ this.buttons[index].inKey = this.focusedEffect === null ? "enabled" : "button_parameter" + (index + 1);
+ this.buttons[index].shift = this.focusedEffect === null ? undefined : function() {
+ this.setGroup(unfocusGroup);
+ this.setKey("enabled");
+ };
+ this.buttons[index].unshift = this.focusedEffect === null ? undefined : function() {
+ this.setGroup(effectGroup);
+ this.setKey("button_parameter" + (index + 1));
+ };
+ this.buttons[index].outKey = this.buttons[index].inKey;
+ this.knobs[index].group = this.buttons[index].group;
+ this.knobs[index].inKey = this.focusedEffect === null ? "meta" : "parameter" + (index + 1);
+ this.knobs[index].shift = this.focusedEffect === null ? undefined : function() {
+ this.setGroupKey(unfocusGroup, "meta");
+ };
+ this.knobs[index].unshift = this.focusedEffect === null ? undefined : function() {
+ this.setGroupKey(effectGroup, "parameter" + (index + 1));
+ };
+ this.buttons[index].outConnect();
+ }
+ }
+}
+
+class S4Mk3Deck extends Deck {
+ constructor(decks, colors, effectUnit, mixer, inReports, outReport, io) {
+ super(decks, colors);
+
+ this.playButton = new PlayButton({
+ output: KeepLEDWithOneColorDimedWhenInactive ? undefined : Button.prototype.uncoloredOutput
+ });
+
+ this.cueButton = new CueButton({
+ deck: this
+ });
+
+ this.effectUnit = effectUnit;
+ this.mixer = mixer;
+
+ this.syncMasterButton = new Button({
+ key: "sync_leader",
+ defaultRange: 0.08,
+ shift: UseKeylockOnMaster ? function() {
+ this.setKey("keylock");
+ } : undefined,
+ unshift: UseKeylockOnMaster ? function() {
+ this.setKey("sync_leader");
+ } : undefined,
+ onShortRelease: function() {
+ script.toggleControl(this.group, this.inKey);
+ },
+ onLongPress: function() {
+ const currentRange = engine.getValue(this.group, "rateRange");
+ if (currentRange < 1.0) {
+ engine.setValue(this.group, "rateRange", 1.0);
+ this.indicator(true);
+ } else {
+ engine.setValue(this.group, "rateRange", this.defaultRange);
+ this.indicator(false);
+ }
+ },
+ });
+ this.syncButton = new Button({
+ key: "sync_enabled",
+ onLongPress: function() {
+ if (this.shifted) {
+ engine.setValue(this.group, "sync_key", true);
+ engine.setValue(this.group, "sync_key", false);
+ } else {
+ script.triggerControl(this.group, "beatsync_tempo");
+ }
+ },
+ onShortRelease: function() {
+ script.toggleControl(this.group, this.inKey);
+ if (!this.shifted) {
+ engine.softTakeover(this.group, "rate", true);
+ }
+ },
+ shift: !UseKeylockOnMaster ? function() {
+ this.setKey("keylock");
+ } : undefined,
+ unshift: !UseKeylockOnMaster ? function() {
+ this.setKey("sync_enabled");
+ } : undefined,
+ });
+ 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 Button({
+ key: "reverseroll",
+ deck: this,
+ previousWheelMode: null,
+ loopModeConnection: null,
+ unshift: function() {
+ this.setKey("reverseroll");
+
+ },
+ shift: function() {
+ this.setKey("loop_enabled");
+ },
+ output: KeepLEDWithOneColorDimedWhenInactive ? undefined : Button.prototype.uncoloredOutput,
+ onShortRelease: function() {
+ if (!this.shifted) {
+ engine.setValue(this.group, this.key, false);
+ }
+ },
+ loopModeOff: function(skipRestore) {
+ if (this.previousWheelMode !== null) {
+ this.indicator(false);
+ const wheelOutput = new Uint8Array(40).fill(0);
+ wheelOutput[0] = decks[0] - 1;
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+ if (!skipRestore) {
+ this.deck.wheelMode = this.previousWheelMode;
+ }
+ this.previousWheelMode = null;
+ if (this.loopModeConnection !== null) {
+ this.loopModeConnection.disconnect();
+ this.loopModeConnection = null;
+ }
+ }
+ },
+ onLoopChange: function(loopEnabled) {
+ if (loopEnabled) { return; }
+ this.loopModeOff();
+ },
+ onShortPress: function() {
+ this.indicator(false);
+ if (this.shifted) {
+ const loopEnabled = engine.getValue(this.group, "loop_enabled");
+ // If there is currently no loop, we set the loop in of a new loop
+ if (!loopEnabled) {
+ engine.setValue(this.group, "loop_end_position", -1);
+ engine.setValue(this.group, "loop_in", true);
+ this.indicator(true);
+ // Else, we enter/exit the loop in wheel mode
+ } else if (this.previousWheelMode === null) {
+ this.deck.fluxButton.loopModeOff();
+ engine.setValue(this.group, "scratch2_enable", false);
+ this.previousWheelMode = this.deck.wheelMode;
+ this.deck.wheelMode = wheelModes.loopIn;
+
+ if (this.loopModeConnection === null) {
+ this.loopModeConnection = engine.makeConnection(this.group, this.outKey, this.onLoopChange.bind(this));
+ }
+
+ const wheelOutput = new Uint8Array(40).fill(0);
+ wheelOutput[0] = decks[0] - 1;
+ wheelOutput[1] = wheelLEDmodes.ringFlash;
+ wheelOutput[4] = this.color + Button.prototype.brightnessOn;
+
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+
+ this.indicator(true);
+ } else if (this.previousWheelMode !== null) {
+ this.loopModeOff();
+ }
+ } else {
+ engine.setValue(this.group, this.key, true);
+ }
+ }
+ });
+ this.fluxButton = new Button({
+ key: "slip_enabled",
+ deck: this,
+ previousWheelMode: null,
+ loopModeConnection: null,
+ unshift: function() {
+ this.setKey("slip_enabled");
+
+ },
+ shift: function() {
+ this.setKey("loop_enabled");
+ },
+ outConnect: function() {
+ if (this.outKey !== undefined && this.group !== undefined) {
+ const connection = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+ if (connection) {
+ this.outConnections[0] = connection;
+ } else {
+ console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`);
+ }
+ }
+ },
+ output: KeepLEDWithOneColorDimedWhenInactive ? undefined : Button.prototype.uncoloredOutput,
+ onShortRelease: function() {
+ if (!this.shifted) {
+ engine.setValue(this.group, this.key, false);
+ engine.setValue(this.group, "scratch2_enable", false);
+ }
+ },
+ loopModeOff: function(skipRestore) {
+ if (this.previousWheelMode !== null) {
+ this.indicator(false);
+ const wheelOutput = new Uint8Array(40).fill(0);
+ wheelOutput[0] = decks[0] - 1;
+ controller.sendOutputReport(wheelOutput.buffer, null, 50, true);
+ if (!skipRestore) {
+ this.deck.wheelMode = this.previousWheelMode;
+ }
+ this.previousWheelMode = null;
+ if (this.loopModeConnection !== null) {
+ this.loopModeConnection.disconnect();
+ this.loopModeConnection = null;
+ }
+ }
+ },
+ onLoopChange: function(loopEnabled) {
+ if (loopEnabled) { return; }
+ this.loopModeOff();
+ },
+ onShortPress: function() {
+ this.indicator(false);
+ if (this.shifted) {
+ const loopEnabled = engine.getValue(this.group, "loop_enabled");
+ // If there is currently no loop, we set the loop in of a new loop
+ if (!loopEnabled) {
+ engine.setValue(this.group, "loop_out", true);
+ this.deck.reverseButton.indicator(false);
+ // Else, we enter/exit the loop in wheel mode
+ } else if (this.previousWheelMode === null) {
+ this.deck.reverseButton.loopModeOff();
+ engine.setValue(this.group, "scratch2_enable", false);
+ this.previousWheelMode = this.deck.wheelMode;
+ this.deck.wheelMode = wheelModes.loopOut;
+ if (this.loopModeConnection === null) {
+ this.loopModeConnection = engine.makeConnection(this.group, this.outKey, this.onLoopChange.bind(this));
+ }
+
+ const wheelOutput = new Uint8Array(40).fill(0);
+ wheelOutput[0] = decks[0] - 1;
+ wheelOutput[1] = wheelLEDmodes.ringFlash;
+ wheelOutput[4] = this.color + Button.prototype.brightnessOn;
+
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+
+ this.indicator(true);
+ } else if (this.previousWheelMode !== null) {
+ this.loopModeOff();
+ }
+ } else {
+ engine.setValue(this.group, this.key, true);
+ }
+ }
+ });
+ this.gridButton = new Button({
+ key: GridButtonBlinkOverBeat ? "beat_active" : undefined,
+ deck: this,
+ previousMoveMode: null,
+ unshift: !GridButtonBlinkOverBeat ? function() {
+ this.output(false);
+ } : undefined,
+ onShortPress: function() {
+ this.deck.libraryEncoder.gridButtonPressed = true;
+
+ if (this.shift) {
+ engine.setValue(this.group, "bpm_tap", true);
+ }
+ },
+ onLongPress: function() {
+ this.deck.libraryEncoder.gridButtonPressed = true;
+ this.previousMoveMode = this.deck.moveMode;
+
+ if (this.shifted) {
+ this.deck.moveMode = moveModes.grid;
+ } else {
+ this.deck.moveMode = moveModes.bpm;
+ }
+
+ this.indicator(true);
+ },
+ onLongRelease: function() {
+ this.deck.libraryEncoder.gridButtonPressed = false;
+ if (this.previousMoveMode !== null) {
+ this.deck.moveMode = this.previousMoveMode;
+ this.previousMoveMode = null;
+ }
+ this.indicator(false);
+ },
+ onShortRelease: function() {
+ this.deck.libraryEncoder.gridButtonPressed = false;
+ script.triggerControl(this.group, "beats_translate_curpos");
+
+ if (this.shift) {
+ engine.setValue(this.group, "bpm_tap", false);
+ }
+ },
+ });
+
+ this.deckButtonLeft = new Button({
+ deck: this,
+ input: function(value) {
+ if (value) {
+ this.deck.switchDeck(Deck.groupForNumber(decks[0]));
+ this.outReport.data[io.deckButtonOutputByteOffset] = colors[0] + this.brightnessOn;
+ // turn off the other deck selection button's LED
+ this.outReport.data[io.deckButtonOutputByteOffset + 1] = KeepDeckSelectDimmed ? colors[1] + this.brightnessOff : 0;
+ this.outReport.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.outReport.data[io.deckButtonOutputByteOffset] = KeepDeckSelectDimmed ? colors[0] + this.brightnessOff : 0;
+ this.outReport.data[io.deckButtonOutputByteOffset + 1] = colors[1] + this.brightnessOn;
+ this.outReport.send();
+ }
+ },
+ });
+
+ // set deck selection button LEDs
+ outReport.data[io.deckButtonOutputByteOffset] = colors[0] + Button.prototype.brightnessOn;
+ outReport.data[io.deckButtonOutputByteOffset + 1] = KeepDeckSelectDimmed ? colors[1] + Button.prototype.brightnessOff : 0;
+ outReport.send();
+
+ this.shiftButton = new PushButton({
+ deck: this,
+ output: KeepLEDWithOneColorDimedWhenInactive ? undefined : Button.prototype.uncoloredOutput,
+ unshift: function() {
+ this.output(false);
+ },
+ shift: function() {
+ this.output(true);
+ },
+ input: function(pressed) {
+ if (pressed) {
+ this.deck.shift();
+ } else {
+ this.deck.unshift();
+ }
+ }
+ });
+
+ this.leftEncoder = new Encoder({
+ deck: this,
+ onChange: function(right) {
+
+ switch (this.deck.moveMode) {
+ case moveModes.grid:
+ script.triggerControl(this.group, right ? "beats_adjust_faster" : "beats_adjust_slower");
+ break;
+ case moveModes.keyboard:
+ if (
+ this.deck.keyboard[0].offset === (right ? 16 : 0)
+ ) {
+ return;
+ }
+ this.deck.keyboardOffset += (right ? 1 : -1);
+ this.deck.keyboard.forEach(function(pad) {
+ pad.outTrigger();
+ });
+ break;
+ case moveModes.bpm:
+ script.triggerControl(this.group, right ? "beats_translate_later" : "beats_translate_earlier");
+ break;
+ default:
+ 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 {
+ if (right) {
+ script.triggerControl(this.group, "pitch_up_small");
+ } else {
+ script.triggerControl(this.group, "pitch_down_small");
+ }
+ }
+ break;
+ }
+ }
+ });
+ this.leftEncoderPress = new PushButton({
+ input: function(pressed) {
+ this.pressed = pressed;
+ if (pressed) {
+ script.toggleControl(this.group, "pitch_adjust_set_default");
+ }
+ },
+ });
+
+ this.rightEncoder = new Encoder({
+ deck: this,
+ onChange: function(right) {
+ if (this.deck.wheelMode === wheelModes.loopIn || this.deck.wheelMode === wheelModes.loopOut) {
+ const moveFactor = this.shifted ? LoopEncoderShiftmoveFactor : LoopEncoderMoveFactor;
+ const valueIn = engine.getValue(this.group, "loop_start_position") + (right ? moveFactor : -moveFactor);
+ const valueOut = engine.getValue(this.group, "loop_end_position") + (right ? moveFactor : -moveFactor);
+ engine.setValue(this.group, "loop_start_position", valueIn);
+ engine.setValue(this.group, "loop_end_position", valueOut);
+ } else if (this.shifted) {
+ script.triggerControl(this.group, right ? "loop_move_1_forward" : "loop_move_1_backward");
+ } else {
+ script.triggerControl(this.group, right ? "loop_double" : "loop_halve");
+ }
+ }
+ });
+ 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 {
+ script.triggerControl(this.group, "reloop_toggle");
+ }
+ },
+ });
+
+ this.libraryEncoder = new Encoder({
+ libraryPlayButtonPressed: false,
+ gridButtonPressed: false,
+ starButtonPressed: false,
+ libraryViewButtonPressed: false,
+ libraryPlaylistButtonPressed: false,
+ currentSortedColumnIdx: -1,
+ onChange: function(right) {
+ if (this.libraryViewButtonPressed) {
+ this.currentSortedColumnIdx = (LibrarySortableColumns.length + this.currentSortedColumnIdx + (right ? 1 : -1)) % LibrarySortableColumns.length;
+ engine.setValue("[Library]", "sort_column", LibrarySortableColumns[this.currentSortedColumnIdx]);
+ } else if (this.starButtonPressed) {
+ if (this.shifted) {
+ // FIXME doesn't exist, feature request needed
+ script.triggerControl(this.group, right ? "track_color_prev" : "track_color_next");
+ } else {
+ script.triggerControl(this.group, right ? "stars_up" : "stars_down");
+ }
+ } else if (this.gridButtonPressed) {
+ script.triggerControl(this.group, right ? "waveform_zoom_up" : "waveform_zoom_down");
+ } else if (this.libraryPlayButtonPressed) {
+ script.triggerControl("[PreviewDeck1]", right ? "beatjump_16_forward" : "beatjump_16_backward");
+ } else {
+ // FIXME there is a bug where this action has no effect when the Mixxx window has no focused. https://github.com/mixxxdj/mixxx/issues/11285
+ // As a workaround, we are using deprecated control, hoping the bug will be fixed before the controls get removed
+ const currentlyFocusWidget = engine.getValue("[Library]", "focused_widget");
+ if (currentlyFocusWidget === 0) {
+ if (this.shifted) {
+ script.triggerControl("[Playlist]", right ? "SelectNextPlaylist" : "SelectPrevPlaylist");
+ } else {
+ script.triggerControl("[Playlist]", right ? "SelectNextTrack" : "SelectPrevTrack");
+ }
+ } else {
+ engine.setValue("[Library]", "focused_widget", this.shifted ? 2 : 3);
+ engine.setValue("[Library]", "MoveVertical", right ? 1 : -1);
+ }
+ }
+ }
+ });
+ this.libraryEncoderPress = new Button({
+ libraryViewButtonPressed: false,
+ onShortPress: function() {
+ if (this.libraryViewButtonPressed) {
+ script.toggleControl("[Library]", "sort_order");
+ } else {
+ const currentlyFocusWidget = engine.getValue("[Library]", "focused_widget");
+ // 3 == Tracks table or root views of library features
+ if (this.shifted && currentlyFocusWidget === 0) {
+ script.triggerControl("[Playlist]", "ToggleSelectedSidebarItem");
+ } else if (currentlyFocusWidget === 3 || currentlyFocusWidget === 0) {
+ script.triggerControl(this.group, "LoadSelectedTrack");
+ } else {
+ script.triggerControl("[Library]", "GoToItem");
+ }
+ }
+ },
+ // FIXME not supported, feature request
+ // onLongPress: function(){
+ // script.triggerControl("[Library]", "search_related_track", engine.getValue("[Library]", "sort_column"));
+ // }
+ });
+ this.libraryPlayButton = new PushButton({
+ group: "[PreviewDeck1]",
+ libraryEncoder: this.libraryEncoder,
+ input: function(pressed) {
+ if (pressed) {
+ script.triggerControl(this.group, "LoadSelectedTrackAndPlay");
+ } else {
+ engine.setValue(this.group, "play", 0);
+ script.triggerControl(this.group, "eject");
+ }
+ this.libraryEncoder.libraryPlayButtonPressed = pressed;
+ },
+ outKey: "play",
+ });
+ this.libraryStarButton = new Button({
+ group: "[Library]",
+ libraryEncoder: this.libraryEncoder,
+ onShortRelease: function() {
+ script.triggerControl(this.group, this.shifted ? "track_color_prev" : "track_color_next");
+ },
+ onLongPress: function() {
+ this.libraryEncoder.starButtonPressed = true;
+ },
+ onLongRelease: function() {
+ this.libraryEncoder.starButtonPressed = false;
+ },
+ });
+ // FIXME there is no feature about playlist at the moment, so we use this button to control the context menu, which has playlist control
+ this.libraryPlaylistButton = new Button({
+ group: "[Library]",
+ libraryEncoder: this.libraryEncoder,
+ outConnect: function() {
+ const connection = engine.makeConnection(this.group, "focused_widget", (widget) => {
+ // 4 == Context menu
+ this.output(widget === 4);
+ });
+ // This is useful for case where effect would have been fully disabled in Mixxx. This appears to be the case during unit tests.
+ if (connection) {
+ this.outConnections[0] = connection;
+ } else {
+ console.warn(`Unable to connect ${this.group}.focused_widget' to the controller output. The control appears to be unavailable.`);
+ }
+ },
+ onShortRelease: function() {
+ const currentlyFocusWidget = engine.getValue("[Library]", "focused_widget");
+ // 3 == Tracks table or root views of library features
+ // 4 == Context menu
+ if (currentlyFocusWidget !== 3 && currentlyFocusWidget !== 4) {
+ return;
+ }
+ script.toggleControl("[Library]", "show_track_menu");
+ this.libraryEncoder.libraryPlayButtonPressed = false;
+
+ if (currentlyFocusWidget === 4) {
+ engine.setValue("[Library]", "focused_widget", 3);
+ }
+ },
+ onShortPress: function() {
+ this.libraryEncoder.libraryPlayButtonPressed = true;
+ },
+ onLongRelease: function() {
+ this.libraryEncoder.libraryPlayButtonPressed = false;
+ },
+ onLongPress: function() {
+ engine.setValue("[Library]", "clear_search", 1);
+ }
+ });
+ this.libraryViewButton = new Button({
+ group: "[Master]",
+ key: "maximize_library",
+ libraryEncoder: this.libraryEncoder,
+ libraryEncoderPress: this.libraryEncoderPress,
+ onShortRelease: function() {
+ script.toggleControl(this.group, this.inKey, true);
+ },
+ onLongPress: function() {
+ this.libraryEncoder.libraryViewButtonPressed = true;
+ this.libraryEncoderPress.libraryViewButtonPressed = true;
+ },
+ onLongRelease: function() {
+ this.libraryEncoder.libraryViewButtonPressed = false;
+ this.libraryEncoderPress.libraryViewButtonPressed = false;
+ }
+ });
+
+ this.keyboardPlayMode = null;
+ this.keyboardOffset = 9;
+
+ this.pads = Array(8).fill(new Component());
+ const defaultPadLayer = [
+ new IntroOutroButton({
+ cueBaseName: "intro_start",
+ }),
+ new IntroOutroButton({
+ cueBaseName: "intro_end",
+ }),
+ new IntroOutroButton({
+ cueBaseName: "outro_start",
+ }),
+ new IntroOutroButton({
+ cueBaseName: "outro_end",
+ }),
+ new HotcueButton({
+ number: 1
+ }),
+ new HotcueButton({
+ number: 2
+ }),
+ new HotcueButton({
+ number: 3
+ }),
+ new HotcueButton({
+ number: 4
+ })
+ ];
+ const hotcuePage2 = Array(8).fill({});
+ const hotcuePage3 = Array(8).fill({});
+ const samplerOrBeatloopRollPage = Array(8).fill({});
+ this.keyboard = Array(8).fill({});
+ let i = 0;
+ /* eslint no-unused-vars: "off" */
+ for (const pad of hotcuePage2) {
+ // start with hotcue 5; hotcues 1-4 are in defaultPadLayer
+ hotcuePage2[i] = new HotcueButton({number: i + 1});
+ hotcuePage3[i] = new HotcueButton({number: i + 13});
+ if (UseBeatloopRollInsteadOfSampler) {
+ samplerOrBeatloopRollPage[i] = new BeatLoopRollButton({
+ number: i,
+ deck: this,
+ });
+
+ } else {
+ let samplerNumber = i + 1;
+ if (samplerNumber > 4) {
+ samplerNumber += 4;
+ }
+ if (decks[0] > 1) {
+ samplerNumber += 4;
+ }
+ samplerOrBeatloopRollPage[i] = new SamplerButton({
+ number: samplerNumber,
+ });
+ if (SamplerCrossfaderAssign) {
+ engine.setValue(
+ `[Sampler${samplerNumber}]`,
+ "orientation",
+ (decks[0] === 1) ? 0 : 2
+ );
+ }
+ }
+ this.keyboard[i] = new KeyboardButton({
+ number: i + 1,
+ deck: this,
+ });
+ i++;
+ }
+
+ const switchPadLayer = (deck, newLayer) => {
+ let index = 0;
+ for (let pad of deck.pads) {
+ pad.outDisconnect();
+ pad.inDisconnect();
+
+ pad = newLayer[index];
+ Object.assign(pad, io.pads[index]);
+ if (!(pad instanceof HotcueButton)) {
+ pad.color = deck.color;
+ }
+ // don't change the group of SamplerButtons
+ if (!(pad instanceof SamplerButton)) {
+ pad.group = deck.group;
+ }
+ if (pad.inReport === undefined) {
+ pad.inReport = inReports[1];
+ }
+ pad.outReport = outReport;
+ pad.inConnect();
+ pad.outConnect();
+ pad.outTrigger();
+ deck.pads[index] = pad;
+ index++;
+ }
+ };
+
+ this.padLayers = {
+ defaultLayer: 0,
+ hotcuePage2: 1,
+ hotcuePage3: 2,
+ samplerPage: 3,
+ keyboard: 5,
+ };
+ switchPadLayer(this, defaultPadLayer);
+ this.currentPadLayer = this.padLayers.defaultLayer;
+
+ this.hotcuePadModeButton = new Button({
+ deck: this,
+ onShortPress: function() {
+ if (!this.shifted) {
+ if (this.deck.currentPadLayer !== this.deck.padLayers.hotcuePage2) {
+ switchPadLayer(this.deck, hotcuePage2);
+ this.deck.currentPadLayer = this.deck.padLayers.hotcuePage2;
+ } else {
+ switchPadLayer(this.deck, defaultPadLayer);
+ this.deck.currentPadLayer = this.deck.padLayers.defaultLayer;
+ }
+ this.deck.lightPadMode();
+ } else {
+ switchPadLayer(this.deck, hotcuePage3);
+ this.deck.currentPadLayer = this.deck.padLayers.hotcuePage3;
+ this.deck.lightPadMode();
+ }
+
+ },
+ // hack to switch the LED color when changing decks
+ outTrigger: function() {
+ this.deck.lightPadMode();
+ }
+ });
+ // The record button doesn't have a mapping by default, but you can add yours here
+ // this.recordPadModeButton = new Button({
+ // ...
+ // });
+ this.samplesPadModeButton = new Button({
+ deck: this,
+ onShortPress: function() {
+ if (this.deck.currentPadLayer !== this.deck.padLayers.samplerPage) {
+ switchPadLayer(this.deck, samplerOrBeatloopRollPage);
+ engine.setValue("[Samplers]", "show_samplers", true);
+ this.deck.currentPadLayer = this.deck.padLayers.samplerPage;
+ } else {
+ switchPadLayer(this.deck, defaultPadLayer);
+ engine.setValue("[Samplers]", "show_samplers", false);
+ this.deck.currentPadLayer = this.deck.padLayers.defaultLayer;
+ }
+ this.deck.lightPadMode();
+ },
+ });
+ // The mute button doesn't have a mapping by default, but you can add yours here
+ // this.mutePadModeButton = new Button({
+ // ...
+ // });
+
+ this.stemsPadModeButton = new Button({
+ deck: this,
+ previousMoveMode: null,
+ onLongPress: function() {
+ if (this.deck.keyboardPlayMode !== null) {
+ this.deck.keyboardPlayMode = null;
+ this.deck.lightPadMode();
+ }
+ },
+ onShortPress: function() {
+ if (this.previousMoveMode === null) {
+ this.previousMoveMode = this.deck.moveMode;
+ this.deck.moveMode = moveModes.keyboard;
+ }
+ },
+ onShortRelease: function() {
+ if (this.previousMoveMode !== null && !this.deck.keyboardPlayMode) {
+ this.deck.moveMode = this.previousMoveMode;
+ this.previousMoveMode = null;
+ }
+ if (this.deck.currentPadLayer === this.deck.padLayers.keyboard) {
+ switchPadLayer(this.deck, defaultPadLayer);
+ this.deck.currentPadLayer = this.deck.padLayers.defaultLayer;
+ } else if (this.deck.currentPadLayer !== this.deck.padLayers.keyboard) {
+ switchPadLayer(this.deck, this.deck.keyboard);
+ this.deck.currentPadLayer = this.deck.padLayers.keyboard;
+ }
+ this.deck.lightPadMode();
+ },
+ onLongRelease: function() {
+ if (this.previousMoveMode !== null && !this.deck.keyboardPlayMode) {
+ this.deck.moveMode = this.previousMoveMode;
+ this.previousMoveMode = null;
+ }
+ },
+ // hack to switch the LED color when changing decks
+ outTrigger: function() {
+ this.deck.lightPadMode();
+ }
+ });
+
+ this.wheelMode = wheelModes.vinyl;
+ let motorWindDownTimer = 0;
+ const motorWindDownTimerCallback = () => {
+ engine.stopTimer(motorWindDownTimer);
+ motorWindDownTimer = 0;
+ };
+ this.turntableButton = UseMotors ? new Button({
+ deck: this,
+ input: function(press) {
+ if (press) {
+ this.deck.reverseButton.loopModeOff(true);
+ this.deck.fluxButton.loopModeOff(true);
+ if (this.deck.wheelMode === wheelModes.motor) {
+ this.deck.wheelMode = wheelModes.vinyl;
+ motorWindDownTimer = engine.beginTimer(MotorWindDownMilliseconds, motorWindDownTimerCallback, true);
+ engine.setValue(this.group, "scratch2_enable", false);
+ } else {
+ this.deck.wheelMode = wheelModes.motor;
+ const group = this.group;
+ engine.beginTimer(MotorWindUpMilliseconds, function() {
+ engine.setValue(group, "scratch2_enable", true);
+ }, true);
+ }
+ this.outTrigger();
+ }
+ },
+ outTrigger: function() {
+ const motorOn = this.deck.wheelMode === wheelModes.motor;
+ this.send(this.color + (motorOn ? this.brightnessOn : this.brightnessOff));
+ const vinylModeOn = this.deck.wheelMode === wheelModes.vinyl;
+ this.deck.jogButton.send(this.color + (vinylModeOn ? this.brightnessOn : this.brightnessOff));
+ },
+ }) : undefined;
+ this.jogButton = new Button({
+ deck: this,
+ input: function(press) {
+ if (press) {
+ this.deck.reverseButton.loopModeOff(true);
+ this.deck.fluxButton.loopModeOff(true);
+ if (this.deck.wheelMode === wheelModes.vinyl) {
+ this.deck.wheelMode = wheelModes.jog;
+ } else {
+ if (this.deck.wheelMode === wheelModes.motor) {
+ motorWindDownTimer = engine.beginTimer(MotorWindDownMilliseconds, motorWindDownTimerCallback, true);
+ }
+ this.deck.wheelMode = wheelModes.vinyl;
+ }
+ engine.setValue(this.group, "scratch2_enable", false);
+ this.outTrigger();
+ }
+ },
+ outTrigger: function() {
+ const vinylOn = this.deck.wheelMode === wheelModes.vinyl;
+ this.send(this.color + (vinylOn ? this.brightnessOn : this.brightnessOff));
+ if (this.deck.turntableButton) {
+ const motorOn = this.deck.wheelMode === wheelModes.motor;
+ this.deck.turntableButton.send(this.color + (motorOn ? this.brightnessOn : this.brightnessOff));
+ }
+ },
+ });
+
+ this.wheelTouch = new Button({
+ touched: false,
+ deck: this,
+ input: function(touched) {
+ this.touched = touched;
+ if (this.deck.wheelMode === wheelModes.vinyl || this.deck.wheelMode === wheelModes.motor) {
+ if (touched) {
+ engine.setValue(this.group, "scratch2_enable", true);
+ } else {
+ this.stopScratchWhenOver();
+ }
+ }
+ },
+ stopScratchWhenOver: function() {
+ if (this.touched || this.deck.wheelMode === wheelModes.motor) {
+ return;
+ }
+
+ if (engine.getValue(this.group, "play") &&
+ engine.getValue(this.group, "scratch2") < 1.5 * baseRevolutionsPerSecond &&
+ engine.getValue(this.group, "scratch2") > 0) {
+ engine.setValue(this.group, "scratch2_enable", false);
+ } else if (engine.getValue(this.group, "scratch2") === 0) {
+ engine.setValue(this.group, "scratch2_enable", false);
+ } else {
+ engine.beginTimer(100, this.stopScratchWhenOver.bind(this), true);
+ }
+ }
+ });
+
+ // The relative and absolute position inputs have the same resolution but direction
+ // cannot be determined reliably with the absolute position because it is easily
+ // possible to spin the wheel fast enough that it spins more than half a revolution
+ // between input reports. So there is no need to process the absolution position
+ // at all; the relative position is sufficient.
+ this.wheelRelative = new Component({
+ oldValue: null,
+ deck: this,
+ // We use a rolling average on a sample of speed received. An alternative could
+ // be to compute precise speed as soon as two points have been received. While the
+ // alternative is likely going to reduce the delay. it may introduce imprefection due
+ // to delays that could occurred at various level, so we stick with the naive average for now
+ stack: [],
+ stackIdx: 0,
+ avgSpeed: 0,
+ // There is a second sampling group, larger, that improve precision but increase delay, which
+ // is used in TT mode
+ stackAvg: [],
+ stackAvgIdx: 0,
+ ttAvgSpeed: 0,
+ input: function(value) {
+ const oldValue = this.oldValue;
+ this.oldValue = value;
+ if (oldValue === null) {
+ // This is to avoid the issue where the first time, we diff with 0, leading to the absolute value
+ return;
+ }
+
+ let diff = value - oldValue;
+
+ if (diff > wheelRelativeMax / 2) {
+ diff = (wheelRelativeMax - value + oldValue) * -1;
+ } else if (diff < -1 * (wheelRelativeMax / 2)) {
+ diff = wheelRelativeMax - oldValue + value;
+ }
+
+ this.stack[this.stackIdx] = diff / wheelTimerDelta;
+ this.stackIdx = (this.stackIdx + 1) % WheelSpeedSample;
+
+ this.avgSpeed = (this.stack.reduce((ps, v) => ps + v, 0) / this.stack.length) * wheelTicksPerTimerTicksToRevolutionsPerSecond;
+
+ this.stackAvg[this.stackAvgIdx] = this.avgSpeed;
+ this.stackAvgIdx = (this.stackAvgIdx + 1) % TurnTableSpeedSample;
+
+ this.ttAvgSpeed = this.stackAvg.reduce((ps, v) => ps + v, 0) / this.stackAvg.length;
+
+ if (this.avgSpeed === 0 &&
+ engine.getValue(this.group, "scratch2") === 0 &&
+ engine.getValue(this.group, "jog") === 0 &&
+ this.deck.wheelMode !== wheelModes.motor) {
+ return;
+ }
+
+ switch (this.deck.wheelMode) {
+ case wheelModes.motor:
+ engine.setValue(this.group, "scratch2", this.ttAvgSpeed / baseRevolutionsPerSecond);
+ break;
+ case wheelModes.loopIn:
+ {
+ const loopStartPosition = engine.getValue(this.group, "loop_start_position");
+ const loopEndPosition = engine.getValue(this.group, "loop_end_position");
+ const value = Math.min(loopStartPosition + (this.avgSpeed * LoopWheelMoveFactor), loopEndPosition - LoopWheelMoveFactor);
+ engine.setValue(
+ this.group,
+ "loop_start_position",
+ value
+ );
+ }
+ break;
+ case wheelModes.loopOut:
+ {
+ const loopEndPosition = engine.getValue(this.group, "loop_end_position");
+ const value = loopEndPosition + (this.avgSpeed * LoopWheelMoveFactor);
+ engine.setValue(
+ this.group,
+ "loop_end_position",
+ value
+ );
+ }
+ break;
+ case wheelModes.vinyl:
+ if (this.deck.wheelTouch.touched || engine.getValue(this.group, "scratch2") !== 0) {
+ engine.setValue(this.group, "scratch2", this.avgSpeed);
+ } else {
+ engine.setValue(this.group, "jog", this.avgSpeed);
+ }
+ break;
+ default:
+ engine.setValue(this.group, "jog", this.avgSpeed);
+ }
+ },
+ });
+
+ this.wheelLED = new Component({
+ deck: this,
+ outKey: "playposition",
+ output: function(fractionOfTrack) {
+ if (this.deck.wheelMode > wheelModes.motor) {
+ return;
+ }
+ const durationSeconds = engine.getValue(this.group, "duration");
+ const positionSeconds = fractionOfTrack * durationSeconds;
+ const revolutions = positionSeconds * baseRevolutionsPerSecond;
+ const fractionalRevolution = revolutions - Math.floor(revolutions);
+ const LEDposition = fractionalRevolution * wheelAbsoluteMax;
+
+ const wheelOutput = new Uint8Array(40).fill(0);
+ wheelOutput[0] = decks[0] - 1;
+
+ if (engine.getValue(this.group, "end_of_track") && WheelLedBlinkOnTrackEnd) {
+ wheelOutput[1] = wheelLEDmodes.ringFlash;
+ } else {
+ wheelOutput[1] = wheelLEDmodes.spot;
+ wheelOutput[2] = LEDposition & 0xff;
+ wheelOutput[3] = LEDposition >> 8;
+ }
+ wheelOutput[4] = this.color + Button.prototype.brightnessOn;
+
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+
+ }
+ });
+
+ for (const property in this) {
+ if (Object.prototype.hasOwnProperty.call(this, property)) {
+ const component = this[property];
+ if (component instanceof Component) {
+ Object.assign(component, io[property]);
+ if (component.inReport === undefined) {
+ component.inReport = inReports[1];
+ }
+ component.outReport = outReport;
+ if (component.group === undefined) {
+ component.group = this.group;
+ }
+ if (component.color === undefined) {
+ component.color = this.color;
+ }
+ if (component instanceof Encoder) {
+ component.max = 2 ** component.inBitLength - 1;
+ }
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ if (typeof this.unshift === "function" && this.unshift.length === 0) {
+ this.unshift();
+ }
+ }
+ }
+ }
+ }
+
+ assignKeyboardPlayMode(group, action) {
+ this.keyboardPlayMode = {
+ group: group,
+ action: action,
+ };
+ this.lightPadMode();
+ }
+
+ lightPadMode() {
+ if (this.currentPadLayer === this.padLayers.hotcuePage2) {
+ this.hotcuePadModeButton.send(this.hotcuePadModeButton.color + this.hotcuePadModeButton.brightnessOn);
+ } else if (this.currentPadLayer === this.padLayers.hotcuePage3) {
+ this.hotcuePadModeButton.send(LedColors.white + this.hotcuePadModeButton.brightnessOn);
+ } else {
+ this.hotcuePadModeButton.send(this.hotcuePadModeButton.color + this.hotcuePadModeButton.brightnessOff);
+ }
+
+ // unfortunately the other pad mode buttons only have one LED color
+ // const recordPadModeLEDOn = this.currentPadLayer === this.padLayers.hotcuePage3;
+ // this.recordPadModeButton.send(recordPadModeLEDOn ? 127 : 0);
+
+ const samplesPadModeLEDOn = this.currentPadLayer === this.padLayers.samplerPage;
+ this.samplesPadModeButton.send(samplesPadModeLEDOn ? 127 : 0);
+
+ // this.mutePadModeButtonLEDOn = this.currentPadLayer === this.padLayers.samplerPage2;
+ // const mutedModeButton.send(mutePadModeButtonLEDOn ? 127 : 0);
+ if (this.keyboardPlayMode !== null) {
+ this.stemsPadModeButton.send(LedColors.green + this.stemsPadModeButton.brightnessOn);
+ } else {
+ const keyboardPadModeLEDOn = this.currentPadLayer === this.padLayers.keyboard;
+ this.stemsPadModeButton.send(this.stemsPadModeButton.color + (keyboardPadModeLEDOn ? this.stemsPadModeButton.brightnessOn : this.stemsPadModeButton.brightnessOff));
+ }
+ }
+}
+
+class S4Mk3MixerColumn extends ComponentContainer {
+ constructor(idx, inReports, outReport, io) {
+ super();
+
+ this.idx = idx;
+ this.group = `[Channel${idx}]`;
+
+ this.gain = new Pot({
+ inKey: "pregain",
+ });
+ this.eqHigh = new Pot({
+ group: `[EqualizerRack1_${this.group}_Effect1]`,
+ inKey: "parameter3",
+ });
+ this.eqMid = new Pot({
+ group: `[EqualizerRack1_${this.group}_Effect1]`,
+ inKey: "parameter2",
+ });
+ this.eqLow = new Pot({
+ group: `[EqualizerRack1_${this.group}_Effect1]`,
+ inKey: "parameter1",
+ });
+ this.quickEffectKnob = new Pot({
+ group: `[QuickEffectRack1_${this.group}]`,
+ inKey: "super1",
+ });
+ this.volume = new Pot({
+ inKey: "volume",
+ mixer: this,
+ input: MixerControlsMixAuxOnShift ? function(value) {
+ if (this.mixer.shifted && this.group !== `[Channel${idx}]`) { // FIXME only if group != [ChannelX]
+ const controlKey = (this.group === `[Microphone${idx}]` || this.group === "[Microphone]") ? "talkover" : "master";
+ const isPlaying = engine.getValue(this.group, controlKey);
+ if ((value !== 0) !== isPlaying) {
+ engine.setValue(this.group, controlKey, value !== 0);
+ }
+ }
+ this.defaultInput(value);
+ } : undefined
+ });
+
+ this.pfl = new ToggleButton({
+ inKey: "pfl",
+ outKey: "pfl",
+ });
+
+ this.effectUnit1Assign = new PowerWindowButton({
+ group: "[EffectRack1_EffectUnit1]",
+ key: `group_${this.group}_enable`,
+ });
+
+ this.effectUnit2Assign = new PowerWindowButton({
+ group: "[EffectRack1_EffectUnit2]",
+ key: `group_${this.group}_enable`,
+ });
+
+ // FIXME: Why is output not working for these?
+ this.saveGain = new PushButton({
+ key: "update_replaygain_from_pregain"
+ });
+
+ this.crossfaderSwitch = new Component({
+ inBitLength: 2,
+ input: function(value) {
+ if (value === 0) {
+ engine.setValue(this.group, "orientation", 2);
+ } else if (value === 1) {
+ engine.setValue(this.group, "orientation", 1);
+ } else if (value === 2) {
+ engine.setValue(this.group, "orientation", 0);
+ }
+ },
+ });
+
+ for (const property in this) {
+ if (Object.prototype.hasOwnProperty.call(this, property)) {
+ const component = this[property];
+ if (component instanceof Component) {
+ Object.assign(component, io[property]);
+ if (component instanceof Pot) {
+ component.inReport = inReports[2];
+ } else {
+ component.inReport = inReports[1];
+ }
+ component.outReport = outReport;
+
+ if (component.group === undefined) {
+ component.group = this.group;
+ }
+
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+ }
+ }
+
+ if (MixerControlsMixAuxOnShift) {
+ this.shift = function() {
+ engine.setValue("[Microphone]", "show_microphone", true);
+ this.updateGroup(true);
+ };
+
+ this.unshift = function() {
+ engine.setValue("[Microphone]", "show_microphone", false);
+ this.updateGroup(false);
+ };
+ }
+ }
+
+ updateGroup(shifted) {
+ let alternativeInput = null;
+ if (engine.getValue(`[Auxiliary${this.idx}]`, "input_configured")) {
+ alternativeInput = `[Auxiliary${this.idx}]`;
+ } else if (engine.getValue(this.idx !== 1 ? `[Microphone${this.idx}]` : "[Microphone]", "input_configured")) {
+ alternativeInput = this.idx !== 1 ? `[Microphone${this.idx}]` : "[Microphone]";
+ }
+
+ if (!alternativeInput) {
+ return;
+ }
+ this.group = shifted ? alternativeInput : `[Channel${this.idx}]`;
+ for (const property of ["gain", "volume", "pfl", "crossfaderSwitch"]) {
+ const component = this[property];
+ if (component instanceof Component) {
+ component.outDisconnect();
+ component.inDisconnect();
+ component.group = this.group;
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+ }
+ for (const property of ["effectUnit1Assign", "effectUnit2Assign"]) {
+ const component = this[property];
+ if (component instanceof Component) {
+ component.outDisconnect();
+ component.inDisconnect();
+ component.inKey = `group_${this.group}_enable`;
+ component.outKey = `group_${this.group}_enable`;
+ component.inConnect();
+ component.outConnect();
+ component.outTrigger();
+ }
+ }
+ }
+}
+
+class S4MK3 {
+ constructor() {
+ if (engine.getValue("[Master]", "num_samplers") < 16) {
+ engine.setValue("[Master]", "num_samplers", 16);
+ }
+
+ this.inReports = [];
+ this.inReports[1] = new HIDInputReport(1);
+ // The master volume, booth volume, headphone mix, and headphone volume knobs
+ // control the controller's audio interface in hardware, so they are not mapped.
+ this.inReports[2] = new HIDInputReport(2);
+ this.inReports[3] = new HIDInputReport(3);
+
+ // There are various of other HID report which doesn't seem to have any
+ // immediate use but it is likely that some useful settings may be found
+ // in them such as the wheel tension.
+
+ this.outReports = [];
+ this.outReports[128] = new HIDOutputReport(128, 94);
+
+ this.effectUnit1 = new S4Mk3EffectUnit(1, this.inReports, this.outReports[128],
+ {
+ mixKnob: {inByte: 30},
+ mainButton: {inByte: 1, inBit: 6, outByte: 62},
+ knobs: [
+ {inByte: 32},
+ {inByte: 34},
+ {inByte: 36},
+ ],
+ buttons: [
+ {inByte: 1, inBit: 7, outByte: 63},
+ {inByte: 1, inBit: 3, outByte: 64},
+ {inByte: 1, inBit: 2, outByte: 65},
+ ],
+ }
+ );
+ this.effectUnit2 = new S4Mk3EffectUnit(2, this.inReports, this.outReports[128],
+ {
+ mixKnob: {inByte: 70},
+ mainButton: {inByte: 9, inBit: 4, outByte: 73},
+ knobs: [
+ {inByte: 72},
+ {inByte: 74},
+ {inByte: 76},
+ ],
+ buttons: [
+ {inByte: 9, inBit: 5, outByte: 74},
+ {inByte: 9, inBit: 6, outByte: 75},
+ {inByte: 9, inBit: 7, outByte: 76},
+ ],
+ }
+ );
+
+ // The interaction between the FX SELECT buttons and the QuickEffect enable buttons is rather complex.
+ // It is easier to have this separate from the S4Mk3MixerColumn dhe FX SELECT buttons are not
+ // really in the mixer columns.
+ this.mixer = new Mixer(this.inReports, this.outReports);
+
+ // There is no consistent offset between the left and right deck,
+ // so every single components' IO needs to be specified individually
+ // for both decks.
+ this.leftDeck = new S4Mk3Deck(
+ [1, 3], [DeckColors[0], DeckColors[2]], this.effectUnit1, this.mixer,
+ this.inReports, this.outReports[128],
+ {
+ playButton: {inByte: 4, inBit: 0, outByte: 55},
+ cueButton: {inByte: 4, inBit: 1, outByte: 8},
+ syncButton: {inByte: 5, inBit: 7, outByte: 14},
+ syncMasterButton: {inByte: 0, inBit: 0, outByte: 15},
+ hotcuePadModeButton: {inByte: 4, inBit: 2, outByte: 9},
+ recordPadModeButton: {inByte: 4, inBit: 3, outByte: 56},
+ samplesPadModeButton: {inByte: 4, inBit: 4, outByte: 57},
+ mutePadModeButton: {inByte: 4, inBit: 5, outByte: 58},
+ stemsPadModeButton: {inByte: 5, inBit: 0, outByte: 10},
+ deckButtonLeft: {inByte: 5, inBit: 2},
+ deckButtonRight: {inByte: 5, inBit: 3},
+ deckButtonOutputByteOffset: 12,
+ tempoFaderLED: {outByte: 11},
+ shiftButton: {inByte: 5, inBit: 1, outByte: 59},
+ leftEncoder: {inByte: 19, inBit: 0},
+ leftEncoderPress: {inByte: 6, inBit: 2},
+ rightEncoder: {inByte: 19, inBit: 4},
+ rightEncoderPress: {inByte: 6, inBit: 5},
+ libraryEncoder: {inByte: 20, inBit: 0},
+ libraryEncoderPress: {inByte: 0, inBit: 1},
+ turntableButton: {inByte: 5, inBit: 5, outByte: 17},
+ jogButton: {inByte: 5, inBit: 4, outByte: 16},
+ gridButton: {inByte: 5, inBit: 6, outByte: 18},
+ reverseButton: {inByte: 1, inBit: 4, outByte: 60},
+ fluxButton: {inByte: 1, inBit: 5, outByte: 61},
+ libraryPlayButton: {inByte: 0, inBit: 5, outByte: 22},
+ libraryStarButton: {inByte: 0, inBit: 4, outByte: 21},
+ libraryPlaylistButton: {inByte: 1, inBit: 1, outByte: 20},
+ libraryViewButton: {inByte: 1, inBit: 0, outByte: 19},
+ pads: [
+ {inByte: 3, inBit: 5, outByte: 0},
+ {inByte: 3, inBit: 4, outByte: 1},
+ {inByte: 3, inBit: 7, outByte: 2},
+ {inByte: 3, inBit: 6, outByte: 3},
+
+ {inByte: 3, inBit: 3, outByte: 4},
+ {inByte: 3, inBit: 2, outByte: 5},
+ {inByte: 3, inBit: 1, outByte: 6},
+ {inByte: 3, inBit: 0, outByte: 7},
+ ],
+ tempoFader: {inByte: 12, inBit: 0, inBitLength: 16, inReport: this.inReports[2]},
+ wheelRelative: {inByte: 11, inBit: 0, inBitLength: 16, inReport: this.inReports[3]},
+ wheelAbsolute: {inByte: 15, inBit: 0, inBitLength: 16, inReport: this.inReports[3]},
+ wheelTouch: {inByte: 16, inBit: 4},
+ }
+ );
+
+ this.rightDeck = new S4Mk3Deck(
+ [2, 4], [DeckColors[1], DeckColors[3]], this.effectUnit2, this.mixer,
+ this.inReports, this.outReports[128],
+ {
+ playButton: {inByte: 12, inBit: 0, outByte: 66},
+ cueButton: {inByte: 14, inBit: 5, outByte: 31},
+ syncButton: {inByte: 14, inBit: 4, outByte: 37},
+ syncMasterButton: {inByte: 10, inBit: 0, outByte: 38},
+ hotcuePadModeButton: {inByte: 12, inBit: 2, outByte: 32},
+ recordPadModeButton: {inByte: 12, inBit: 3, outByte: 67},
+ samplesPadModeButton: {inByte: 12, inBit: 4, outByte: 68},
+ mutePadModeButton: {inByte: 12, inBit: 5, outByte: 69},
+ stemsPadModeButton: {inByte: 12, inBit: 1, outByte: 33},
+ deckButtonLeft: {inByte: 14, inBit: 2},
+ deckButtonRight: {inByte: 14, inBit: 3},
+ deckButtonOutputByteOffset: 35,
+ tempoFaderLED: {outByte: 34},
+ shiftButton: {inByte: 14, inBit: 1, outByte: 70},
+ leftEncoder: {inByte: 20, inBit: 4},
+ leftEncoderPress: {inByte: 15, inBit: 5},
+ rightEncoder: {inByte: 21, inBit: 0},
+ rightEncoderPress: {inByte: 15, inBit: 2},
+ libraryEncoder: {inByte: 21, inBit: 4},
+ libraryEncoderPress: {inByte: 10, inBit: 1},
+ turntableButton: {inByte: 14, inBit: 6, outByte: 40},
+ jogButton: {inByte: 14, inBit: 0, outByte: 39},
+ gridButton: {inByte: 14, inBit: 7, outByte: 41},
+ reverseButton: {inByte: 10, inBit: 4, outByte: 71},
+ fluxButton: {inByte: 10, inBit: 5, outByte: 72},
+ libraryPlayButton: {inByte: 9, inBit: 2, outByte: 45},
+ libraryStarButton: {inByte: 9, inBit: 1, outByte: 44},
+ libraryPlaylistButton: {inByte: 9, inBit: 3, outByte: 43},
+ libraryViewButton: {inByte: 9, inBit: 0, outByte: 42},
+ pads: [
+ {inByte: 13, inBit: 5, outByte: 23},
+ {inByte: 13, inBit: 4, outByte: 24},
+ {inByte: 13, inBit: 7, outByte: 25},
+ {inByte: 13, inBit: 6, outByte: 26},
+
+ {inByte: 13, inBit: 3, outByte: 27},
+ {inByte: 13, inBit: 2, outByte: 28},
+ {inByte: 13, inBit: 1, outByte: 29},
+ {inByte: 13, inBit: 0, outByte: 30},
+ ],
+ tempoFader: {inByte: 10, inBit: 0, inBitLength: 16, inReport: this.inReports[2]},
+ wheelRelative: {inByte: 39, inBit: 0, inBitLength: 16, inReport: this.inReports[3]},
+ wheelAbsolute: {inByte: 43, inBit: 0, inBitLength: 16, inReport: this.inReports[3]},
+ wheelTouch: {inByte: 16, inBit: 5},
+ }
+ );
+
+ const that = this;
+ /* eslint no-unused-vars: "off" */
+ const meterConnection = engine.makeConnection("[Master]", "guiTick50ms", function(_value) {
+ const deckMeters = new Uint8Array(78).fill(0);
+ // Each column has 14 segments, but treat the top one specially for the clip indicator.
+ const deckSegments = 13;
+ for (let deckNum = 1; deckNum <= 4; deckNum++) {
+ let deckGroup = `[Channel${deckNum}]`;
+ if (that.leftDeck.shifted || that.rightDeck.shifted) {
+ if (engine.getValue(`[Auxiliary${deckNum}]`, "input_configured")) {
+ deckGroup = `[Auxiliary${deckNum}]`;
+ } else if (engine.getValue(deckNum !== 1 ? `[Microphone${deckNum}]` : "[Microphone]", "input_configured")) {
+ deckGroup = deckNum !== 1 ? `[Microphone${deckNum}]` : "[Microphone]";
+ }
+ }
+ const deckLevel = engine.getValue(deckGroup, "VuMeter");
+ const columnBaseIndex = (deckNum - 1) * (deckSegments + 2);
+ const scaledLevel = deckLevel * deckSegments;
+ const segmentsToLightFully = Math.floor(scaledLevel);
+ const partialSegmentValue = scaledLevel - segmentsToLightFully;
+ if (segmentsToLightFully > 0) {
+ // There are 3 brightness levels per segment: off, dim, and full.
+ for (let i = 0; i <= segmentsToLightFully; i++) {
+ deckMeters[columnBaseIndex + i] = 127;
+ }
+ if (partialSegmentValue > 0.5 && segmentsToLightFully < deckSegments) {
+ deckMeters[columnBaseIndex + segmentsToLightFully + 1] = 125;
+ }
+ }
+ if (engine.getValue(deckGroup, "PeakIndicator")) {
+ deckMeters[columnBaseIndex + deckSegments + 1] = 127;
+ }
+ }
+ // There are more bytes in the report which seem like they should be for the main
+ // mix meters, but setting those bytes does not do anything, except for lighting
+ // the clip lights on the main mix meters.
+ controller.sendOutputReport(129, deckMeters.buffer);
+ });
+ if (UseMotors) {
+ engine.beginTimer(20, this.motorCallback.bind(this));
+ this.leftVelocityFactor = wheelAbsoluteMax * baseRevolutionsPerSecond * 2;
+ this.rightVelocityFactor = wheelAbsoluteMax * baseRevolutionsPerSecond * 2;
+
+ this.leftFactor = [this.leftVelocityFactor];
+ this.leftFactorIdx = 1;
+ this.rightFactor = [this.rightVelocityFactor];
+ this.rightFactorIdx = 1;
+
+ this.averageLeftCorrectness = [];
+ this.averageLeftCorrectnessIdx = 0;
+ this.averageRightCorrectness = [];
+ this.averageRightCorrectnessIdx = 0;
+ }
+
+ }
+ motorCallback() {
+ const motorData = new Uint8Array([
+ 1, 0x20, 1, 0, 0,
+ 1, 0x20, 1, 0, 0,
+
+ ]);
+ const maxVelocity = 10;
+
+ let velocityLeft = 0;
+ let velocityRight = 0;
+
+ let expectedLeftSpeed = 0;
+ let expectedRightSpeed = 0;
+
+ if (this.leftDeck.wheelMode === wheelModes.motor
+ && engine.getValue(this.leftDeck.group, "play")) {
+ expectedLeftSpeed = engine.getValue(this.leftDeck.group, "rate_ratio");
+ }
+
+ if (this.rightDeck.wheelMode === wheelModes.motor
+ && engine.getValue(this.rightDeck.group, "play")) {
+ expectedRightSpeed = engine.getValue(this.rightDeck.group, "rate_ratio");
+ }
+
+ const currentLeftSpeed = this.leftDeck.wheelRelative.avgSpeed / baseRevolutionsPerSecond;
+ const currentRightSpeed = this.rightDeck.wheelRelative.avgSpeed / baseRevolutionsPerSecond;
+
+ if (expectedLeftSpeed) {
+ velocityLeft = expectedLeftSpeed + Math.min(
+ maxVelocity,
+ Math.max(
+ -maxVelocity,
+ (expectedLeftSpeed - currentLeftSpeed)
+ )
+ );
+ } else {
+ if (TightnessFactor > 0.5) {
+ // Super loose
+ const reduceFactor = (Math.min(0.5, TightnessFactor - 0.5) / 0.5) * 0.7;
+ velocityLeft = currentLeftSpeed * reduceFactor;
+ } else if (TightnessFactor < 0.5) {
+ // Super tight
+ const reduceFactor = (2 - Math.max(0, TightnessFactor) * 4);
+ velocityLeft = expectedLeftSpeed + Math.min(
+ maxVelocity,
+ Math.max(
+ -maxVelocity,
+ (expectedLeftSpeed - currentLeftSpeed) * reduceFactor
+ )
+ );
+
+ }
+ }
+
+ if (expectedRightSpeed) {
+ velocityRight = expectedRightSpeed + Math.min(
+ maxVelocity,
+ Math.max(
+ -maxVelocity,
+ (expectedRightSpeed - currentRightSpeed)
+ )
+ );
+ } else {
+ if (TightnessFactor > 0.5) {
+ // Super loose
+ const reduceFactor = (Math.min(0.5, TightnessFactor - 0.5) / 0.5) * 0.7;
+ velocityRight = currentRightSpeed * reduceFactor;
+ } else if (TightnessFactor < 0.5) {
+ // Super tight
+ const reduceFactor = (2 - Math.max(0, TightnessFactor) * 4);
+ console.log(reduceFactor);
+ velocityRight = expectedRightSpeed + Math.min(
+ maxVelocity,
+ Math.max(
+ -maxVelocity,
+ (expectedRightSpeed - currentRightSpeed) * reduceFactor
+ )
+ );
+
+ }
+ }
+
+ if (velocityLeft < 0) {
+ motorData[1] = 0xe0;
+ motorData[2] = 0xfe;
+ velocityLeft = -velocityLeft;
+ }
+
+ if (velocityRight < 0) {
+ motorData[6] = 0xe0;
+ motorData[7] = 0xfe;
+ velocityRight = -velocityRight;
+ }
+
+ const roundedCurrentLeftSpeed = Math.round(currentLeftSpeed * 100);
+ const roundedCurrentRightSpeed = Math.round(currentRightSpeed * 100);
+
+ velocityLeft = velocityLeft * this.leftVelocityFactor;
+ velocityRight = velocityRight * this.rightVelocityFactor;
+
+ const minNormalFactor = 0.8 * wheelAbsoluteMax * baseRevolutionsPerSecond * 2;
+ const maxNormalFactor = 1.2 * wheelAbsoluteMax * baseRevolutionsPerSecond * 2;
+
+ if (velocityLeft > minNormalFactor && velocityLeft < maxNormalFactor) {
+ this.averageLeftCorrectness[this.averageLeftCorrectnessIdx] = roundedCurrentLeftSpeed;
+ this.averageLeftCorrectnessIdx = (this.averageLeftCorrectnessIdx + 1) % 10;
+ const averageCorrectness = Math.round(this.averageLeftCorrectness.reduce((a, b) => a+b, 0) / this.averageLeftCorrectness.length);
+ this.leftFactor[this.leftFactorIdx] = velocityLeft;
+ this.leftFactorIdx = (this.leftFactorIdx + 1) % 10;
+ const averageFactor = Math.round(this.leftFactor.reduce((a, b) => a+b, 0) / this.leftFactor.length);
+
+
+ if ((averageCorrectness < 100 && velocityLeft > this.leftVelocityFactor) || (averageCorrectness > 100 && velocityLeft < this.leftVelocityFactor)) {
+ this.leftVelocityFactor = averageFactor;
+ }
+ }
+
+ if (velocityRight > minNormalFactor && velocityRight < maxNormalFactor) {
+ this.averageRightCorrectness[this.averageRightCorrectnessIdx] = roundedCurrentRightSpeed / (expectedRightSpeed || 0.001);
+ this.averageRightCorrectnessIdx = (this.averageRightCorrectnessIdx + 1) % 20;
+ const averageCorrectness = Math.round(this.averageRightCorrectness.reduce((a, b) => a+b, 0) / this.averageRightCorrectness.length);
+ this.rightFactor[this.rightFactorIdx] = velocityRight;
+ this.rightFactorIdx = (this.rightFactorIdx + 1) % 20;
+ const averageFactor = Math.round(this.rightFactor.reduce((a, b) => a+b, 0) / this.rightFactor.length);
+
+
+ if ((averageCorrectness < 100 && velocityRight > this.rightVelocityFactor) || (averageCorrectness > 100 && velocityRight < this.rightVelocityFactor)) {
+ this.rightVelocityFactor = averageFactor;
+ }
+ }
+
+ if (velocityLeft) {
+ velocityLeft += wheelAbsoluteMax / 2;
+ }
+
+ if (velocityRight) {
+ velocityRight += wheelAbsoluteMax / 2;
+ }
+
+ velocityLeft = Math.min(
+ MaxWheelForce,
+ Math.floor(velocityLeft)
+ );
+
+ velocityRight = Math.min(
+ MaxWheelForce,
+ Math.floor(velocityRight)
+ );
+
+ motorData[3] = velocityLeft & 0xff;
+ motorData[4] = velocityLeft >> 8;
+
+ motorData[8] = velocityRight & 0xff;
+ motorData[9] = velocityRight >> 8;
+ controller.sendOutputReport(49, motorData.buffer, true);
+ }
+ incomingData(data) {
+ const reportId = data[0];
+ if (reportId in this.inReports && reportId !== 3) {
+ this.inReports[reportId].handleInput(data.buffer.slice(1));
+ } else if (reportId === 3) {
+ // The 32 bit unsigned ints at bytes 8 and 36 always have exactly the same value,
+ // so only process one of them. This must be processed before the wheel positions.
+ const oldWheelTimer = wheelTimer;
+ const view = new DataView(data.buffer);
+ wheelTimer = view.getUint32(8, true);
+ // Processing first value; no previous value to compare with.
+ if (oldWheelTimer === null) {
+ return;
+ }
+ wheelTimerDelta = wheelTimer - oldWheelTimer;
+ if (wheelTimerDelta < 0) {
+ wheelTimerDelta += wheelTimerMax;
+ }
+
+ this.leftDeck.wheelRelative.input(view.getUint16(12, true));
+ this.rightDeck.wheelRelative.input(view.getUint16(40, true));
+ } else {
+ console.warn(`Unsupported HID repord with ID ${reportId}. Contains: ${data}`);
+ }
+ }
+ init() {
+ // sending these magic reports is required for the jog wheel LEDs to work
+ const wheelLEDinitReport = new Uint8Array(26).fill(0);
+ wheelLEDinitReport[1] = 1;
+ wheelLEDinitReport[2] = 3;
+ controller.sendOutputReport(48, wheelLEDinitReport.buffer, true);
+ wheelLEDinitReport[0] = 1;
+ controller.sendOutputReport(48, wheelLEDinitReport.buffer);
+
+ // Init wheel timer data
+ wheelTimer = null;
+ wheelTimerDelta = 0;
+
+ // get state of knobs and faders
+ for (const repordId of [0x01, 0x02]) {
+ this.inReports[repordId].handleInput(controller.getInputReport(repordId));
+ }
+ }
+ shutdown() {
+ // button LEDs
+ controller.sendOutputReport(128, new Uint8Array(94).fill(0).buffer);
+
+ // meter LEDs
+ controller.sendOutputReport(129, new Uint8Array(78).fill(0).buffer);
+
+ const wheelOutput = new Uint8Array(40).fill(0);
+ // left wheel LEDs
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+ // right wheel LEDs
+ wheelOutput[0] = 1;
+ controller.sendOutputReport(50, wheelOutput.buffer, true);
+ }
+}
+
+/* eslint no-unused-vars: "off", no-var: "off" */
+var TraktorS4MK3 = new S4MK3();
diff --git a/res/controllers/common-controller-scripts.js b/res/controllers/common-controller-scripts.js
index f0d19253ee7b..68eed5895412 100644
--- a/res/controllers/common-controller-scripts.js
+++ b/res/controllers/common-controller-scripts.js
@@ -21,7 +21,7 @@
// Returns an ASCII byte array for the string
String.prototype.toInt = function() {
- const a = new Array();
+ const a = [];
for (let i = 0; i < this.length; i++) {
a[i] = this.charCodeAt(i);
}
@@ -124,9 +124,40 @@ var colorCodeToObject = function(colorCode) {
};
};
+/* eslint @typescript-eslint/no-empty-function: "off" */
var script = function() {
};
+// ----------------- Mapping constants ---------------------
+
+// Library column value, which can be used to interact with the CO for "[Library] sort_column"
+script.LIBRARY_COLUMNS = Object.freeze({
+ ARTIST: 1,
+ TITLE: 2,
+ ALBUM: 3,
+ ALBUM_ARTIST: 4,
+ YEAR: 5,
+ GENRE: 6,
+ COMPOSER: 7,
+ GROUPING: 8,
+ TRACK_NUMBER: 9,
+ FILETYPE: 10,
+ NATIVE_LOCATION: 11,
+ COMMENT: 12,
+ DURATION: 13,
+ BITRATE: 14,
+ BPM: 15,
+ REPLAY_GAIN: 16,
+ DATETIME_ADDED: 17,
+ TIMES_PLAYED: 18,
+ RATING: 19,
+ KEY: 20,
+ PREVIEW: 21,
+ COVERART: 22,
+ TRACK_COLOR: 30,
+ LAST_PLAYED: 31,
+});
+
// DEPRECATED -- use script.midiDebug() instead
script.debug = function(channel, control, value, status, group) {
print("Warning: script.debug() is deprecated. Use script.midiDebug() instead.");
@@ -464,7 +495,6 @@ script.softStart = function(channel, control, value, status, group, factor) {
};
// bpm - Used for tapping the desired BPM for a deck
-
var bpm = function() {
};
@@ -554,6 +584,7 @@ var Controller = function() {
Controller.prototype.addButton = function(buttonName, button, eventHandler) {
if (eventHandler) {
+ /* eslint @typescript-eslint/no-this-alias: "off" */
const executionEnvironment = this;
const handler = function(value) {
button.state = value;
diff --git a/src/test/controller_mapping_validation_test.cpp b/src/test/controller_mapping_validation_test.cpp
index 2a91e4715fe5..f0586c98ddb6 100644
--- a/src/test/controller_mapping_validation_test.cpp
+++ b/src/test/controller_mapping_validation_test.cpp
@@ -13,6 +13,14 @@ void FakeControllerJSProxy::send(const QList& data, unsigned int length) {
Q_UNUSED(length);
}
+void FakeControllerJSProxy::sendOutputReport(quint8 reportID,
+ const QByteArray& dataArray,
+ bool resendUnchangedReport) {
+ Q_UNUSED(reportID);
+ Q_UNUSED(dataArray);
+ Q_UNUSED(resendUnchangedReport);
+}
+
void FakeControllerJSProxy::sendSysexMsg(const QList& data, unsigned int length) {
Q_UNUSED(data);
Q_UNUSED(length);
diff --git a/src/test/controller_mapping_validation_test.h b/src/test/controller_mapping_validation_test.h
index a9a539fb3236..1e6c9a6eacff 100644
--- a/src/test/controller_mapping_validation_test.h
+++ b/src/test/controller_mapping_validation_test.h
@@ -18,6 +18,10 @@ class FakeControllerJSProxy : public ControllerJSProxy {
Q_INVOKABLE void sendShortMsg(unsigned char status,
unsigned char byte1,
unsigned char byte2);
+
+ Q_INVOKABLE void sendOutputReport(quint8 reportID,
+ const QByteArray& dataArray,
+ bool resendUnchangedReport = false);
};
class FakeController : public Controller {