From 2924a07915bc3ef0058c1f11ab7506e9d4db4b51 Mon Sep 17 00:00:00 2001 From: "Antoine C." Date: Mon, 18 Aug 2025 08:13:47 +0000 Subject: [PATCH 1/6] S4MK3: refactor device constant --- res/controllers/Traktor-Kontrol-S4-MK3.js | 70 +++++++++++++---------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js index e3623c870bc3..f2b7ef612010 100644 --- a/res/controllers/Traktor-Kontrol-S4-MK3.js +++ b/res/controllers/Traktor-Kontrol-S4-MK3.js @@ -21,6 +21,30 @@ const LedColors = { white: 68, }; +const LedColorMap = { + 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, + + 0xAD65FF: LedColors.fuscia, + 0xCC0079: LedColors.magenta, + 0xCC477E: LedColors.azalea, + 0xCC4761: LedColors.salmon, + + 0xCCCCCC: LedColors.white, +}; + // 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 = [ @@ -163,6 +187,8 @@ const SoftwareMixerHeadphone = !!engine.getSetting("softwareMixerHeadphone"); // Define custom default layout used by the pads, instead of intro/outro and first 4 hotcues. const DefaultPadLayout = engine.getSetting("defaultPadLayout"); +// Whether or not to use the ShareDataAPI, available in the PR 12199. +const UseSharedDataAPI = engine.getSetting("useSharedDataAPI"); // The LEDs only support 16 base colors. Adding 1 in addition to // the normal 2 for Button.prototype.brightnessOn changes the color @@ -207,6 +233,17 @@ const SamplerCrossfaderAssign = true; const MotorWindUpMilliseconds = 1200; const MotorWindDownMilliseconds = 900; +/* + * Kontrol S4 Mk3 hardware-specific constants + */ +const wheelRelativeMax = 2 ** 32 - 1; +const wheelAbsoluteMax = 2879; + +const wheelTimerMax = 2 ** 32 - 1; + +const baseRevolutionsPerSecond = BaseRevolutionsPerMinute / 60; + + /* * HID report parsing library */ @@ -1355,6 +1392,7 @@ Pot.prototype.inBit = 0; Pot.prototype.inBitLength = 16; Encoder.prototype.inBitLength = 4; +Encoder.prototype.tickDelta = 1 / (2 << Encoder.prototype.inBitLength); // valid range 0 - 3, but 3 makes some colors appear whitish Button.prototype.brightnessOff = 0; @@ -1366,38 +1404,8 @@ Button.prototype.uncoloredOutput = function(value) { 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, +Button.prototype.colorMap = new ColorMapper(LedColorMap); - 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 ** 32 - 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, From 10902bf94d1d089768108460d360a18717692088 Mon Sep 17 00:00:00 2001 From: "Antoine C." Date: Mon, 18 Aug 2025 08:17:56 +0000 Subject: [PATCH 2/6] S4MK3: improve stability with CO are missings --- res/controllers/Traktor-Kontrol-S4-MK3.js | 40 ++++++++++++----------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js index f2b7ef612010..7403a22233b3 100644 --- a/res/controllers/Traktor-Kontrol-S4-MK3.js +++ b/res/controllers/Traktor-Kontrol-S4-MK3.js @@ -383,13 +383,13 @@ class Component { this.send(value); } outConnect() { - if (this.outKey !== undefined && this.group !== undefined) { + if (this.outKey !== undefined && this.group !== undefined && this.outConnections.length === 0) { 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; + this.outConnections.push(connection); } else { - console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`); + console.warn(`Unable to connect '${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`); } } } @@ -401,6 +401,7 @@ class Component { } outTrigger() { for (const connection of this.outConnections) { + if (!connection) { continue; } connection.trigger(); } } @@ -428,6 +429,9 @@ class ComponentContainer extends Component { } reconnectComponents(callback) { for (const component of this) { + if (typeof component.unshift === "function" && component.unshift.length === 0) { + component.unshift(); + } if (typeof component.outDisconnect === "function" && component.outDisconnect.length === 0) { component.outDisconnect(); } @@ -666,8 +670,6 @@ class TriggerButton extends Button { class PowerWindowButton extends Button { constructor(options) { super(options); - this.isLongPressed = false; - this.longPressTimer = 0; } onShortPress() { script.toggleControl(this.group, this.inKey); @@ -784,10 +786,10 @@ class HotcueButton extends PushButton { } } outConnect() { - if (undefined !== this.group) { + if (undefined !== this.group && this.outConnections.length === 0) { const connection0 = engine.makeConnection(this.group, this.outKey, this.output.bind(this)); if (connection0) { - this.outConnections[0] = connection0; + this.outConnections.push(connection0); } else { console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`); } @@ -796,7 +798,7 @@ class HotcueButton extends PushButton { this.output(engine.getValue(this.group, this.outKey)); }); if (connection1) { - this.outConnections[1] = connection1; + this.outConnections.push(connection1); } else { console.warn(`Unable to connect ${this.group}.${this.colorKey}' to the controller output. The control appears to be unavailable.`); } @@ -856,13 +858,13 @@ class KeyboardButton extends PushButton { } } outConnect() { - if (undefined !== this.group) { + if (undefined !== this.group && this.outConnections.length === 0) { 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; + this.outConnections.push(connection); } else { console.warn(`Unable to connect ${this.group}.key' to the controller output. The control appears to be unavailable.`); } @@ -952,16 +954,16 @@ class SamplerButton extends Button { } } outConnect() { - if (undefined !== this.group) { + if (undefined !== this.group && this.outConnections.length === 0) { const connection0 = engine.makeConnection(this.group, "play", this.output.bind(this)); if (connection0) { - this.outConnections[0] = connection0; + this.outConnections.push(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; + this.outConnections.push(connection1); } else { console.warn(`Unable to connect ${this.group}.track_loaded' to the controller output. The control appears to be unavailable.`); } @@ -1366,16 +1368,16 @@ class QuickEffectButton extends Button { this.outConnections[1].trigger(); } outConnect() { - if (this.group !== undefined) { + if (this.group !== undefined && this.outConnections.length === 0) { const connection0 = engine.makeConnection(this.group, "loaded_chain_preset", this.presetLoaded.bind(this)); if (connection0) { - this.outConnections[0] = connection0; + this.outConnections.push(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; + this.outConnections.push(connection1); } else { console.warn(`Unable to connect ${this.group}.enabled' to the controller output. The control appears to be unavailable.`); } @@ -1782,10 +1784,10 @@ class S4Mk3Deck extends Deck { this.setKey("loop_enabled"); }, outConnect: function() { - if (this.outKey !== undefined && this.group !== undefined) { + if (this.outKey !== undefined && this.group !== undefined && this.outConnections.length === 0) { const connection = engine.makeConnection(this.group, this.outKey, this.output.bind(this)); if (connection) { - this.outConnections[0] = connection; + this.outConnections.push(connection); } else { console.warn(`Unable to connect ${this.group}.${this.outKey}' to the controller output. The control appears to be unavailable.`); } @@ -2132,7 +2134,7 @@ class S4Mk3Deck extends Deck { }); // 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; + this.outConnections.push(connection); } else { console.warn(`Unable to connect ${this.group}.focused_widget' to the controller output. The control appears to be unavailable.`); } From 45b8e4e5eb4419af6d9152fa7ddeec8c9d6880ab Mon Sep 17 00:00:00 2001 From: "Antoine C." Date: Mon, 18 Aug 2025 08:28:15 +0000 Subject: [PATCH 3/6] S4MK3: use button hooks and add shared data API --- .../Traktor Kontrol S4 MK3.hid.xml | 2 +- res/controllers/Traktor-Kontrol-S4-MK3.js | 482 +++++++++++++----- 2 files changed, 363 insertions(+), 121 deletions(-) diff --git a/res/controllers/Traktor Kontrol S4 MK3.hid.xml b/res/controllers/Traktor Kontrol S4 MK3.hid.xml index 69d65c1037b3..353028ee2e0a 100644 --- a/res/controllers/Traktor Kontrol S4 MK3.hid.xml +++ b/res/controllers/Traktor Kontrol S4 MK3.hid.xml @@ -691,7 +691,7 @@ - + diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js index 7403a22233b3..c43fb3cf2b5f 100644 --- a/res/controllers/Traktor-Kontrol-S4-MK3.js +++ b/res/controllers/Traktor-Kontrol-S4-MK3.js @@ -187,9 +187,6 @@ const SoftwareMixerHeadphone = !!engine.getSetting("softwareMixerHeadphone"); // Define custom default layout used by the pads, instead of intro/outro and first 4 hotcues. const DefaultPadLayout = engine.getSetting("defaultPadLayout"); -// Whether or not to use the ShareDataAPI, available in the PR 12199. -const UseSharedDataAPI = engine.getSetting("useSharedDataAPI"); - // 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 @@ -243,6 +240,77 @@ const wheelTimerMax = 2 ** 32 - 1; const baseRevolutionsPerSecond = BaseRevolutionsPerMinute / 60; +// The active tab ID. This is used when SharedDataAPI is active, to communicate with the screens which tab is currently selected. +const ActiveTabPadID = { + record: 8, + samples: 4, + mute: 7, + stems: 5, + cue: 11, +}; + +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, + hotcueColor: 4, +}; + +// 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; +const isObject = (item) => { + return (item && typeof item === "object" && !Array.isArray(item)); +}; + +const mergeDeep = (target, ...sources) => { + if (!sources.length) { return target; } + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) { Object.assign(target, {[key]: {}}); } + mergeDeep(target[key], source[key]); + } else { + Object.assign(target, {[key]: source[key]}); + } + } + } + + return mergeDeep(target, ...sources); +}; + +const hasRuntimeDataAPI = () => typeof engine.getSharedData === "function"; + +const updateRuntimeData = (patch) => { + if (!hasRuntimeDataAPI()) { + return; + } + engine.setSharedData(mergeDeep(engine.getSharedData() || {}, patch)); +}; /* * HID report parsing library @@ -536,6 +604,13 @@ class Deck extends ComponentContainer { component.color = this.groupsToColors[newGroup]; }); this.secondDeckModes = currentModes; + this.currentDeckNumber = newDeck; + + updateRuntimeData({ + group: { + [this.decks[0] === 1 ? "leftdeck":"rightdeck"]: this.group + } + }); } static groupForNumber(deckNumber) { return `[Channel${deckNumber}]`; @@ -548,13 +623,19 @@ class Button extends Component { super(options); - if (this.input === undefined) { + if (this.input === undefined + || (typeof this.onLongPress === "function" && this.onLongPress.length === 0) + || (typeof this.onLongRelease === "function" && this.onLongRelease.length === 0) + || (typeof this.onShortPress === "function" && this.onShortPress.length === 0) + || (typeof this.onShortRelease === "function" && this.onShortRelease.length === 0) + || (typeof this.onPress === "function" && this.onPress.length === 0) + || (typeof this.onRelease === "function" && this.onRelease.length === 0)) { this.input = this.defaultInput; - if (typeof this.input === "function" - && this.inReport instanceof HIDInputReport - && this.input.length === 0) { - this.inConnect(); - } + } + if (typeof this.input === "function" + && this.inReport instanceof HIDInputReport + && this.input.length === 0) { + this.inConnect(); } if (this.longPressTimeOutMillis === undefined) { @@ -614,24 +695,32 @@ class Button extends Component { } } defaultInput(pressed) { + this.pressed = pressed; if (pressed) { + this.isShortPress = true; this.isLongPress = false; + if (typeof this.onPress === "function" && this.onPress.length === 0) { this.onPress(); } 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.isShortPress = false; this.longPressTimer = 0; if (typeof this.onLongPress !== "function") { return; } this.onLongPress(this); }, true); } } else if (this.isLongPress) { + this.isLongPress = false; + if (typeof this.onRelease === "function" && this.onRelease.length === 0) { this.onRelease(); } if (typeof this.onLongRelease === "function" && this.onLongRelease.length === 0) { this.onLongRelease(); } } else { + this.isShortPress = false; if (this.longPressTimer !== 0) { engine.stopTimer(this.longPressTimer); this.longPressTimer = 0; } + if (typeof this.onRelease === "function" && this.onRelease.length === 0) { this.onRelease(); } if (typeof this.onShortRelease === "function" && this.onShortRelease.length === 0) { this.onShortRelease(); } } } @@ -1144,14 +1233,12 @@ class Mixer extends ComponentContainer { 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); + onPress: function() { + 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, @@ -1320,6 +1407,10 @@ class FXSelect extends Button { this.mixer.comboSelected = true; } this.mixer.secondPressedFxSelector = null; + + updateRuntimeData({ + selectedQuickFX: null + }); } } @@ -1478,14 +1569,14 @@ class S4Mk3EffectUnit extends ComponentContainer { this.group = undefined; this.output(false); }, - input: function(pressed) { + onPress: function() { if (!this.shifted) { for (const index of [0, 1, 2]) { const effectGroup = `[EffectRack1_EffectUnit${unitNumber}_Effect${index + 1}]`; - engine.setValue(effectGroup, "enabled", pressed); + engine.setValue(effectGroup, "enabled", true); } - this.output(pressed); - } else if (pressed) { + this.output(true); + } else { if (this.unit.focusedEffect !== null) { this.unit.setFocusedEffect(null); } else { @@ -1493,6 +1584,15 @@ class S4Mk3EffectUnit extends ComponentContainer { this.shift(); } } + }, + onRelease: function() { + if (!this.shifted) { + for (const index of [0, 1, 2]) { + const effectGroup = `[EffectRack1_EffectUnit${unitNumber}_Effect${index + 1}]`; + engine.setValue(effectGroup, "enabled", false); + } + this.output(false); + } } }); @@ -1862,14 +1962,11 @@ class S4Mk3Deck extends Deck { 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) { @@ -1881,7 +1978,6 @@ class S4Mk3Deck extends Deck { this.indicator(true); }, onLongRelease: function() { - this.deck.libraryEncoder.gridButtonPressed = false; if (this.previousMoveMode !== null) { this.deck.moveMode = this.previousMoveMode; this.previousMoveMode = null; @@ -1889,7 +1985,6 @@ class S4Mk3Deck extends Deck { this.indicator(false); }, onShortRelease: function() { - this.deck.libraryEncoder.gridButtonPressed = false; script.triggerControl(this.group, "beats_translate_curpos"); if (this.shift) { @@ -1937,13 +2032,24 @@ class S4Mk3Deck extends Deck { shift: function() { this.output(true); }, - input: function(pressed) { - if (pressed) { - this.deck.shift(); - } else { - this.deck.unshift(); - } - } + onPress: function() { + this.deck.shift(); + + updateRuntimeData({ + shift: { + [decks[0] === 1 ? "leftdeck":"rightdeck"]: true + } + }); + }, + onRelease: function() { + this.deck.unshift(); + + updateRuntimeData({ + shift: { + [decks[0] === 1 ? "leftdeck":"rightdeck"]: false + } + }); + }, }); this.leftEncoder = new Encoder({ @@ -1997,12 +2103,25 @@ class S4Mk3Deck extends Deck { } }); this.leftEncoderPress = new PushButton({ - input: function(pressed) { - this.pressed = pressed; - if (pressed) { + deck: this, + onPress: function() { + if (this.shifted) { script.toggleControl(this.group, "pitch_adjust_set_default"); } + + updateRuntimeData({ + displayBeatloopSize: { + [this.group]: true + } + }); }, + onRelease: hasRuntimeDataAPI() ? function() { + updateRuntimeData({ + displayBeatloopSize: { + [this.group]: false + } + }); + } : undefined }); this.rightEncoder = new Encoder({ @@ -2022,10 +2141,8 @@ class S4Mk3Deck extends Deck { } }); this.rightEncoderPress = new PushButton({ - input: function(pressed) { - if (!pressed) { - return; - } + deck: this, + onPress: function() { const loopEnabled = engine.getValue(this.group, "loop_enabled"); if (!this.shifted) { script.triggerControl(this.group, "beatloop_activate"); @@ -2036,26 +2153,34 @@ class S4Mk3Deck extends Deck { }); this.libraryEncoder = new Encoder({ - libraryPlayButtonPressed: false, - gridButtonPressed: false, - starButtonPressed: false, - libraryViewButtonPressed: false, - libraryPlaylistButtonPressed: false, + deck: this, currentSortedColumnIdx: -1, onChange: function(right) { - if (this.libraryViewButtonPressed) { + let fxChanged = false; + for (const fxButton of this.deck.effectUnit.buttons) { + if (fxButton.pressed) { + script.triggerControl(fxButton.group, right ? "next_effect" : "prev_effect"); + fxChanged = true; + } + } + + if (fxChanged) { + return; + } + + if (this.deck.libraryViewButton.pressed) { this.currentSortedColumnIdx = (LibrarySortableColumns.length + this.currentSortedColumnIdx + (right ? 1 : -1)) % LibrarySortableColumns.length; engine.setValue("[Library]", "sort_column", LibrarySortableColumns[this.currentSortedColumnIdx]); - } else if (this.starButtonPressed) { + } else if (this.deck.libraryStarButton.pressed) { 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) { + } else if (this.deck.gridButton.pressed) { script.triggerControl(this.group, right ? "waveform_zoom_up" : "waveform_zoom_down"); - } else if (this.libraryPlayButtonPressed) { + } else if (this.deck.libraryPlayButton.pressed) { 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 @@ -2069,15 +2194,15 @@ class S4Mk3Deck extends Deck { } } else { engine.setValue("[Library]", "focused_widget", this.shifted ? 2 : 3); - engine.setValue("[Library]", "MoveVertical", right ? 1 : -1); + engine.setValue("[Library]", this.deck.turntableButton.pressed ? "ScrollVertical" : "MoveVertical", right ? 1 : -1); } } } }); this.libraryEncoderPress = new Button({ - libraryViewButtonPressed: false, + deck: this, onShortPress: function() { - if (this.libraryViewButtonPressed) { + if (this.deck.libraryViewButton.pressed) { script.toggleControl("[Library]", "sort_order"); } else { const currentlyFocusWidget = engine.getValue("[Library]", "focused_widget"); @@ -2098,15 +2223,17 @@ class S4Mk3Deck extends Deck { }); this.libraryPlayButton = new PushButton({ group: "[PreviewDeck1]", - libraryEncoder: this.libraryEncoder, - input: function(pressed) { - if (pressed) { - script.triggerControl(this.group, "LoadSelectedTrackAndPlay"); + deck: this, + onPress: function() { + if (this.shifted) { + engine.setValue(this.group, "CloneFromDeck", this.deck.currentDeckNumber); } else { - engine.setValue(this.group, "play", 0); - script.triggerControl(this.group, "eject"); + script.triggerControl(this.group, "LoadSelectedTrackAndPlay"); } - this.libraryEncoder.libraryPlayButtonPressed = pressed; + }, + onRelease: function() { + engine.setValue(this.group, "play", 0); + script.triggerControl(this.group, "eject"); }, outKey: "play", }); @@ -2116,12 +2243,6 @@ class S4Mk3Deck extends Deck { 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({ @@ -2147,18 +2268,11 @@ class S4Mk3Deck extends Deck { 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); } @@ -2171,14 +2285,7 @@ class S4Mk3Deck extends Deck { 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; - } + onLongPress: function() {}, // This is needed to make difference between a shot and long press }); this.keyboardPlayMode = null; @@ -2308,7 +2415,7 @@ class S4Mk3Deck extends Deck { this.hotcuePadModeButton = new Button({ deck: this, - onShortPress: function() { + onShortRelease: function() { if (!this.shifted) { if (this.deck.currentPadLayer !== this.deck.padLayers.hotcuePage2) { switchPadLayer(this.deck, hotcuePage2); @@ -2325,34 +2432,87 @@ class S4Mk3Deck extends Deck { } }, + onShortPress: hasRuntimeDataAPI() ? function() { + updateRuntimeData({ + padsMode: { + [this.group]: ActiveTabPadID.hotcue + } + }); + } : undefined, + onLongPress: function() { + this.previousMoveMode = this.deck.moveMode; + this.deck.moveMode = moveModes.hotcueColor; + + }, + onLongRelease: function() { + this.deck.moveMode = this.previousMoveMode; + this.previousMoveMode = null; + }, + // hack to switch the LED color when changing decks + outTrigger: function() { + this.deck.lightPadMode(); + } + }); + this.recordPadModeButton = new Button({ + deck: this, + onShortPress: hasRuntimeDataAPI() ? function() { + updateRuntimeData({ + padsMode: { + [this.deck.group]: ActiveTabPadID.jump + } + }); + switchPadLayer(this.deck, beatJumpPage); + this.deck.lightPadMode(); + } : undefined, // 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({ + pressed: false, deck: this, onShortPress: function() { + engine.setValue(this.deck.group, "loop_anchor", 1); + + updateRuntimeData({ + padsMode: { + [this.deck.group]: UseBeatloopRollInsteadOfSampler ? ActiveTabPadID.roll : ActiveTabPadID.samples + } + }); + }, + onShortRelease: function() { if (this.deck.currentPadLayer !== this.deck.padLayers.samplerPage) { switchPadLayer(this.deck, samplerOrBeatloopRollPage); - engine.setValue("[Samplers]", "show_samplers", true); + engine.setValue("[Skin]", "show_samplers", true); this.deck.currentPadLayer = this.deck.padLayers.samplerPage; } else { switchPadLayer(this.deck, defaultPadLayer); - engine.setValue("[Samplers]", "show_samplers", false); + engine.setValue("[Skin]", "show_samplers", false); this.deck.currentPadLayer = this.deck.padLayers.defaultLayer; } this.deck.lightPadMode(); + engine.setValue(this.deck.group, "loop_anchor", 0); }, + onLongRelease: function() { + engine.setValue(this.deck.group, "loop_anchor", 0); + } }); // The mute button doesn't have a mapping by default, but you can add yours here - // this.mutePadModeButton = new Button({ - // ... - // }); + this.mutePadModeButton = new Button({ + deck: this, + onShortPress: hasRuntimeDataAPI() ? function() { + updateRuntimeData({ + padsMode: { + [this.deck.group]: ActiveTabPadID.mute + } + }); + } : undefined, + // hack to switch the LED color when changing decks + outTrigger: function() { + this.deck.lightPadMode(); + } + }); this.stemsPadModeButton = new Button({ deck: this, @@ -2364,6 +2524,11 @@ class S4Mk3Deck extends Deck { } }, onShortPress: function() { + updateRuntimeData({ + padsMode: { + [this.deck.group]: ActiveTabPadID.stems + } + }); if (this.previousMoveMode === null) { this.previousMoveMode = this.deck.moveMode; this.deck.moveMode = moveModes.keyboard; @@ -2396,43 +2561,46 @@ class S4Mk3Deck extends Deck { }); this.wheelMode = wheelModes.vinyl; - this.turntableButton = UseMotors ? new Button({ + this.turntableButton = 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; - engine.setValue(this.group, "scratch2_enable", false); - } else { - this.deck.wheelMode = wheelModes.motor; - const group = this.group; - } - this.outTrigger(); - } + onShortPress: function() { }, - outTrigger: function() { + onLongRelease: function() { + }, + onShortRelease: UseMotors ? function() { + this.deck.reverseButton.loopModeOff(true); + this.deck.fluxButton.loopModeOff(true); + if (this.deck.wheelMode === wheelModes.motor) { + this.deck.wheelMode = wheelModes.vinyl; + engine.setValue(this.group, "scratch2_enable", false); + } else { + this.deck.wheelMode = wheelModes.motor; + const group = this.group; + engine.beginTimer(MotorWindUpMilliseconds, () => { + engine.setValue(group, "scratch2_enable", true); + }, true); + } + this.outTrigger(); + } : undefined, + outTrigger: UseMotors ? 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; + } : 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 { - this.deck.wheelMode = wheelModes.vinyl; - } - engine.setValue(this.group, "scratch2_enable", false); - this.outTrigger(); + onPress: function() { + this.deck.reverseButton.loopModeOff(true); + this.deck.fluxButton.loopModeOff(true); + if (this.deck.wheelMode === wheelModes.vinyl) { + this.deck.wheelMode = wheelModes.jog; + } else { + this.deck.wheelMode = wheelModes.vinyl; } + engine.setValue(this.group, "scratch2_enable", false); + this.outTrigger(); }, outTrigger: function() { const vinylOn = this.deck.wheelMode === wheelModes.vinyl; @@ -2697,21 +2865,26 @@ class S4Mk3Deck extends Deck { this.hotcuePadModeButton.send(this.hotcuePadModeButton.color + this.hotcuePadModeButton.brightnessOff); } + const data = (hasRuntimeDataAPI() ? engine.getSharedData() : false) || {}; + // unfortunately the other pad mode buttons only have one LED color // const recordPadModeLEDOn = this.currentPadLayer === this.padLayers.hotcuePage3; - // this.recordPadModeButton.send(recordPadModeLEDOn ? 127 : 0); + this.recordPadModeButton.output(data.padsMode && data.padsMode[this.group] === ActiveTabPadID.jump); 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); + this.mutePadModeButton.output(data.viewArtwork && data.viewArtwork[this.group]); if (this.keyboardPlayMode !== null) { this.stemsPadModeButton.send(LedColors.green + this.stemsPadModeButton.brightnessOn); } else { - const keyboardPadModeLEDOn = this.currentPadLayer === this.padLayers.keyboard; + const keyboardPadModeLEDOn = this.currentPadLayer === this.padLayers.keyboard || this.currentPadLayer === this.padLayers.stem; this.stemsPadModeButton.send(this.stemsPadModeButton.color + (keyboardPadModeLEDOn ? this.stemsPadModeButton.brightnessOn : this.stemsPadModeButton.brightnessOff)); } + if (!hasRuntimeDataAPI() || !data.keyboardMode) { return; } + data.keyboardMode[this.group] = this.currentPadLayer === this.padLayers.keyboard; + engine.setSharedData(data); } } @@ -3197,6 +3370,14 @@ class S4MK3 { wheelLEDinitReport[0] = 1; controller.sendOutputReport(48, wheelLEDinitReport.buffer); + // Reset the motor's torque to a nil value + const motorData = new Uint8Array([ + 1, 0x20, 1, 0, 0, + 1, 0x20, 1, 0, 0, + + ]); + controller.sendOutputReport(49, motorData.buffer); + // Init wheel timer data wheelTimer = null; wheelTimerDelta = 0; @@ -3205,6 +3386,67 @@ class S4MK3 { for (const repordId of [0x01, 0x02]) { this.inReports[repordId].handleInput(controller.getInputReport(repordId)); } + + updateRuntimeData({ + group: { + "leftdeck": "[Channel1]", + "rightdeck": "[Channel2]", + }, + shift: { + "leftdeck": false, + "rightdeck": false, + }, + scrollingWavefom: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + deckColor: { + "[Channel1]": Object.keys(LedColors).indexOf(Object.keys(LedColors).find(key => LedColors[key] === DeckColors[0])) - 1, + "[Channel2]": Object.keys(LedColors).indexOf(Object.keys(LedColors).find(key => LedColors[key] === DeckColors[1])) - 1, + "[Channel3]": Object.keys(LedColors).indexOf(Object.keys(LedColors).find(key => LedColors[key] === DeckColors[2])) - 1, + "[Channel4]": Object.keys(LedColors).indexOf(Object.keys(LedColors).find(key => LedColors[key] === DeckColors[3])) - 1, + }, + rollpadSize: BeatLoopRolls, + selectedQuickFX: null, + selectedHotcue: { + "[Channel1]": null, + "[Channel2]": null, + "[Channel3]": null, + "[Channel4]": null, + }, + selectedStems: { + "[Channel1]": [0, 0, 0, 0], + "[Channel2]": [0, 0, 0, 0], + "[Channel3]": [0, 0, 0, 0], + "[Channel4]": [0, 0, 0, 0], + }, + viewArtwork: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + keyboardMode: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + displayBeatloopSize: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + padsMode: { + "[Channel1]": 0, + "[Channel2]": 0, + "[Channel3]": 0, + "[Channel4]": 0, + }, + }); } shutdown() { // button LEDs From 5f39d261768b3689e8f36aabae6fa3aa8ce0384a Mon Sep 17 00:00:00 2001 From: "Antoine C." Date: Mon, 18 Aug 2025 08:30:48 +0000 Subject: [PATCH 4/6] S4MK3: add stem and improve hotcues --- res/controllers/Traktor-Kontrol-S4-MK3.js | 450 ++++++++++++++++++---- 1 file changed, 382 insertions(+), 68 deletions(-) diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js index c43fb3cf2b5f..06f414192a60 100644 --- a/res/controllers/Traktor-Kontrol-S4-MK3.js +++ b/res/controllers/Traktor-Kontrol-S4-MK3.js @@ -141,8 +141,7 @@ const MixerControlsMixAuxOnShift = !!engine.getSetting("mixerControlsMicAuxOnShi // Default: false const UseBeatloopRollInsteadOfSampler = !!engine.getSetting("useBeatloopRollInsteadOfSampler"); -// Predefined beatlooproll sizes. Note that if you use AddLoopHalveAndDoubleOnBeatloopRollTab, the first and -// last size will be ignored +// Predefined beatlooproll sizes. const BeatLoopRolls = [ engine.getSetting("beatLoopRollsSize1") || 1/8, engine.getSetting("beatLoopRollsSize2") || 1/4, @@ -281,6 +280,19 @@ let wheelTimer = null; // to it and it is guaranteed to be calculated before processing // input for the Components. let wheelTimerDelta = 0; + +/* + * helper function + */ + +const quickFxChannel = (group) => { + return `[QuickEffectRack1_${group}]`; +}; + +const stemChannel = (group, idx) => { + return `${group.substr(0, group.length - 1)}_Stem${idx + 1}]`; +}; + const isObject = (item) => { return (item && typeof item === "object" && !Array.isArray(item)); }; @@ -557,6 +569,13 @@ class Deck extends ComponentContainer { } this.settings = settings; this.secondDeckModes = null; + this.selectedHotcue = null; + + updateRuntimeData({ + selectedHotcue: { + [this.group]: this.selectedHotcue + } + }); } toggleDeck() { if (this.decks === undefined) { @@ -569,14 +588,35 @@ class Deck extends ComponentContainer { newDeckIndex = 0; } - this.switchDeck(Deck.groupForNumber(this.decks[newDeckIndex])); + this.switchDeck(this.decks[newDeckIndex]); } - switchDeck(newGroup) { + switchDeck(newDeck) { + const newGroup = Deck.groupForNumber(newDeck); + + switch (this.moveMode) { + case moveModes.beat: + case moveModes.bpm: + case moveModes.grid: + case moveModes.hotcueColor: + this.moveMode = null; + this.selectedHotcue = null; + + + updateRuntimeData({ + selectedHotcue: { + [this.group]: this.selectedHotcue + } + }); + break; + } + const currentModes = { wheelMode: this.wheelMode, moveMode: this.moveMode, }; + this.selectedStem.fill(false); + engine.setValue(this.group, "scratch2_enable", false); this.group = newGroup; this.color = this.groupsToColors[newGroup]; @@ -598,7 +638,7 @@ class Deck extends ComponentContainer { } 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.group = quickFxChannel(newGroup); } component.color = this.groupsToColors[newGroup]; @@ -680,7 +720,7 @@ class Button extends Component { } indicatorCallback() { this.indicatorState = !this.indicatorState; - this.send((this.indicatorColor || this.color || LedColors.white) + (this.indicatorState ? this.brightnessOn : this.brightnessOff)); + this.send((this.indicatorColor ?? this.color ?? LedColors.white) + (this.indicatorState ? this.brightnessOn : this.brightnessOff)); } indicator(on) { if (on && this.indicatorTimer === 0) { @@ -855,22 +895,42 @@ class HotcueButton extends PushButton { } this.outKey = `hotcue_${this.number}_status`; this.colorKey = `hotcue_${this.number}_color`; + this.indicatorColor = LedColors.off; this.outConnect(); } unshift() { this.inKey = `hotcue_${this.number}_activate`; + this.indicator(false); } shift() { this.inKey = `hotcue_${this.number}_clear`; + this.indicator(true); } input(pressed) { - engine.setValue(this.group, "scratch2_enable", false); - engine.setValue(this.group, this.inKey, pressed); + if (this.deck.moveMode === moveModes.hotcueColor) { + this.deck.selectedHotcue = pressed ? this.number : null; + + updateRuntimeData({ + selectedHotcue: { + [this.group]: this.deck.selectedHotcue + } + }); + } else if (this.deck.libraryPlayButton.pressed) { + engine.setValue(this.deck.libraryPlayButton.group, this.inKey, pressed); + } else { + engine.setValue(this.group, "scratch2_enable", false); + engine.setValue(this.group, this.inKey, pressed); + if (this.shifted) { + this.indicatorColor = LedColors.off; + } + } } output(value) { if (value) { + this.indicatorColor = LedColors.red; this.send(this.color + this.brightnessOn); } else { + this.indicatorColor = LedColors.off; this.send(LedColors.off); } } @@ -893,6 +953,10 @@ class HotcueButton extends PushButton { } } } + outDisconnect() { + this.indicator(false); + super.outDisconnect(); + } } /* @@ -943,7 +1007,7 @@ class KeyboardButton extends PushButton { if (this.number + offset < 1 || this.number + offset > 24) { this.send(0); } else { - this.send(color + (value ? this.brightnessOn : this.brightnessOff)); + this.send(value ? LedColors.yellow : color); } } outConnect() { @@ -961,6 +1025,146 @@ class KeyboardButton extends PushButton { } } +/* + * Represent a pad button that acts as a stem controller. It will be used to mute or unmute a stem or select it for other operation such as volume or quick effect control + */ +class StemButton extends PushButton { + constructor(options) { + super(options); + if (this.number === undefined || !Number.isInteger(this.number) || this.number < 1 || this.number > 4) { + throw Error("StemButton must have a number property of an integer between 1 and 4"); + } + if (this.deck === undefined) { + throw Error("StemButton must have a deck attached to it"); + } + if (this.deck.mixer === undefined) { + throw Error("StemButton must have a deck with a mixer attached to it"); + } + this.color = 0; + this.muted = 0; + this.outConnect(); + } + unshift() { + this.outTrigger(); + } + shift() { + this.outTrigger(); + } + input(pressed) { + if (!this.enabled) { + return; + } + if (this.shifted && pressed) { + script.toggleControl(stemChannel(this.group, this.number - 1), "mute"); + } + if (!this.shifted) { + this.deck.selectedStem[this.number - 1] = pressed; + updateRuntimeData({ + selectedStems: { + [this.group]: this.deck.selectedStem + } + }); + } + if (!this.shifted && pressed && this.deck.mixer.firstPressedFxSelector !== null) { + const presetNumber = this.deck.mixer.calculatePresetNumber(); + this.color = QuickEffectPresetColors[presetNumber - 1]; + engine.setValue(quickFxChannel(stemChannel(this.group, this.number - 1)), "loaded_chain_preset", presetNumber); + this.deck.mixer.firstPressedFxSelector = null; + this.deck.mixer.secondPressedFxSelector = null; + this.deck.mixer.resetFxSelectorColors(); + + + updateRuntimeData({ + selectedQuickFX: null + }); + } + } + output() { + if (!this.color || !this.enabled) { + this.send(0); + } else { + this.send(this.color + (this.muted ? this.brightnessOff : this.brightnessOn)); + } + } + outConnect() { + if (undefined !== this.group) { + const muteConnection = engine.makeConnection(stemChannel(this.group, this.number - 1), "mute", (mute) => { + this.muted = mute; + this.output(); + }); + if (muteConnection) { + this.outConnections[0] = muteConnection; + } else { + console.warn(`Unable to connect '${stemChannel(this.group, this.number)}.mute' to the controller output. The control appears to be unavailable.`); + } + const colorConnection = engine.makeConnection(stemChannel(this.group, this.number - 1), "color", (color) => { + this.color = this.colorMap.getValueForNearestColor(color); + this.output(); + }); + if (colorConnection) { + this.outConnections[1] = colorConnection; + } else { + console.warn(`Unable to connect '${stemChannel(this.group, this.number)}.color' to the controller output. The control appears to be unavailable.`); + } + const enabledConnection = engine.makeConnection(this.group, "stem_count", (count) => { + this.enabled = count >= this.number; + this.output(); + }); + if (enabledConnection) { + this.outConnections[2] = enabledConnection; + } else { + console.warn(`Unable to connect '${this.group}.stem_count' to the controller output. The control appears to be unavailable.`); + } + } + } +} + +class StemMuteButton extends PushButton { + constructor(options) { + if (options.number === undefined || !Number.isInteger(options.number) || options.number < 1 || options.number > 4) { + throw Error("StemMuteButton must have a number property of an integer between 1 and 4"); + } + super(options); + this.color = 0; + this.muted = 0; + this.outConnect(); + } + output() { + if (!this.enabled) { + this.send(0); + } else { + this.send(LedColors.white + (this.muted ? this.brightnessOff : this.brightnessOn)); + } + } + input(pressed) { + if (pressed) { + script.toggleControl(stemChannel(this.group, this.number - 1), "mute"); + } + } + outConnect() { + if (undefined !== this.group) { + const muteConnection = engine.makeConnection(stemChannel(this.group, this.number - 1), "mute", (mute) => { + this.muted = mute; + this.output(); + }); + if (muteConnection) { + this.outConnections[0] = muteConnection; + } else { + console.warn(`Unable to connect '${stemChannel(this.group, this.number)}.mute' to the controller output. The control appears to be unavailable.`); + } + const enabledConnection = engine.makeConnection(this.group, "stem_count", (count) => { + this.enabled = count >= this.number; + this.output(); + }); + if (enabledConnection) { + this.outConnections[1] = enabledConnection; + } else { + console.warn(`Unable to connect '${this.group}.stem_count' 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. */ @@ -979,6 +1183,27 @@ class BeatLoopRollButton extends TriggerButton { throw Error(`BeatLoopRollButton ${options.number}'s size "${BeatLoopRolls[options.number]}" is invalid. Must be a float, or the literal 'half' or 'double'`); } options.key = `beatlooproll_${size}_activate`; + options.onShortPress = function() { + if (!this.deck.beatloop) { + this.deck.beatloop = { + size: engine.getValue(this.group, "beatloop_size"), + start: engine.getValue(this.group, "loop_start_position"), + end: engine.getValue(this.group, "loop_end_position"), + enabled: engine.getValue(this.group, "loop_enabled"), + }; + } + engine.setValue(this.group, this.inKey, true); + }; + options.onShortRelease = function() { + engine.setValue(this.group, this.inKey, false); + if (this.deck.beatloop) { + engine.setValue(this.group, "loop_start_position", this.deck.beatloop.start); + engine.setValue(this.group, "loop_end_position", this.deck.beatloop.end); + engine.setValue(this.group, "beatloop_size", this.deck.beatloop.size); + engine.setValue(this.group, "loop_enabled", this.deck.beatloop.enabled); + this.deck.beatloop = undefined; + } + }; } super(options); if (this.deck === undefined) { @@ -1011,7 +1236,12 @@ class SamplerButton extends Button { onShortPress() { if (!this.shifted) { if (engine.getValue(this.group, "track_loaded") === 0) { - engine.setValue(this.group, "LoadSelectedTrack", 1); + if (this.deck.samplerStemSelection !== null) { + engine.setValue(this.group, "load_selected_track_stems", this.deck.samplerStemSelection); + this.deck.samplerStemSelection = null; + } else { + engine.setValue(this.group, "LoadSelectedTrack", 1); + } } else { engine.setValue(this.group, "cue_gotoandplay", 1); } @@ -1375,8 +1605,16 @@ class FXSelect extends Button { } } this.outReport.send(); + + updateRuntimeData({ + selectedQuickFX: this.mixer.calculatePresetNumber() + }); } else { this.mixer.secondPressedFxSelector = this.number; + + updateRuntimeData({ + selectedQuickFX: this.mixer.calculatePresetNumber() + }); } } @@ -1396,7 +1634,7 @@ class FXSelect extends Button { 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); + engine.setValue(quickFxChannel(`[Channel${deck}]`), "loaded_chain_preset", presetNumber); } } if (this.mixer.firstPressedFxSelector === this.number) { @@ -1425,7 +1663,7 @@ class QuickEffectButton extends Button { 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.group = quickFxChannel(`[Channel${this.number}]`); this.outConnect(); } onShortPress() { @@ -1477,7 +1715,7 @@ class QuickEffectButton extends Button { } /* - * Kontrol S4 Mk3 hardware-specific constants + * Kontrol S4 Mk3 hardware-specific member constants */ Pot.prototype.max = 2 ** 12 - 1; @@ -1499,39 +1737,6 @@ Button.prototype.uncoloredOutput = function(value) { }; Button.prototype.colorMap = new ColorMapper(LedColorMap); - -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 */ @@ -1905,7 +2110,7 @@ class S4Mk3Deck extends Deck { this.indicator(false); const wheelOutput = new Uint8Array(40).fill(0); wheelOutput[0] = decks[0] - 1; - controller.sendOutputReport(wheelOutput.buffer, null, 50, true); + controller.sendOutputReport(50, wheelOutput.buffer, true); if (!skipRestore) { this.deck.wheelMode = this.previousWheelMode; } @@ -1997,7 +2202,7 @@ class S4Mk3Deck extends Deck { deck: this, input: function(value) { if (value) { - this.deck.switchDeck(Deck.groupForNumber(decks[0])); + this.deck.switchDeck(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] = DeckSelectAlwaysBacklit ? colors[1] + this.brightnessOff : 0; @@ -2009,7 +2214,7 @@ class S4Mk3Deck extends Deck { deck: this, input: function(value) { if (value) { - this.deck.switchDeck(Deck.groupForNumber(decks[1])); + this.deck.switchDeck(decks[1]); // turn off the other deck selection button's LED this.outReport.data[io.deckButtonOutputByteOffset] = DeckSelectAlwaysBacklit ? colors[0] + this.brightnessOff : 0; this.outReport.data[io.deckButtonOutputByteOffset + 1] = colors[1] + this.brightnessOn; @@ -2055,6 +2260,14 @@ class S4Mk3Deck extends Deck { this.leftEncoder = new Encoder({ deck: this, onChange: function(right) { + if (this.deck.hasSelectedStem()) { + this.deck.selectedStem.forEach((selected, stemIdx) => { + if (!selected) { return; } + + engine.setValue(stemChannel(this.group, stemIdx), "volume", engine.getValue(stemChannel(this.group, stemIdx), "volume") + (right ? this.tickDelta : -this.tickDelta)); + }); + return; + } switch (this.deck.moveMode) { case moveModes.grid: @@ -2062,18 +2275,34 @@ class S4Mk3Deck extends Deck { break; case moveModes.keyboard: if ( - this.deck.keyboard[0].offset === (right ? 16 : 0) + this.deck.pads[0].offset === (right ? 16 : 0) ) { return; } this.deck.keyboardOffset += (right ? 1 : -1); - this.deck.keyboard.forEach(function(pad) { + this.deck.pads.forEach(function(pad) { pad.outTrigger(); }); break; case moveModes.bpm: script.triggerControl(this.group, right ? "beats_translate_later" : "beats_translate_earlier"); break; + case moveModes.hotcueColor:{ + if (this.deck.selectedHotcue === null) { + return; + } + const currentColor = Button.prototype.colorMap.getValueForNearestColor(engine.getValue(this.deck.group, `hotcue_${this.deck.selectedHotcue}_color`)); + let currentColorIdx = Object.keys(LedColorMap).indexOf(Object.keys(LedColorMap).find(key => LedColorMap[key] === currentColor)); + currentColorIdx = Math.max( + Math.min( + Object.keys(LedColorMap).length - 2, // Last color is reserved for loop hotcue + currentColorIdx + (right ? 1:-1) + ), + 0 + ); + engine.setValue(this.deck.group, `hotcue_${this.deck.selectedHotcue}_color`, Object.keys(LedColorMap)[currentColorIdx]); + break; + } default: if (!this.shifted) { if (!this.deck.leftEncoderPress.pressed) { @@ -2105,6 +2334,14 @@ class S4Mk3Deck extends Deck { this.leftEncoderPress = new PushButton({ deck: this, onPress: function() { + if (this.deck.hasSelectedStem()) { + this.deck.selectedStem.forEach((selected, stemIdx) => { + if (!selected) { return; } + + engine.setValue(stemChannel(this.group, stemIdx), "volume", engine.getValue(stemChannel(this.group, stemIdx), "volume") === 1.0 ? 0 : 1); + }); + return; + } if (this.shifted) { script.toggleControl(this.group, "pitch_adjust_set_default"); } @@ -2127,7 +2364,13 @@ class S4Mk3Deck extends Deck { this.rightEncoder = new Encoder({ deck: this, onChange: function(right) { - if (this.deck.wheelMode === wheelModes.loopIn || this.deck.wheelMode === wheelModes.loopOut) { + if (this.deck.hasSelectedStem()) { + this.deck.selectedStem.forEach((selected, stemIdx) => { + if (!selected) { return; } + + engine.setValue(quickFxChannel(stemChannel(this.group, stemIdx)), "super1", engine.getValue(quickFxChannel(stemChannel(this.group, stemIdx)), "super1") + (right ? this.tickDelta : -this.tickDelta)); + }); + } else 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); @@ -2143,8 +2386,13 @@ class S4Mk3Deck extends Deck { this.rightEncoderPress = new PushButton({ deck: this, onPress: function() { - const loopEnabled = engine.getValue(this.group, "loop_enabled"); - if (!this.shifted) { + if (this.deck.hasSelectedStem()) { + this.deck.selectedStem.forEach((selected, stemIdx) => { + if (!selected) { return; } + + script.toggleControl(quickFxChannel(stemChannel(this.group, stemIdx)), "enabled"); + }); + } else if (!this.shifted) { script.triggerControl(this.group, "beatloop_activate"); } else { script.triggerControl(this.group, "reloop_toggle"); @@ -2210,7 +2458,11 @@ class S4Mk3Deck extends Deck { if (this.shifted && currentlyFocusWidget === 0) { script.triggerControl("[Playlist]", "ToggleSelectedSidebarItem"); } else if (currentlyFocusWidget === 3 || currentlyFocusWidget === 0) { - script.triggerControl(this.group, "LoadSelectedTrack"); + if (this.deck.hasSelectedStem()) { + engine.setValue(this.group, "load_selected_track_stems", this.deck.stemSelection()); + } else { + script.triggerControl(this.group, "LoadSelectedTrack"); + } } else { script.triggerControl("[Library]", "GoToItem"); } @@ -2306,28 +2558,58 @@ class S4Mk3Deck extends Deck { cueBaseName: "outro_end", }), new HotcueButton({ - number: 1 + number: 1, deck: this }), new HotcueButton({ - number: 2 + number: 2, deck: this }), new HotcueButton({ - number: 3 + number: 3, deck: this }), new HotcueButton({ - number: 4 + number: 4, deck: this }) ]; const hotcuePage2 = Array(8).fill({}); const hotcuePage3 = Array(8).fill({}); const samplerOrBeatloopRollPage = Array(8).fill({}); - this.keyboard = Array(8).fill({}); + const keyboard = Array(8).fill({}); + const stem = [ + new StemButton({ + number: 1, + deck: this, + }), + new StemButton({ + number: 2, + deck: this, + }), + new StemButton({ + number: 3, + deck: this, + }), + new StemButton({ + number: 4, + deck: this, + }), + new StemMuteButton({ + number: 1, + }), + new StemMuteButton({ + number: 2, + }), + new StemMuteButton({ + number: 3, + }), + new StemMuteButton({ + number: 4, + }), + ]; 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}); + hotcuePage2[i] = new HotcueButton({number: i + 1, deck: this}); + hotcuePage3[i] = new HotcueButton({number: i + 13, deck: this}); if (UseBeatloopRollInsteadOfSampler) { samplerOrBeatloopRollPage[i] = new BeatLoopRollButton({ number: i, @@ -2344,6 +2626,7 @@ class S4Mk3Deck extends Deck { } samplerOrBeatloopRollPage[i] = new SamplerButton({ number: samplerNumber, + deck: this, }); if (SamplerCrossfaderAssign) { engine.setValue( @@ -2353,7 +2636,7 @@ class S4Mk3Deck extends Deck { ); } } - this.keyboard[i] = new KeyboardButton({ + keyboard[i] = new KeyboardButton({ number: i + 1, deck: this, }); @@ -2362,9 +2645,13 @@ class S4Mk3Deck extends Deck { const switchPadLayer = (deck, newLayer) => { let index = 0; + if (newLayer === samplerOrBeatloopRollPage && deck.hasSelectedStem()) { + deck.samplerStemSelection = deck.stemSelection(); + } for (let pad of deck.pads) { pad.outDisconnect(); pad.inDisconnect(); + const shifted = pad.shifted; pad = newLayer[index]; Object.assign(pad, io.pads[index]); @@ -2378,6 +2665,11 @@ class S4Mk3Deck extends Deck { if (pad.inReport === undefined) { pad.inReport = inReports[1]; } + if (shifted && typeof pad.shift === "function" && pad.shift.length === 0) { + pad.shift(); + } else if (typeof pad.unshift === "function" && pad.unshift.length === 0) { + pad.unshift(); + } pad.outReport = outReport; pad.inConnect(); pad.outConnect(); @@ -2393,6 +2685,7 @@ class S4Mk3Deck extends Deck { hotcuePage3: 2, samplerPage: 3, keyboard: 5, + stem: 6, }; switch (DefaultPadLayout) { case DefaultPadLayoutHotcue: @@ -2539,12 +2832,19 @@ class S4Mk3Deck extends Deck { this.deck.moveMode = this.previousMoveMode; this.previousMoveMode = null; } - if (this.deck.currentPadLayer === this.deck.padLayers.keyboard) { + let targetLayer = this.deck.padLayers.stem; + if (this.shifted) { + targetLayer = this.deck.padLayers.keyboard; + } + if (this.deck.currentPadLayer === targetLayer) { 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; + } else if (targetLayer === this.deck.padLayers.stem) { + switchPadLayer(this.deck, stem); + this.deck.currentPadLayer = targetLayer; + } else if (targetLayer === this.deck.padLayers.keyboard) { + switchPadLayer(this.deck, keyboard); + this.deck.currentPadLayer = targetLayer; } this.deck.lightPadMode(); }, @@ -2819,6 +3119,9 @@ class S4Mk3Deck extends Deck { } }); + this.selectedStem = new Array(4).fill(false); + this.samplerStemSelection = null; + for (const property in this) { if (Object.prototype.hasOwnProperty.call(this, property)) { const component = this[property]; @@ -2848,6 +3151,17 @@ class S4Mk3Deck extends Deck { } } + hasSelectedStem() { + return this.selectedStem.some((stemSelected) => stemSelected); + } + + stemSelection() { + return [...this.selectedStem].reverse().reduce( + (acc, curr) => (curr + acc * 2), + 0, + ); + } + assignKeyboardPlayMode(group, action) { this.keyboardPlayMode = { group: group, @@ -2992,7 +3306,7 @@ class S4Mk3MixerColumn extends ComponentContainer { inKey: "parameter1", }); this.quickEffectKnob = new Pot({ - group: `[QuickEffectRack1_${this.group}]`, + group: quickFxChannel(this.group), inKey: "super1", }); this.volume = new Pot({ From 9dc7f27201a4d566091a4160d4ee9e4b97bd00f9 Mon Sep 17 00:00:00 2001 From: "Antoine C." Date: Mon, 18 Aug 2025 08:45:17 +0000 Subject: [PATCH 5/6] S4MK3: add beat jump pad --- .../Traktor Kontrol S4 MK3.hid.xml | 182 ++++++++++++++++++ res/controllers/Traktor-Kontrol-S4-MK3.js | 91 ++++++++- 2 files changed, 269 insertions(+), 4 deletions(-) diff --git a/res/controllers/Traktor Kontrol S4 MK3.hid.xml b/res/controllers/Traktor Kontrol S4 MK3.hid.xml index 353028ee2e0a..64abb93cb6e0 100644 --- a/res/controllers/Traktor Kontrol S4 MK3.hid.xml +++ b/res/controllers/Traktor Kontrol S4 MK3.hid.xml @@ -424,6 +424,188 @@ + + + + + + + + + + + + + +