diff --git a/res/controllers/Traktor Kontrol S4 MK3.bulk.xml b/res/controllers/Traktor Kontrol S4 MK3.bulk.xml new file mode 100644 index 00000000000..c7bc44712ea --- /dev/null +++ b/res/controllers/Traktor Kontrol S4 MK3.bulk.xml @@ -0,0 +1,945 @@ + + + + Traktor Kontrol S4 MK3 (Screens) + A. Colombier + Mapping for Traktor Kontrol S4 MK3 screens + native_instruments_traktor_kontrol_s4_mk3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/controllers/Traktor Kontrol S4 MK3.hid.xml b/res/controllers/Traktor Kontrol S4 MK3.hid.xml index a254772e6c5..4efc26d7041 100644 --- a/res/controllers/Traktor Kontrol S4 MK3.hid.xml +++ b/res/controllers/Traktor Kontrol S4 MK3.hid.xml @@ -10,6 +10,16 @@ + + + - + diff --git a/res/controllers/Traktor-Kontrol-S4-MK3.js b/res/controllers/Traktor-Kontrol-S4-MK3.js index 960c2ac50c1..74ca6ded9fb 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 = [ @@ -145,6 +169,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 @@ -189,6 +215,60 @@ 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 wheelTimerTicksPerSecond = 100000000; // One tick every 10ns + +const baseRevolutionsPerSecond = BaseRevolutionsPerMinute / 60; +const wheelTicksPerTimerTicksToRevolutionsPerSecond = wheelTimerTicksPerSecond / wheelAbsoluteMax; + +// 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; + /* * HID report parsing library */ @@ -328,13 +408,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.`); } } } @@ -346,6 +426,7 @@ class Component { } outTrigger() { for (const connection of this.outConnections) { + if (!connection) { continue; } connection.trigger(); } } @@ -373,6 +454,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(); } @@ -429,6 +513,7 @@ class Deck extends ComponentContainer { this.color = colors[0]; } this.secondDeckModes = null; + this.selectedHotcue = null; } toggleDeck() { if (this.decks === undefined) { @@ -441,14 +526,28 @@ 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; + 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]; @@ -470,12 +569,22 @@ 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]; }); this.secondDeckModes = currentModes; + this.currentDeckNumber = newDeck; + + if (!UseSharedDataAPI) { + return; + } + + const data = engine.getSharedData() || {}; + if (!data.group) { return; } + data.group[this.decks[0] === 1 ? "leftdeck":"rightdeck"] = this.group; + engine.setSharedData(data); } static groupForNumber(deckNumber) { return `[Channel${deckNumber}]`; @@ -488,13 +597,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) { @@ -554,24 +669,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(); } } } @@ -610,8 +733,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); @@ -717,8 +838,21 @@ class HotcueButton extends PushButton { this.inKey = `hotcue_${this.number}_clear`; } 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; + if (UseSharedDataAPI) { + const data = engine.getSharedData() || {}; + if (!data.selectedHotcue) { return; } + data.selectedHotcue[this.group] = this.deck.selectedHotcue; + engine.setSharedData(data); + } + + } 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); + } } output(value) { if (value) { @@ -728,10 +862,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.`); } @@ -740,7 +874,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.`); } @@ -796,17 +930,17 @@ 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() { - 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.`); } @@ -814,6 +948,90 @@ 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), "mute"); + } + if (!this.shifted) { + this.deck.selectedStem[this.number] = pressed; + } + 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)), "loaded_chain_preset", presetNumber + 1); + this.deck.mixer.firstPressedFxSelector = null; + this.deck.mixer.secondPressedFxSelector = null; + this.deck.mixer.resetFxSelectorColors(); + } + } + 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), "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), "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.`); + } + } + } +} + /* * Represent a pad button that will trigger a pre-defined beatloop size as set in BeatLoopRolls. */ @@ -833,16 +1051,24 @@ class BeatLoopRollButton extends TriggerButton { } options.key = `beatlooproll_${size}_activate`; options.onShortPress = function() { - if (!this.deck.beatloopSize) { - this.deck.beatloopSize = engine.getValue(this.group, "beatloop_size"); + 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.beatloopSize) { - engine.setValue(this.group, "beatloop_size", this.deck.beatloopSize); - this.deck.beatloopSize = undefined; + 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; } }; } @@ -877,7 +1103,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); } @@ -909,16 +1140,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.`); } @@ -1099,14 +1330,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, @@ -1262,7 +1491,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 + 1); + engine.setValue(quickFxChannel(`[Channel${deck}]`), "loaded_chain_preset", presetNumber + 1); } } if (this.mixer.firstPressedFxSelector === this.number) { @@ -1287,7 +1516,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() { @@ -1321,16 +1550,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.`); } @@ -1339,7 +1568,7 @@ class QuickEffectButton extends Button { } /* - * Kontrol S4 Mk3 hardware-specific constants + * Kontrol S4 Mk3 hardware-specific member constants */ Pot.prototype.max = 2 ** 12 - 1; @@ -1347,6 +1576,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; @@ -1358,71 +1588,20 @@ 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, - - 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 +Button.prototype.colorMap = new ColorMapper(LedColorMap); -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 -}; +/* + * helper function + */ -// The mode available, which the wheel can be used for. -const wheelModes = { - jog: 0, - vinyl: 1, - motor: 2, - loopIn: 3, - loopOut: 4, +const quickFxChannel = (group) => { + return `[QuickEffectRack1_${group}]`; }; -const moveModes = { - beat: 0, - bpm: 1, - grid: 2, - keyboard: 3, +const stemChannel = (group, idx) => { + return `${group.substr(0, group.length - 1)}Stem${idx}]`; }; -// 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 */ @@ -1460,14 +1639,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 { @@ -1475,6 +1654,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); + } } }); @@ -1746,10 +1934,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.`); } @@ -1767,7 +1955,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; } @@ -1824,14 +2012,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) { @@ -1843,7 +2028,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; @@ -1851,7 +2035,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) { @@ -1864,7 +2047,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; @@ -1876,7 +2059,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; @@ -1899,18 +2082,52 @@ 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.call(this.deck); + + if (!UseSharedDataAPI) { + return; } - } + + const data = engine.getSharedData() || {}; + if (!data.shift) { return; } + data.shift[decks[0] === 1 ? "leftdeck":"rightdeck"] = true; + engine.setSharedData(data); + }, + onRelease: function() { + this.deck.unshift.call(this.deck); + + if (!UseSharedDataAPI) { + return; + } + + const data = engine.getSharedData() || {}; + if (!data.shift) { return; } + data.shift[decks[0] === 1 ? "leftdeck":"rightdeck"] = false; + engine.setSharedData(data); + }, }); 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; + } + + 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: @@ -1918,18 +2135,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) { @@ -1959,17 +2192,50 @@ class S4Mk3Deck extends Deck { } }); this.leftEncoderPress = new PushButton({ - input: function(pressed) { - this.pressed = pressed; - if (pressed) { + 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"); } + + if (!UseSharedDataAPI) { + return; + } + + + const data = engine.getSharedData() || {}; + if (!data.displayBeatloopSize) { return; } + data.displayBeatloopSize[this.group] = true; + engine.setSharedData(data); }, + onRelease: UseSharedDataAPI ? function() { + const data = engine.getSharedData() || {}; + if (!data.displayBeatloopSize) { return; } + data.displayBeatloopSize[this.group] = false; + engine.setSharedData(data); + } : undefined }); this.rightEncoder = new Encoder({ deck: this, onChange: function(right) { + 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)); + }); + return; + } + 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); @@ -1984,8 +2250,14 @@ class S4Mk3Deck extends Deck { } }); this.rightEncoderPress = new PushButton({ - input: function(pressed) { - if (!pressed) { + deck: this, + onPress: function() { + if (this.deck.hasSelectedStem()) { + this.deck.selectedStem.forEach((selected, stemIdx) => { + if (!selected) { return; } + + script.toggleControl(quickFxChannel(stemChannel(this.group, stemIdx)), "enabled"); + }); return; } const loopEnabled = engine.getValue(this.group, "loop_enabled"); @@ -1998,26 +2270,22 @@ 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) { + 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 @@ -2037,9 +2305,9 @@ class S4Mk3Deck extends Deck { } }); 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"); @@ -2047,7 +2315,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"); } @@ -2060,15 +2332,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", }); @@ -2078,12 +2352,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({ @@ -2096,7 +2364,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.`); } @@ -2109,18 +2377,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); } @@ -2133,14 +2394,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; @@ -2161,28 +2415,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 Component({ + outConnect: function() { this.send(0); }, + }), + new Component({ + outConnect: function() { this.send(0); }, + }), + new Component({ + outConnect: function() { this.send(0); }, + }), + new Component({ + outConnect: function() { this.send(0); }, + }), + ]; 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, @@ -2199,6 +2483,7 @@ class S4Mk3Deck extends Deck { } samplerOrBeatloopRollPage[i] = new SamplerButton({ number: samplerNumber, + deck: this, }); if (SamplerCrossfaderAssign) { engine.setValue( @@ -2208,7 +2493,7 @@ class S4Mk3Deck extends Deck { ); } } - this.keyboard[i] = new KeyboardButton({ + keyboard[i] = new KeyboardButton({ number: i + 1, deck: this, }); @@ -2217,9 +2502,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]); @@ -2233,6 +2522,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(); @@ -2248,6 +2542,7 @@ class S4Mk3Deck extends Deck { hotcuePage3: 2, samplerPage: 3, keyboard: 5, + stem: 6, }; switch (DefaultPadLayout) { case DefaultPadLayoutHotcue: @@ -2270,7 +2565,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); @@ -2287,34 +2582,86 @@ class S4Mk3Deck extends Deck { } }, + onShortPress: UseSharedDataAPI ? function() { + const data = engine.getSharedData() || {}; + if (!data.padsMode) { return; } + data.padsMode[this.deck.group] = ActiveTabPadID.cue; + engine.setSharedData(data); + } : 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(); } }); // The record button doesn't have a mapping by default, but you can add yours here - // this.recordPadModeButton = new Button({ - // ... - // }); + this.recordPadModeButton = new Button({ + deck: this, + onShortPress: UseSharedDataAPI ? function() { + const data = engine.getSharedData() || {}; + if (!data.padsMode) { return; } + data.padsMode[this.deck.group] = ActiveTabPadID.record; + engine.setSharedData(data); + this.output(data.scrollingWavefom[this.deck.group]); + } : undefined, + // hack to switch the LED color when changing decks + outTrigger: function() { + this.deck.lightPadMode(); + } + }); this.samplesPadModeButton = new Button({ + pressed: false, deck: this, onShortPress: function() { + engine.setValue(this.deck.group, "loop_anchor", 1); + + if (!UseSharedDataAPI) { + return; + } + const data = engine.getSharedData() || {}; + if (!data.padsMode) { return; } + data.padsMode[this.deck.group] = ActiveTabPadID.samples; + engine.setSharedData(data); + }, + 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: UseSharedDataAPI ? function() { + const data = engine.getSharedData() || {}; + if (!data.padsMode) { return; } + data.padsMode[this.deck.group] = ActiveTabPadID.mute; + engine.setSharedData(data); + } : undefined, + // hack to switch the LED color when changing decks + outTrigger: function() { + this.deck.lightPadMode(); + } + }); this.stemsPadModeButton = new Button({ deck: this, @@ -2326,6 +2673,12 @@ class S4Mk3Deck extends Deck { } }, onShortPress: function() { + if (UseSharedDataAPI) { + const data = engine.getSharedData() || {}; + if (!data.padsMode) { return; } + data.padsMode[this.deck.group] = ActiveTabPadID.stems; + engine.setSharedData(data); + } if (this.previousMoveMode === null) { this.previousMoveMode = this.deck.moveMode; this.deck.moveMode = moveModes.keyboard; @@ -2336,12 +2689,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(); }, @@ -2358,21 +2718,22 @@ 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(); + onPress: 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(); }, outTrigger: function() { const motorOn = this.deck.wheelMode === wheelModes.motor; @@ -2383,18 +2744,16 @@ class S4Mk3Deck extends Deck { }) : 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; @@ -2512,6 +2871,7 @@ class S4Mk3Deck extends Deck { engine.setValue(this.group, "scratch2", this.speed); } else { engine.setValue(this.group, "jog", this.speed); + console.log(this.speed) } break; default: @@ -2582,6 +2942,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]; @@ -2611,6 +2974,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, @@ -2628,21 +3002,26 @@ class S4Mk3Deck extends Deck { this.hotcuePadModeButton.send(this.hotcuePadModeButton.color + this.hotcuePadModeButton.brightnessOff); } + const data = (UseSharedDataAPI ? 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.scrollingWavefom && data.scrollingWavefom[this.group]); 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 (!UseSharedDataAPI || !data.keyboardMode) { return; } + data.keyboardMode[this.group] = this.currentPadLayer === this.padLayers.keyboard; + engine.setSharedData(data); } } @@ -2750,7 +3129,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({ @@ -2848,6 +3227,7 @@ class S4Mk3MixerColumn extends ComponentContainer { if (!alternativeInput) { return; } + console.log(shifted ? alternativeInput : `[Channel${this.idx}]`); this.group = shifted ? alternativeInput : `[Channel${this.idx}]`; for (const property of ["gain", "volume", "pfl", "crossfaderSwitch"]) { const component = this[property]; @@ -3121,6 +3501,13 @@ class S4MK3 { wheelLEDinitReport[0] = 1; controller.sendOutputReport(48, wheelLEDinitReport.buffer); + 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; @@ -3129,6 +3516,51 @@ class S4MK3 { for (const repordId of [0x01, 0x02]) { this.inReports[repordId].handleInput(controller.getInputReport(repordId)); } + + if (!UseSharedDataAPI) { + return; + } + + engine.setSharedData({ + group: { + "leftdeck": "[Channel1]", + "rightdeck": "[Channel2]", + }, + shift: { + "leftdeck": false, + "rightdeck": false, + }, + scrollingWavefom: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + 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 diff --git a/res/controllers/TraktorKontrolS4MK3Screens.qml b/res/controllers/TraktorKontrolS4MK3Screens.qml new file mode 100644 index 00000000000..fe522503278 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens.qml @@ -0,0 +1,217 @@ +import QtQuick 2.15 +import QtQuick.Window 2.3 + +import QtQuick.Controls 2.15 +import QtQuick.Shapes 1.11 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.15 + +import Qt5Compat.GraphicalEffects + +import "." as Skin +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +import S4MK3 as S4MK3 + +Mixxx.ControllerScreen { + id: root + + required property string screenId + property color fontColor: Qt.rgba(242/255,242/255,242/255, 1) + property color smallBoxBorder: Qt.rgba(44/255,44/255,44/255, 1) + + property string group: screenId == "rightdeck" ? "[Channel2]" : "[Channel1]" + property string theme: engine.getSetting("theme") + + readonly property bool isStockTheme: theme == "stock" + + property var lastFrame: null + + init: function(_controllerName, isDebug) { + console.log(`Screen ${root.screenId} has started with theme ${root.theme}`) + root.state = "Live" + } + + shutdown: function() { + console.log(`Screen ${root.screenId} is stopping`) + root.state = "Stop" + } + + transformFrame: function(input, timestamp) { + let updated = new Uint8Array(320*240); + updated.fill(0) + + let updatedPixelCount = 0; + let updated_zones = []; + + if (!root.lastFrame) { + root.lastFrame = new ArrayBuffer(input.byteLength); + updatedPixelCount = input.byteLength / 2; + updated_zones.push({ + x: 0, + y: 0, + width: 320, + height: 240, + }) + } else { + const view_input = new Uint8Array(input); + const view_last = new Uint8Array(root.lastFrame); + + for (let i = 0; i < 320 * 240; i++) { + } + + let current_rect = null; + + for (let y = 0; y < 240; y++) { + let line_changed = false; + for (let x = 0; x < 320; x++) { + let i = y * 320 + x; + if (view_input[2 * i] != view_last[2 * i] || view_input[2 * i + 1] != view_last[2 * i + 1]) { + line_changed = true; + updatedPixelCount++; + break; + } + } + if (current_rect !== null && line_changed) { + current_rect.height++; + } else if (current_rect !== null) { + updated_zones.push(current_rect); + current_rect = null; + } else if (current_rect === null && line_changed) { + current_rect = { + x: 0, + y, + width: 320, + height: 1, + }; + } + } + if (current_rect !== null) { + updated_zones.push(current_rect); + } + } + new Uint8Array(root.lastFrame).set(new Uint8Array(input)); + + if (!updatedPixelCount) { + return new ArrayBuffer(0); + } else if (root.renderDebug) { + console.log(`Pixel updated: ${updatedPixelCount}, ${updated_zones.length} areas`); + } + + // No redraw needed, stop right there + + let totalPixelToDraw = 0; + for (const area of updated_zones) { + area.x -= Math.min(2, area.x); + area.y -= Math.min(2, area.y); + area.width += Math.min(4, 320 - area.x - area.width); + area.height += Math.min(4, 240 - area.y - area.height); + totalPixelToDraw += area.width*area.height; + } + + if (totalPixelToDraw != 320*240 && (totalPixelToDraw > 320 * 180 || updated_zones.length > 20)) { + if (root.renderDebug) { + console.log(`Full redraw instead of ${totalPixelToDraw} pixels/${updated_zones.length} areas`) + } + totalPixelToDraw = 320*240 + updated_zones = [{ + x: 0, + y: 0, + width: 320, + height: 240, + }] + } else if (root.renderDebug) { + console.log(`Redrawing ${totalPixelToDraw} pixels`) + } + + const screenIdx = screenId === "leftdeck" ? 0 : 1; + + const outputData = new ArrayBuffer(totalPixelToDraw*2 + 20*updated_zones.length); // Number of pixel + 20 (header/footer size) x the number of region + let offset = 0; + + for (const area of updated_zones) { + const header = new Uint8Array(outputData, offset, 16); + const payload = new Uint8Array(outputData, offset + 16, area.width*area.height*2); + const footer = new Uint8Array(outputData, offset + area.width*area.height*2 + 16, 4); + + header.fill(0) + footer.fill(0) + header[0] = 0x84; + header[2] = screenIdx; + header[3] = 0x21; + + header[8] = area.x >> 8; + header[9] = area.x & 0xff; + header[10] = area.y >> 8; + header[11] = area.y & 0xff; + + header[12] = area.width >> 8; + header[13] = area.width & 0xff; + header[14] = area.height >> 8; + header[15] = area.height & 0xff; + + if (area.x === 0 && area.width === 320) { + payload.set(new Uint8Array(input, area.y * 320 * 2, area.width*area.height*2)); + } else { + for (let y = 0; y < area.height; y++) { + payload.set( + new Uint8Array(input, ((area.y + y) * 320 + area.x) * 2, area.width * 2), + y * area.width * 2); + } + } + footer[0] = 0x40; + footer[2] = screenIdx; + offset += area.width*area.height*2 + 20 + } + if (root.renderDebug) { + console.log(`Generated ${offset} bytes to be sent`) + } + // return new ArrayBuffer(0); + return outputData; + } + + Component { + id: splashOff + S4MK3.SplashOff { + anchors.fill: parent + } + } + Component { + id: stockLive + S4MK3.StockScreen { + group: root.group + screenId: root.screenId + anchors.fill: parent + } + } + Component { + id: advancedLive + S4MK3.AdvancedScreen { + isLeftScreen: root.screenId == "leftdeck" + } + } + + Loader { + id: loader + anchors.fill: parent + sourceComponent: splashOff + } + + states: [ + State { + name: "Live" + PropertyChanges { + target: loader + sourceComponent: isStockTheme ? stockLive : advancedLive + } + }, + State { + name: "Stop" + PropertyChanges { + target: loader + sourceComponent: splashOff + } + } + ] +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/BPMIndicator.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/BPMIndicator.qml new file mode 100755 index 00000000000..c49800618f2 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/BPMIndicator.qml @@ -0,0 +1,77 @@ +/* +This module is used to define the top right section, right under the label. +Currently this section is dedicated to BPM and tempo fader information. +*/ +import QtQuick 2.14 +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Rectangle { + id: root + + required property string group + required property color borderColor + + property real value: 0 + + color: "transparent" + radius: 6 + border.color: smallBoxBorder + border.width: 2 + + signal updated + + Text { + id: indicator + text: "-" + font.pixelSize: 17 + color: fontColor + anchors.centerIn: parent + + Mixxx.ControlProxy { + group: root.group + key: "bpm" + onValueChanged: (value) => { + const newValue = value.toFixed(2); + if (newValue === indicator.text) return; + indicator.text = newValue; + root.updated() + } + } + } + + Text { + id: range + font.pixelSize: 9 + color: fontColor + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.topMargin: 2 + + horizontalAlignment: Text.AlignHCenter + + Mixxx.ControlProxy { + group: root.group + key: "rateRange" + onValueChanged: (value) => { + const newValue = `-/+ \n${(value * 100).toFixed()}%`; + if (range.text === newValue) return; + range.text = newValue; + root.updated(); + } + } + } + + states: State { + name: "compacted" + + PropertyChanges { + target: range + visible: false + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/HotcuePoint.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/HotcuePoint.qml new file mode 100644 index 00000000000..f39bdc1ca79 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/HotcuePoint.qml @@ -0,0 +1,199 @@ +/* +This module is used to define markers element as render over the the overview waveform. +When this is written, Mixxx QML doesn't have waveform overview marker ready to be used, so this +is an attempt to provide fully functional markers for the controller screen, while the Mixxx QML +interface is still being worked on. +Consider replacing this with native overview marker in the future. +*/ +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Window 2.15 + +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx + +Item { + required property real position + required property int type + + property int number: 1 + property color color: 'blue' + + enum Type { + OneShot, + Loop, + IntroIn, + IntroOut, + OutroIn, + OutroOut, + LoopIn, + LoopOut + } + + property variant typeWithNumber: [ + HotcuePoint.Type.OneShot, + HotcuePoint.Type.Loop + ] + + x: position * (Window.width - 16) + width: 21 + + // One shot + Shape { + visible: type == HotcuePoint.Type.OneShot + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: color + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 0; startY: 0 + + PathLine { x: 12; y: 0 } + PathLine { x: 18; y: 6 } + PathLine { x: 18; y: 7 } + PathLine { x: 12; y: 13 } + PathLine { x: 2; y: 13 } + PathLine { x: 2; y: 80 } + PathLine { x: 0; y: 80 } + PathLine { x: 0; y: 0 } + } + } + + // Intro/Outro entry marker + Shape { + visible: type == HotcuePoint.Type.IntroIn || type == HotcuePoint.Type.OutroIn + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: "#6e6e6e" + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 0; startY: 0 + + PathLine { x: 11; y: 0 } + PathLine { x: 2; y: 13 } + PathLine { x: 2; y: 80 } + PathLine { x: 0; y: 80 } + PathLine { x: 0; y: 0 } + } + } + + // Intro/Outro exit marker + Shape { + visible: type == HotcuePoint.Type.IntroOut || type == HotcuePoint.Type.OutroOut + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: "#6e6e6e" + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 2; startY: 0 + + PathLine { x: 0; y: 0 } + PathLine { x: 0; y: 67 } + PathLine { x: -9; y: 80 } + PathLine { x: 2; y: 80 } + PathLine { x: 2; y: 0 } + } + } + + // Loop + Shape { + visible: type == HotcuePoint.Type.Loop + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: "#6ef36e" + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 13; startY: 0 + + PathArc { + x: 2; y: 13 + radiusX: 9; radiusY: 9 + direction: PathArc.Clockwise + } + PathLine { x: 2; y: 80 } + PathLine { x: 0; y: 80 } + PathLine { x: 0; y: 0 } + PathLine { x: 21; y: 0 } + } + } + + // Loop in + Shape { + visible: type == HotcuePoint.Type.LoopIn + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: "#6ef36e" + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 0; startY: 0 + + PathLine { x: 8; y: 0 } + PathLine { x: 2; y: 10 } + PathLine { x: 2; y: 80 } + PathLine { x: 0; y: 80 } + PathLine { x: 0; y: 0 } + } + } + + // Loop out + Shape { + visible: type == HotcuePoint.Type.LoopOut + anchors.fill: parent + antialiasing: true + + ShapePath { + strokeWidth: 1 + strokeColor: Qt.rgba(0, 0, 0, 0.5) + fillColor: "#6ef36e" + strokeStyle: ShapePath.SolidLine + // dashPattern: [ 1, 4 ] + startX: 2; startY: 0 + + PathLine { x: -6; y: 0 } + PathLine { x: 0; y: 10 } + PathLine { x: 0; y: 80 } + PathLine { x: 2; y: 80 } + PathLine { x: 2; y: 0 } + } + } + + Shape { + visible: type in typeWithNumber + anchors.fill: parent + antialiasing: true + + ShapePath { + fillColor: "black" + strokeColor: "black" + PathText { + x: 4 + y: 3 + font.family: "Arial" + font.pixelSize: 11 + font.weight: Font.Medium + text: `${number}` + } + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/KeyIndicator.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/KeyIndicator.qml new file mode 100755 index 00000000000..5d2c1b4c582 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/KeyIndicator.qml @@ -0,0 +1,127 @@ +/* +This module is used to define the top left section, right under the label. +Currently this section is dedicated to key/pitch information. +*/ +import QtQuick 2.14 +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Rectangle { + id: root + + required property string group + + enum Key { + NoKey, + OneD, + EightD, + ThreeD, + TenD, + FiveD, + TwelveD, + SevenD, + SecondD, + NineD, + FourD, + ElevenD, + SixD, + TenM, + FiveM, + TwelveM, + SevenM, + TwoM, + NineM, + FourM, + ElevenM, + SixM, + OneM, + EightM, + ThreeM + } + + property variant colorsMap: [ + "#b09840", // No key + "#b960a2",// 1d + "#9fc516", // 8d + "#527fc0", // 3d + "#f28b2e", // 10d + "#5bc1cf", // 5d + "#e84c4d", // 12d + "#73b629", // 7d + "#8269ab", // 2d + "#fdd615", // 9d + "#3cc0f0", // 4d + "#4cb686", // 11d + "#4cb686", // 6d + "#f5a158", // 10m + "#7bcdd9", // 5m + "#ed7171", // 12m + "#8fc555", // 7m + "#9b86be", // 2m + "#fcdf45", // 9m + "#63cdf4", // 4m + "#f1845f", // 11m + "#70c4a0", // 6m + "#c680b6", // 1m + "#b2d145", // 8m + "#7499cd" // 3m + ] + + property variant textMap: [ + "No key", + "1d", + "8d", + "3d", + "10d", + "5d", + "12d", + "7d", + "2d", + "9d", + "4d", + "11d", + "6d", + "10m", + "5m", + "12m", + "7m", + "2m", + "9m", + "4m", + "11m", + "6m", + "1m", + "8m", + "3m" + ] + + required property color borderColor + + property int key: KeyIndicator.Key.NoKey + + radius: 6 + border.color: colorsMap[key] + border.width: 2 + + color: colorsMap[key] + signal updated + + Mixxx.ControlProxy { + group: root.group + key: "key" + onValueChanged: (value) => { + if (value === root.key) return; + root.key = value; + root.updated() + } + } + + Text { + text: textMap[key] + font.pixelSize: 17 + color: fontColor + anchors.centerIn: parent + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Keyboard.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Keyboard.qml new file mode 100644 index 00000000000..2e3b87e453d --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Keyboard.qml @@ -0,0 +1,140 @@ +/* +This module is used render the keyboard scale, originating from C major (do). +*/ +import QtQuick 2.15 +import QtQuick.Shapes 1.4 +import QtQuick.Layouts 1.3 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +import "." as S4MK3 + +Item { + id: root + + required property string group + + property int key: -1 + + signal updated + + Mixxx.ControlProxy { + group: root.group + key: "key" + onValueChanged: (value) => { + if (value === root.key) return; + root.key = value; + root.updated() + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Rectangle { anchors.fill: parent; color: "transparent" } + } + Repeater { + id: whiteKeys + + model: 7 + + property variant keyMap: [ + S4MK3.KeyIndicator.Key.OneD, + S4MK3.KeyIndicator.Key.ThreeD, + S4MK3.KeyIndicator.Key.FiveD, + S4MK3.KeyIndicator.Key.TwelveD, + S4MK3.KeyIndicator.Key.SecondD, + S4MK3.KeyIndicator.Key.FourD, + S4MK3.KeyIndicator.Key.SixD, + S4MK3.KeyIndicator.Key.TenM, + S4MK3.KeyIndicator.Key.TwelveM, + S4MK3.KeyIndicator.Key.TwoM, + S4MK3.KeyIndicator.Key.NineM, + S4MK3.KeyIndicator.Key.ElevenM, + S4MK3.KeyIndicator.Key.OneM, + S4MK3.KeyIndicator.Key.ThreeM + ] + + Rectangle { + Layout.preferredWidth: 21 + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + radius: 2 + border.width: 1 + border.color: root.key == whiteKeys.keyMap[index] || root.key == whiteKeys.keyMap[index + 7] ? "red" : "black" + color: root.key == whiteKeys.keyMap[index] || root.key == whiteKeys.keyMap[index + 7] ? "#aaaaaa" : "white" + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Rectangle { anchors.fill: parent; color: "transparent" } + } + } + RowLayout { + anchors.fill: parent + spacing: 0 + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Rectangle { anchors.fill: parent; color: "transparent" } + } + Repeater { + id: blackKeys + + model: 5 + + property variant keyMap: [ + S4MK3.KeyIndicator.Key.EightD, + S4MK3.KeyIndicator.Key.TenD, + S4MK3.KeyIndicator.Key.SevenD, + S4MK3.KeyIndicator.Key.NineD, + S4MK3.KeyIndicator.Key.ElevenD, + S4MK3.KeyIndicator.Key.FiveM, + S4MK3.KeyIndicator.Key.SevenM, + S4MK3.KeyIndicator.Key.FourM, + S4MK3.KeyIndicator.Key.SixM, + S4MK3.KeyIndicator.Key.EightM, + ] + + Item { + Layout.fillHeight: true + Layout.preferredWidth: index == 1 ? 42 : index == 4 ? 12 : 21 + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 12 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + color: "transparent" + ColumnLayout { + anchors.fill: parent + spacing: 0 + Rectangle { + Layout.fillHeight: true + Layout.fillWidth: true + radius: 2 + border.width: 1 + // border.color: root.key == blackKeys.keyMap[index] || root.key == blackKeys.keyMap[index + blackKeys.model] ? "red" : "black" + color: root.key == blackKeys.keyMap[index] || root.key == blackKeys.keyMap[index + blackKeys.model] ? "#aaaaaa" : "black" + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Rectangle { anchors.fill: parent; color: "transparent" } + } + } + } + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Rectangle { anchors.fill: parent; color: "transparent" } + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/LoopSizeIndicator.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/LoopSizeIndicator.qml new file mode 100755 index 00000000000..7f0bdec7d01 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/LoopSizeIndicator.qml @@ -0,0 +1,75 @@ +/* +This module is used to define the center right section, above the waveform. +Currently this section is dedicated to display loop state information such as loop state, anchor mode or size. +*/ +import QtQuick 2.14 +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Rectangle { + id: root + + required property string group + + property color loopReverseOffBoxColor: Qt.rgba(255/255,113/255,9/255, 1) + property color loopOffBoxColor: Qt.rgba(67/255,70/255,66/255, 1) + property color loopOffFontColor: "white" + property color loopOnBoxColor: Qt.rgba(125/255,246/255,64/255, 1) + property color loopOnFontColor: "black" + + property bool on: true + signal updated + + radius: 6 + border.width: 2 + border.color: (loopSizeIndicator.on ? loopOnBoxColor : (loop_anchor.value == 0 ? loopOffBoxColor : loopReverseOffBoxColor)) + color: (loopSizeIndicator.on ? loopOnBoxColor : (loop_anchor.value == 0 ? loopOffBoxColor : loopReverseOffBoxColor)) + + Text { + id: indicator + anchors.centerIn: parent + font.pixelSize: 46 + color: (loopSizeIndicator.on ? loopOnFontColor : loopOffFontColor) + + Mixxx.ControlProxy { + group: root.group + key: "beatloop_size" + onValueChanged: (value) => { + const newValue = (value < 1 ? `1/${1 / value}` : `${value}`); + if (newValue === indicator.text) return; + indicator.text = newValue; + root.updated() + } + } + } + + Mixxx.ControlProxy { + group: root.group + key: "loop_enabled" + onValueChanged: (value) => { + if (value === root.on) return; + root.on = value; + root.updated() + } + } + + Mixxx.ControlProxy { + group: root.group + key: "loop_anchor" + id: loop_anchor + onValueChanged: (value) => { + root.updated() + } + } + + states: State { + name: "compacted" + + PropertyChanges { + target: indicator + font.pixelSize: 17 + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/OnAirTrack.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/OnAirTrack.qml new file mode 100644 index 00000000000..0d034066a6e --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/OnAirTrack.qml @@ -0,0 +1,81 @@ +/* +This module is used to define the top section o the screen. +Currently this section is dedicated to display title and artist of the track loaded on the deck. +*/ +import QtQuick 2.14 +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Item { + id: root + + required property string group + property var deckPlayer: Mixxx.PlayerManager.getPlayer(root.group) + property bool scrolling: true + + property real speed: 1.7 + property real spacing: 30 + + Rectangle { + id: frame + anchors.top: root.top + anchors.bottom: root.bottom + width: parent.width + x: 6 + color: 'transparent' + + readonly property string fulltext: !trackLoadedControl.value || root.deckPlayer.title.trim().length + root.deckPlayer.artist.trim().length == 0 ? qsTr("No Track Loaded") : `${root.deckPlayer.title} - ${root.deckPlayer.artist}`.trim() + + Text { + id: text1 + text: frame.fulltext + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + Text { + id: text2 + visible: root.width < text1.implicitWidth + anchors.left: text1.right + anchors.leftMargin: spacing + text: frame.fulltext + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + + Mixxx.ControlProxy { + id: trackLoadedControl + + group: root.group + key: "track_loaded" + } + + Timer { + id: timer + + property int modifier: -root.speed + + repeat: true + interval: 15 + running: root.width < text1.implicitWidth && root.scrolling + + onTriggered: { + frame.x += modifier; + if (frame.x <= -text1.implicitWidth - spacing) { + frame.x = 0; + } + } + + onRunningChanged: { + if (!running) { + frame.x = 6; + } + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Progression.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Progression.qml new file mode 100755 index 00000000000..82d8f8b3f89 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/Progression.qml @@ -0,0 +1,53 @@ +/* +This module is used to draw an overlay on the waveform overview in order to highlight better the playback progression. +As the native Mixxx QML component involves, this component might become redundant and should be replaces with native modules. +*/ +import QtQuick 2.15 +import QtQuick.Window 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Item { + id: root + + required property string group + + property real windowWidth: Window.width + + width: 0 + signal updated + + Mixxx.ControlProxy { + group: root.group + key: "track_loaded" + onValueChanged: (value) => { + if (value === root.visible) return; + root.visible = value + root.updated() + } + } + + Mixxx.ControlProxy { + group: root.group + key: "playposition" + onValueChanged: (value) => { + const newValue = Math.round(value * (320 - 12)); + if (newValue === root.width) return; + root.width = newValue; + root.updated() + } + } + + clip: true + + Rectangle { + anchors.fill: parent + anchors.leftMargin: -border.width + anchors.topMargin: -border.width + anchors.bottomMargin: -border.width + border.width: 2 + border.color:"black" + color: Qt.rgba(0.39, 0.80, 0.96, 0.3) + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/SplashOff.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/SplashOff.qml new file mode 100644 index 00000000000..a3937436c79 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/SplashOff.qml @@ -0,0 +1,15 @@ +import QtQuick 2.15 + +Rectangle { + id: root + anchors.fill: parent + color: "black" + + Image { + anchors.centerIn: parent + width: root.width*0.8 + height: root.height + fillMode: Image.PreserveAspectFit + source: engine.getSetting("idleBackground") == "mask" ? "./Screens/Images/logo.png" : "../../../images/templates/logo_mixxx.png" + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/StockScreen.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/StockScreen.qml new file mode 100644 index 00000000000..6833d6c8740 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/StockScreen.qml @@ -0,0 +1,498 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.3 + +import "../../../qml" as Skin +import Mixxx 1.0 as Mixxx + +import S4MK3 as S4MK3 + +Rectangle { + id: root + + required property string group + required property string screenId + + readonly property bool useSharedApi: engine.getSetting("useSharedDataAPI") + + anchors.fill: parent + color: "black" + + function onSharedDataUpdate(data) { + if (!root) return; + + console.log(`Received data on screen#${root.screenId} while currently bind to ${root.group}: ${JSON.stringify(data)}`); + if (typeof data === "object" && typeof data.group[root.screenId] === "string" && root.group !== data.group[root.screenId]) { + root.group = data.group[root.screenId] + waveformOverview.player = Mixxx.PlayerManager.getPlayer(root.group) + artwork.player = Mixxx.PlayerManager.getPlayer(root.group) + console.log(`Changed group for screen ${root.screenId} to ${root.group}`); + } + var shouldBeCompacted = false; + if (typeof data.padsMode === "object") { + scrollingWaveform.visible = data.padsMode[root.group] === 4 + artworkSpacer.visible = data.padsMode[root.group] === 1 + shouldBeCompacted |= scrollingWaveform.visible || artworkSpacer.visible + } + if (typeof data.keyboardMode === "object") { + shouldBeCompacted |= data.keyboardMode[root.group] + keyboard.visible = !!data.keyboardMode[root.group] + } + deckInfo.state = shouldBeCompacted ? "compacted" : "" + if (typeof data.displayBeatloopSize === "object") { + timeIndicator.mode = data.displayBeatloopSize[root.group] ? S4MK3.TimeAndBeatloopIndicator.Mode.BeetjumpSize : S4MK3.TimeAndBeatloopIndicator.Mode.RemainingTime + timeIndicator.update() + } + } + + Mixxx.ControlProxy { + id: trackLoadedControl + + group: root.group + key: "track_loaded" + + onValueChanged: (value) => { + if (!value && deckInfo) { + deckInfo.state = "" + scrollingWaveform.visible = false + } + } + } + + Timer { + id: channelchange + + interval: 5000 + repeat: true + running: false + + onTriggered: { + root.onSharedDataUpdate({ + group: { + "leftdeck": screenId === "leftdeck" && trackLoadedControl.group === "[Channel1]" ? "[Channel3]" : "[Channel1]", + "rightdeck": screenId === "rightdeck" && trackLoadedControl.group === "[Channel2]" ? "[Channel4]" : "[Channel2]", + }, + scrollingWaveform: { + "[Channel1]": true, + "[Channel2]": true, + "[Channel3]": true, + "[Channel4]": true, + }, + keyboardMode: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + displayBeatloopSize: { + "[Channel1]": false, + "[Channel2]": false, + "[Channel3]": false, + "[Channel4]": false, + }, + }); + } + } + + Component.onCompleted: { + if (!root.useSharedApi) { + return; + } + + engine.makeSharedDataConnection(root.onSharedDataUpdate) + + root.onSharedDataUpdate({ + group: { + "leftdeck": "[Channel1]", + "rightdeck": "[Channel2]", + }, + scrollingWaveform: { + "[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, + }, + }); + } + + Rectangle { + anchors.fill: parent + color: "transparent" + + Image { + id: artwork + anchors.fill: parent + + property var player: Mixxx.PlayerManager.getPlayer(root.group) + + source: player.coverArtUrl + height: 100 + width: 100 + fillMode: Image.PreserveAspectFit + + opacity: artworkSpacer.visible ? 1 : 0.2 + z: -1 + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 1 + + S4MK3.OnAirTrack { + id: onAir + group: root.group + Layout.fillWidth: true + Layout.fillHeight: true + + scrolling: !scrollingWaveform.visible + } + } + } + + // Indicator + Rectangle { + id: deckInfo + + Layout.fillWidth: true + Layout.preferredHeight: 105 + Layout.leftMargin: 6 + Layout.rightMargin: 6 + color: "transparent" + + GridLayout { + id: gridLayout + anchors.fill: parent + columnSpacing: 6 + rowSpacing: 6 + columns: 2 + + // Section: Key + S4MK3.KeyIndicator { + id: keyIndicator + group: root.group + borderColor: smallBoxBorder + + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Section: Bpm + S4MK3.BPMIndicator { + id: bpmIndicator + group: root.group + borderColor: smallBoxBorder + + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Section: Key + S4MK3.TimeAndBeatloopIndicator { + id: timeIndicator + group: root.group + + Layout.fillWidth: true + Layout.preferredHeight: 72 + timeColor: smallBoxBorder + } + + // Section: Bpm + S4MK3.LoopSizeIndicator { + id: loopSizeIndicator + group: root.group + + Layout.fillWidth: true + Layout.preferredHeight: 72 + } + } + states: State { + name: "compacted" + + PropertyChanges { + target:deckInfo + Layout.preferredHeight: 28 + } + PropertyChanges { + target: gridLayout + columns: 4 + } + PropertyChanges { + target: bpmIndicator + state: "compacted" + } + PropertyChanges { + target: timeIndicator + Layout.preferredHeight: -1 + Layout.fillHeight: true + state: "compacted" + } + PropertyChanges { + target: loopSizeIndicator + Layout.preferredHeight: -1 + Layout.fillHeight: true + state: "compacted" + } + } + } + + Item { + id: scrollingWaveform + + Layout.fillWidth: true + Layout.minimumHeight: scrollingWaveform.visible ? 120 : 0 + Layout.leftMargin: 0 + Layout.rightMargin: 0 + + visible: false + + Skin.WaveformRow { + group: root.group + x: 0 + width: 320 + height: 100 + zoomControlRatio: 200 + } + } + + Mixxx.ControlProxy { + id: deckScratching + + group: root.group + key: "scratch2_enable" + + onValueChanged: { + if (root.useSharedApi) { + return; + } + + if (value) { + waveformTimer.running = false; + scrollingWaveform.visible = true; + deckInfo.state = scrollingWaveform.visible ? "compacted" : "" + } else { + waveformTimer.running = true; + waveformTimer.restart() + } + } + } + + Timer { + id: waveformTimer + + interval: 4000 + repeat: false + running: false + + onTriggered: { + scrollingWaveform.visible = false; + deckInfo.state = scrollingWaveform.visible ? "compacted" : "" + } + } + + // Spacer + Item { + id: artworkSpacer + + Layout.fillWidth: true + Layout.minimumHeight: artworkSpacer.visible ? 120 : 0 + Layout.leftMargin: 6 + Layout.rightMargin: 6 + + visible: false + + Rectangle { + color: "transparent" + visible: parent.visible + anchors.top: parent.top + anchors.bottom: parent.bottom + x: 153 + width: 2 + } + } + + // Track progress + Item { + id: waveform + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 6 + Layout.rightMargin: 6 + layer.enabled: true + + S4MK3.Progression { + id: progression + group: root.group + + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + } + + Mixxx.WaveformOverview { + id: waveformOverview + anchors.fill: parent + player: Mixxx.PlayerManager.getPlayer(root.group) + } + + Mixxx.ControlProxy { + id: samplesControl + + group: root.group + key: "track_samples" + } + + // Hotcue + Repeater { + model: 16 + + S4MK3.HotcuePoint { + required property int index + + Mixxx.ControlProxy { + id: samplesControl + + group: root.group + key: "track_samples" + } + + Mixxx.ControlProxy { + id: hotcueEnabled + group: root.group + key: `hotcue_${index + 1}_status` + } + + Mixxx.ControlProxy { + id: hotcuePosition + group: root.group + key: `hotcue_${index + 1}_position` + } + + Mixxx.ControlProxy { + id: hotcueColor + group: root.group + key: `hotcue_${number}_color` + } + + anchors.top: parent.top + // anchors.left: parent.left + anchors.bottom: parent.bottom + visible: hotcueEnabled.value + + number: this.index + 1 + type: S4MK3.HotcuePoint.Type.OneShot + position: hotcuePosition.value / samplesControl.value + color: `#${(hotcueColor.value >> 16).toString(16).padStart(2, '0')}${((hotcueColor.value >> 8) & 255).toString(16).padStart(2, '0')}${(hotcueColor.value & 255).toString(16).padStart(2, '0')}` + } + } + + // Intro + S4MK3.HotcuePoint { + + Mixxx.ControlProxy { + id: introStartEnabled + group: root.group + key: `intro_start_enabled` + } + + Mixxx.ControlProxy { + id: introStartPosition + group: root.group + key: `intro_start_position` + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + visible: introStartEnabled.value + + type: S4MK3.HotcuePoint.Type.IntroIn + position: introStartPosition.value / samplesControl.value + } + + // Extro + S4MK3.HotcuePoint { + + Mixxx.ControlProxy { + id: introEndEnabled + group: root.group + key: `intro_end_enabled` + } + + Mixxx.ControlProxy { + id: introEndPosition + group: root.group + key: `intro_end_position` + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + visible: introEndEnabled.value + + type: S4MK3.HotcuePoint.Type.IntroOut + position: introEndPosition.value / samplesControl.value + } + + // Loop in + S4MK3.HotcuePoint { + Mixxx.ControlProxy { + id: loopStartPosition + group: root.group + key: `loop_start_position` + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + visible: loopStartPosition.value > 0 + + type: S4MK3.HotcuePoint.Type.LoopIn + position: loopStartPosition.value / samplesControl.value + } + + // Loop out + S4MK3.HotcuePoint { + Mixxx.ControlProxy { + id: loopEndPosition + group: root.group + key: `loop_end_position` + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + visible: loopEndPosition.value > 0 + + type: S4MK3.HotcuePoint.Type.LoopOut + position: loopEndPosition.value / samplesControl.value + } + } + + S4MK3.Keyboard { + id: keyboard + group: root.group + visible: false + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 6 + Layout.rightMargin: 6 + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/TimeAndBeatloopIndicator.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/TimeAndBeatloopIndicator.qml new file mode 100755 index 00000000000..26302f4073c --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/TimeAndBeatloopIndicator.qml @@ -0,0 +1,107 @@ +/* +This module is used to define the center left section, above the waveform. +Currently this section is dedicated to show the remaining time as well as the beatloop when changing. +*/ +import QtQuick 2.14 +import QtQuick.Controls 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Rectangle { + id: root + + required property string group + + property color timeColor: Qt.rgba(67/255,70/255,66/255, 1) + property color beatjumpColor: 'yellow' + + enum Mode { + RemainingTime, + BeetjumpSize + } + + property int mode: TimeAndBeatloopIndicator.Mode.RemainingTime + + radius: 6 + border.color: timeColor + border.width: 2 + color: timeColor + signal updated + + function update() { + let newValue = ""; + if (root.mode === TimeAndBeatloopIndicator.Mode.RemainingTime) { + var seconds = ((1.0 - progression.value) * duration.value); + var mins = parseInt(seconds / 60).toString(); + seconds = parseInt(seconds % 60).toString(); + + newValue = `-${mins.padStart(2, '0')}:${seconds.padStart(2, '0')}`; + } else { + newValue = (beatjump.value < 1 ? `1/${1 / beatjump.value}` : `${beatjump.value}`); + } + if (newValue === indicator.text) return; + indicator.text = newValue; + root.updated() + } + + Text { + id: indicator + anchors.centerIn: parent + text: "0.00" + + font.pixelSize: 46 + color: fontColor + + Mixxx.ControlProxy { + id: progression + group: root.group + key: "playposition" + } + + Mixxx.ControlProxy { + id: duration + group: root.group + key: "duration" + } + + Mixxx.ControlProxy { + id: beatjump + group: root.group + key: "beatjump_size" + } + + Mixxx.ControlProxy { + id: endoftrack + group: root.group + key: "end_of_track" + onValueChanged: (value) => { + root.border.color = value ? 'red' : timeColor + root.color = value ? 'red' : timeColor + root.updated() + } + } + } + + Component.onCompleted: { + progression.onValueChanged.connect(update) + duration.onValueChanged.connect(update) + beatjump.onValueChanged.connect(update) + update() + } + + states: State { + name: "compacted" + + PropertyChanges { + target: indicator + font.pixelSize: 17 + } + } + + onModeChanged: () => { + border.color = root.mode == TimeAndBeatloopIndicator.Mode.BeetjumpSize ? beatjumpColor : timeColor + color = root.mode == TimeAndBeatloopIndicator.Mode.BeetjumpSize ? beatjumpColor : timeColor + indicator.color = root.mode == TimeAndBeatloopIndicator.Mode.BeetjumpSize ? 'black' : 'white' + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/WaveformOverview.qml b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/WaveformOverview.qml new file mode 100755 index 00000000000..ade89743521 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/WaveformOverview.qml @@ -0,0 +1,165 @@ +/* +This module is used to waveform overview, at the bottom of the screen. It is reusing component definition of `WaveformOverview.qml` but remove +the link to markers and provide hooks with screen update/redraw, needed for partial updates. +Currently this section is dedicated to BPM and tempo fader information. +*/ +import QtQuick 2.15 +import QtQuick.Window 2.15 + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +Item { + id: root + + required property string group + property var deckPlayer: Mixxx.PlayerManager.getPlayer(root.group) + property real scale: 0.2 + + signal updated + + visible: false + antialiasing: true + anchors.fill: parent + + Connections { + onGroupChanged: { + deckPlayer = Mixxx.PlayerManager.getPlayer(root.group) + console.log("Group changed!!") + root.updated() + } + } + + Rectangle { + color: "white" + anchors.top: parent.top + anchors.bottom: parent.bottom + x: 153 + width: 2 + } + Item { + id: waveformContainer + + property real duration: samplesControl.value / sampleRateControl.value + + anchors.fill: parent + clip: true + + Mixxx.ControlProxy { + id: samplesControl + + group: root.group + key: "track_samples" + } + + Mixxx.ControlProxy { + id: sampleRateControl + + group: root.group + key: "track_samplerate" + } + + Mixxx.ControlProxy { + id: playPositionControl + + group: root.group + key: "playposition" + } + + Mixxx.ControlProxy { + id: rateRatioControl + + group: root.group + key: "rate_ratio" + } + + Mixxx.ControlProxy { + id: zoomControl + + group: root.group + key: "waveform_zoom" + } + + Item { + id: waveformBeat + + property real effectiveZoomFactor: (zoomControl.value * rateRatioControl.value / root.scale) * 6 + + width: waveformContainer.duration * effectiveZoomFactor + height: parent.height + x: 0.5 * waveformContainer.width - playPositionControl.value * width + visible: true + + Shape { + id: preroll + + property real triangleHeight: waveformBeat.height + property real triangleWidth: 0.25 * waveformBeat.effectiveZoomFactor + property int numTriangles: Math.ceil(width / triangleWidth) + + anchors.top: waveformBeat.top + anchors.right: waveformBeat.left + width: Math.max(0, waveformBeat.x) + height: waveformBeat.height + + ShapePath { + strokeColor: 'red' + strokeWidth: 1 + fillColor: "transparent" + + PathMultiline { + paths: { + let p = []; + for (let i = 0; i < preroll.numTriangles; i++) { + p.push([Qt.point(preroll.width - i * preroll.triangleWidth, preroll.triangleHeight / 2), Qt.point(preroll.width - (i + 1) * preroll.triangleWidth, 0), Qt.point(preroll.width - (i + 1) * preroll.triangleWidth, preroll.triangleHeight), Qt.point(preroll.width - i * preroll.triangleWidth, preroll.triangleHeight / 2)]); + } + return p; + } + } + } + } + + Shape { + id: postroll + + property real triangleHeight: waveformBeat.height + property real triangleWidth: 0.25 * waveformBeat.effectiveZoomFactor + property int numTriangles: Math.ceil(width / triangleWidth) + + anchors.top: waveformBeat.top + anchors.left: waveformBeat.right + width: waveformContainer.width / 2 + height: waveformBeat.height + + ShapePath { + strokeColor: 'red' + strokeWidth: 1 + fillColor: "transparent" + + PathMultiline { + paths: { + let p = []; + for (let i = 0; i < postroll.numTriangles; i++) { + p.push([Qt.point(i * postroll.triangleWidth, postroll.triangleHeight / 2), Qt.point((i + 1) * postroll.triangleWidth, 0), Qt.point((i + 1) * postroll.triangleWidth, postroll.triangleHeight), Qt.point(i * postroll.triangleWidth, postroll.triangleHeight / 2)]); + } + return p; + } + } + } + } + } + + MixxxControls.WaveformOverview { + id: waveformOverview + // property real duration: samplesControl.value / sampleRateControl.onValueChanged + + player: root.player + anchors.fill: parent + channels: Mixxx.WaveformOverview.Channels.BothChannels + renderer: Mixxx.WaveformOverview.Renderer.RGB + colorHigh: 'white' + colorMid: 'blue' + colorLow: 'green' + } + } +} diff --git a/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/qmldir b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/qmldir new file mode 100644 index 00000000000..6c76347ed73 --- /dev/null +++ b/res/controllers/TraktorKontrolS4MK3Screens/S4MK3/qmldir @@ -0,0 +1,12 @@ +module S4MK3 +BPMIndicator 1.0 BPMIndicator.qml +HotcuePoint 1.0 HotcuePoint.qml +Keyboard 1.0 Keyboard.qml +KeyIndicator 1.0 KeyIndicator.qml +LoopSizeIndicator 1.0 LoopSizeIndicator.qml +OnAirTrack 1.0 OnAirTrack.qml +Progression 1.0 Progression.qml +TimeAndBeatloopIndicator 1.0 TimeAndBeatloopIndicator.qml +WaveformOverview 1.0 WaveformOverview.qml +SplashOff 1.0 SplashOff.qml +StockScreen 1.0 StockScreen.qml diff --git a/res/qml/WaveformRow.qml b/res/qml/WaveformRow.qml index be129fc42bf..82a8eb43d14 100644 --- a/res/qml/WaveformRow.qml +++ b/res/qml/WaveformRow.qml @@ -15,6 +15,8 @@ Item { property string group // required property var deckPlayer: Mixxx.PlayerManager.getPlayer(group) + property int zoomControlRatio: 100 + property alias shader: shader Item { id: waveformContainer @@ -118,7 +120,7 @@ Item { Item { id: waveform - property real effectiveZoomFactor: (1 / rateRatioControl.value) * (100 / zoomControl.value) + property real effectiveZoomFactor: (1 / rateRatioControl.value) * (root.zoomControlRatio / zoomControl.value) width: waveformContainer.duration * effectiveZoomFactor height: parent.height @@ -126,6 +128,7 @@ Item { visible: root.deckPlayer.isLoaded WaveformShader { + id: shader group: root.group anchors.fill: parent } diff --git a/src/controllers/bulk/bulksupported.h b/src/controllers/bulk/bulksupported.h index a7bf6c916d6..f10673e87ba 100644 --- a/src/controllers/bulk/bulksupported.h +++ b/src/controllers/bulk/bulksupported.h @@ -28,4 +28,5 @@ constexpr static bulk_support_lookup bulk_supported[] = { {{0x06f8, 0xb107}, {0x83, 0x03, std::nullopt}}, // Hercules Mk4 {{0x06f8, 0xb100}, {0x86, 0x06, std::nullopt}}, // Hercules Mk2 {{0x06f8, 0xb120}, {0x82, 0x03, std::nullopt}}, // Hercules MP3 LE / Glow + {{0x17cc, 0x1720}, {0x00, 0x03, 0x04}}, // Traktor NI S4 Mk3 }; diff --git a/tools/README b/tools/README index def2a3d4040..3055fbed113 100644 --- a/tools/README +++ b/tools/README @@ -20,3 +20,11 @@ cd build && gcc ../tools/dummy_hid_device.c -lhidapi-hidraw -o dummy_hid_device # Allow the created hidraw device to be accessed by the user. You may also set the write udev rules. Finally, you can also run Mixxx as root, but that's not recommended. sudo chown "$USER" "$(ls -1t /dev/hidraw* | head -n 1)" ``` + +## Traktor S4 Mk3 Screen drawing + +This small program can be used directly to draw arbitrary rectangles on the Traktor S4 Mk3 screens. It may also be useful for one to perform tests on top of the existing reversed engineered protocol. + +```sh +cd build && gcc ../tools/traktor_s4_mk3_screen_test.c `pkg-config --cflags --libs libusb-1.0` -o traktor_s4_mk3_screen_test && ./traktor_s4_mk3_screen_test +``` diff --git a/tools/clang_format.py b/tools/clang_format.py index 16076470c91..baeface76d2 100755 --- a/tools/clang_format.py +++ b/tools/clang_format.py @@ -49,7 +49,7 @@ def run_clang_format_on_lines(rootdir, file_to_format, stylepath=None): ", ".join("{}-{}".format(*x) for x in file_to_format.lines), ) - filename = os.path.join(rootdir, file_to_format.filename) + filename = os.path.join(rootdir, file_to_format.filename).strip() cmd = [ "clang-format", "--style=file", diff --git a/tools/traktor_s4_mk3_screen_test.c b/tools/traktor_s4_mk3_screen_test.c new file mode 100644 index 00000000000..cf14fd3833a --- /dev/null +++ b/tools/traktor_s4_mk3_screen_test.c @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#define VENDOR_ID 0x17cc +#define PRODUCT_ID 0x1720 +#define IN_EPADDR 0x00 +#define OUT_EPADDR 0x03 + +static const uint8_t header_data[] = { + 0x84, 0x0, 0x0, 0x21, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, // Draw offset (y=0, x=0) + 0x1, + 0x40, + 0x0, + 0xf0, // Draw dimenssion (width=320, height=240) +}; +static const uint8_t footer_data[] = { + 0x40, 0x00, 0x00, 0x00}; + +int main(int argc, char** argv) { + libusb_context* context; + int transferred; + + if (argc != 7) { + fprintf(stderr, "Usage: %s \n", *argv); + return -1; + } + + libusb_init(&context); + + libusb_device_handle* handle = libusb_open_device_with_vid_pid( + context, VENDOR_ID, PRODUCT_ID); + + if (!handle) { + fprintf(stderr, "Unable to open USB Bulk device\n"); + return -1; + } + + uint8_t screen_idx = atoi(argv[1]); + + if (screen_idx != 0 && screen_idx != 1) { + fprintf(stderr, "Invalid screen ID %d\n", screen_idx); + return -1; + } + + uint16_t x = atoi(argv[2]); + uint16_t y = atoi(argv[3]); + uint16_t width = atoi(argv[4]); + uint16_t height = atoi(argv[5]); + uint16_t color = strtol(argv[6], NULL, 2); + + uint8_t* data = malloc(width * height * sizeof(uint16_t) + + sizeof(header_data) + sizeof(footer_data)); + uint8_t* header = data; + + memcpy(header, header_data, sizeof(header_data)); + + header[2] = screen_idx; + + header[8] = x >> 8; + header[9] = x & 0xff; + header[10] = y >> 8; + header[11] = y & 0xff; + + header[12] = width >> 8; + header[13] = width & 0xff; + header[14] = height >> 8; + header[15] = height & 0xff; + + printf("draw x=%d,y=%d,width=%d,height=%d with color %x\n", x, y, width, height, color); + + size_t payload_size = width * height * sizeof(uint16_t) + + sizeof(header_data) + sizeof(footer_data); + uint8_t* payload = data + sizeof(header_data); + uint8_t* footer = payload + width * height * sizeof(uint16_t); + + for (int px = 0; px < width * height; px++) { + payload[px * sizeof(uint16_t)] = color >> 8; + payload[px * sizeof(uint16_t) + 1] = color & 0xff; + } + + memcpy(footer, footer_data, sizeof(footer_data)); + + footer[2] = screen_idx; + + clock_t start, end; + double cpu_time_used; + + start = clock(); + int ret = libusb_bulk_transfer(handle, OUT_EPADDR, data, payload_size, &transferred, 0); + end = clock(); + cpu_time_used = ((double)(end - start)) / CLOCKS_PER_SEC; + + if (ret < 0) { + fprintf(stderr, "Unable to send to USB Bulk device\n"); + + } else { + fprintf(stderr, "Sent %d bytes in %f ms\n", transferred, cpu_time_used); + } + + libusb_close(handle); + libusb_exit(context); + + return 0; +}