Last active
May 4, 2026 04:47
-
-
Save mxmilkiib/26525b2da8124bf06d13c8afbf366688 to your computer and use it in GitHub Desktop.
AI_RULES.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Akai MPD218 Controller Script for Mixxx | |
| * Author: Milkii | |
| * Description: Robust, clean implementation of MPD218 controller with simplified architecture | |
| * | |
| * this program is free software; you can redistribute it and/or | |
| * modify it under the terms of the gnu general public license | |
| * as published by the free software foundation; either version 2 | |
| * of the license, or (at your option) any later version. | |
| */ | |
| // Main controller object - MUST be global for Mixxx to find it | |
| var MPD218 = {}; | |
| // MARK: CONFIGURATION | |
| // helper to access debug setting | |
| MPD218.isDebugEnabled = function() { | |
| return MPD218.Config && MPD218.Config.system && MPD218.Config.system.debugEnabled; | |
| }; | |
| // announce script loading | |
| console.log("ποΈ LOADING AKAI MPD218 CONTROLLER SCRIPT (Robust Rewrite)"); | |
| console.log("β MPD218 SCRIPT LOADED - Clean implementation ready!"); | |
| console.log("π Script loaded at: " + new Date().toLocaleString()); | |
| /* | |
| CLEAN ARCHITECTURE OVERVIEW: | |
| - ControllerState: centralized state management | |
| - MIDIConstants: all MIDI-related constants | |
| - PadLayout: simple, direct pad to note mapping | |
| - EncoderMapping: clean encoder to function mapping | |
| - LEDManager: dedicated LED control | |
| - MIDIHandler: centralized MIDI message routing | |
| - Controllers: individual handlers for different control types | |
| */ | |
| // MARK: MIDI CONSTANTS | |
| MPD218.MIDI = { | |
| // status bytes | |
| NOTE_ON: 0x90, | |
| NOTE_OFF: 0x80, | |
| CC: 0xB0, | |
| // channels (0-based for status byte calculation) | |
| PAD_CHANNEL: 9, // channel 10 (0x99/0x89 for pads) | |
| BUTTON_CHANNEL: 8, // channel 9 (0x98/0x88 for feature buttons) | |
| // NRPN control numbers | |
| NRPN_MSB: 99, | |
| NRPN_LSB: 98, | |
| NRPN_INCREMENT: 96, | |
| NRPN_DECREMENT: 97, | |
| // velocities | |
| LED_ON: 127, | |
| LED_OFF: 0 | |
| }; | |
| // MARK: HARDWARE CONSTANTS | |
| MPD218.HARDWARE = { | |
| // physical pad layout (device as manufactured, no rotation) | |
| // hardware bank 1 (pad bank A) | |
| PAD_NOTES: { | |
| // top row (furthest from user) | |
| TOP_ROW: [0x30, 0x31, 0x32, 0x33], | |
| // second row | |
| SECOND_ROW: [0x2C, 0x2D, 0x2E, 0x2F], | |
| // third row | |
| THIRD_ROW: [0x28, 0x29, 0x2A, 0x2B], | |
| // bottom row (closest to user) | |
| BOTTOM_ROW: [0x24, 0x25, 0x26, 0x27] | |
| }, | |
| // hardware bank 2 (pad bank B) | |
| PAD_NOTES_BANK2: { | |
| TOP_ROW: [0x40, 0x41, 0x42, 0x43], | |
| SECOND_ROW: [0x3C, 0x3D, 0x3E, 0x3F], | |
| THIRD_ROW: [0x38, 0x39, 0x3A, 0x3B], | |
| BOTTOM_ROW: [0x34, 0x35, 0x36, 0x37] | |
| }, | |
| // hardware bank 3 (pad bank C) | |
| PAD_NOTES_BANK3: { | |
| TOP_ROW: [0x50, 0x51, 0x52, 0x53], | |
| SECOND_ROW: [0x4C, 0x4D, 0x4E, 0x4F], | |
| THIRD_ROW: [0x48, 0x49, 0x4A, 0x4B], | |
| BOTTOM_ROW: [0x44, 0x45, 0x46, 0x47] | |
| }, | |
| // timing constants | |
| TIMING: { | |
| LED_UPDATE_DELAY: 50, // ms delay for LED updates after pad press | |
| SHUTDOWN_ANIMATION_INTERVAL: 60, // ms between shutdown animation steps | |
| RECONFIGURE_DELAY: 100, // ms delay before re-init after reconfigure | |
| SYNC_DELAY: 200, // ms delay before syncing LEDs after animation | |
| STARTUP_ANIMATION_DURATION: 5000, // ms total startup animation time | |
| FLASH_TEST_DURATION: 1000, // ms for LED flash test | |
| ANIMATION_GAP_DURATION: 300, // ms fixed gap between flashes (all channels) | |
| ZOOM_FEEDBACK_DURATION: 4000 // ms to show zoom level on pads | |
| }, | |
| // limits and ranges | |
| LIMITS: { | |
| MAX_HOTCUES: 16, // maximum hotcue number | |
| MAX_ZOOM: 100.0, // maximum waveform zoom | |
| MIN_ZOOM: 1.0, // minimum waveform zoom | |
| DECK_COUNT: 4, // number of decks | |
| MAX_BANKS: 3, // number of available banks | |
| MIDI_CHANNELS: 16, // total MIDI channels (0-15) | |
| MAX_ENCODER_SPEED: 1000 // maximum valid encoder speed multiplier | |
| } | |
| }; | |
| // MARK: CONTROLLER STATE | |
| MPD218.State = { | |
| initialized: false, | |
| currentBank: 1, | |
| timers: [], | |
| connections: [], | |
| lastPadTime: {}, | |
| lastInitTime: null, | |
| // nrpn parameter tracking per channel | |
| nrpnParams: {}, | |
| // zoom feedback state | |
| zoomFeedback: { | |
| active: false, | |
| deck: null, | |
| timer: null, | |
| lastLevel: null, | |
| currentPadStates: {} // track which pads are currently lit for zoom feedback | |
| }, | |
| // superknob feedback state | |
| superknobFeedback: { | |
| active: false, | |
| deck: null, | |
| timer: null, | |
| lastValue: null, | |
| currentPadStates: {} // track which pads are currently lit for superknob feedback | |
| }, | |
| // beatjump rate tracking (per deck) | |
| beatjumpRate: { | |
| lastTime: {}, // last increment time per deck | |
| multiplier: {} // current jump multiplier per deck | |
| }, | |
| // timer management utilities | |
| addTimer: function(timerId) { | |
| if (timerId && this.timers.indexOf(timerId) === -1) { | |
| this.timers.push(timerId); | |
| } | |
| return timerId; | |
| }, | |
| removeTimer: function(timerId) { | |
| const index = this.timers.indexOf(timerId); | |
| if (index !== -1) { | |
| this.timers.splice(index, 1); | |
| } | |
| }, | |
| cleanupAllTimers: function() { | |
| this.timers.forEach(id => { | |
| try { | |
| engine.stopTimer(id); | |
| } catch (e) { | |
| // timer might already be stopped, ignore errors | |
| } | |
| }); | |
| this.timers = []; | |
| // cleanup zoom feedback timer separately | |
| if (this.zoomFeedback.timer) { | |
| try { | |
| engine.stopTimer(this.zoomFeedback.timer); | |
| } catch (e) { | |
| // ignore timer cleanup errors | |
| } | |
| this.zoomFeedback.timer = null; | |
| } | |
| // cleanup superknob feedback timer separately | |
| if (this.superknobFeedback.timer) { | |
| try { | |
| engine.stopTimer(this.superknobFeedback.timer); | |
| } catch (e) { | |
| // ignore timer cleanup errors | |
| } | |
| this.superknobFeedback.timer = null; | |
| } | |
| } | |
| }; | |
| // MARK: CONFIGURATION | |
| // configure your MPD218 controller settings here | |
| MPD218.Config = { | |
| // LAYOUT SETTINGS | |
| layout: { | |
| // device physical orientation | |
| rotation: 0, // degrees: 0, 90, 180, or 270 | |
| rotationDirection: "counterclockwise", // "clockwise" or "counterclockwise" | |
| // indexing direction from user perspective | |
| indexOrder: "ascending", // "ascending" (0,1,2,3) or "descending" (3,2,1,0) | |
| // deck assignment from left-to-right (or top-to-bottom if rotated) | |
| deckOrder: [3, 1, 2, 4], // standard deck order: 3,1,2,4 | |
| // deckOrder: [1, 2, 3, 4], // linear deck order: 1,2,3,4 | |
| // feature row assignment (nearest to furthest from user) | |
| featureRows: { | |
| nearest: "bpmlock", // bottom row (closest to user) | |
| second: "slip_enabled", // second row | |
| third: "keylock", // third row | |
| furthest: "quantize" // top row (furthest from user) | |
| } | |
| }, | |
| // ENCODER SETTINGS | |
| encoders: { | |
| // zoom encoder speeds (multipliers) | |
| zoomFast: 1, // fast zoom encoder speed (coarse adjustment) | |
| zoomSlow: 0.1, // fine zoom encoder speed (precise adjustment) | |
| // beatgrid nudge sensitivity | |
| beatgridSpeed: 1.0, // beatgrid adjustment speed | |
| // jogwheel sensitivity | |
| jogwheelSpeed: 1200.0, // jogwheel scratch speed | |
| // scrub sensitivity (alternative to jogwheel without inertia) | |
| scrubSpeed: 0.001 // direct playposition scrub speed | |
| }, | |
| // ZOOM FEEDBACK OPTIONS | |
| zoomFeedback: { | |
| enabled: true, // enable/disable zoom level visualization | |
| duration: 4000, // ms to show zoom level | |
| reverseDirection: true // reverse zoom encoder direction | |
| }, | |
| // SYSTEM SETTINGS | |
| system: { | |
| debugEnabled: true // enable debug logging (set false to reduce console output) | |
| }, | |
| // INTERACTION SETTINGS (future expansion?) | |
| // interaction: { | |
| // bankSwitchMode: "manual", // "manual", "auto", "momentary" | |
| // padSensitivity: "medium", // "low", "medium", "high" | |
| // doubleClickTime: 300 // ms for double-click actions | |
| // } | |
| }; | |
| // MARK: PAD LAYOUT GENERATOR | |
| // generates layouts based on configuration | |
| MPD218.LayoutGenerator = { | |
| // base physical layout (device as manufactured, no rotation) | |
| PHYSICAL_GRID: [ | |
| MPD218.HARDWARE.PAD_NOTES.TOP_ROW, // top row | |
| MPD218.HARDWARE.PAD_NOTES.SECOND_ROW, // second row | |
| MPD218.HARDWARE.PAD_NOTES.THIRD_ROW, // third row | |
| MPD218.HARDWARE.PAD_NOTES.BOTTOM_ROW // bottom row | |
| ], | |
| PHYSICAL_GRID_BANK2: [ | |
| MPD218.HARDWARE.PAD_NOTES_BANK2.TOP_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK2.SECOND_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK2.THIRD_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK2.BOTTOM_ROW | |
| ], | |
| PHYSICAL_GRID_BANK3: [ | |
| MPD218.HARDWARE.PAD_NOTES_BANK3.TOP_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK3.SECOND_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK3.THIRD_ROW, | |
| MPD218.HARDWARE.PAD_NOTES_BANK3.BOTTOM_ROW | |
| ], | |
| // rotate grid by specified degrees and direction | |
| rotateGrid: function(grid, degrees, direction = "clockwise") { | |
| let rotated = grid.map(row => [...row]); // deep copy | |
| const rotations = (degrees / 90) % 4; | |
| const clockwise = direction === "clockwise"; | |
| for (let i = 0; i < rotations; i++) { | |
| const rows = rotated.length; | |
| const cols = rotated[0].length; | |
| const newGrid = []; | |
| if (clockwise) { | |
| // rotate 90 degrees clockwise: transpose then reverse each row | |
| for (let col = 0; col < cols; col++) { | |
| const newRow = []; | |
| for (let row = rows - 1; row >= 0; row--) { | |
| newRow.push(rotated[row][col]); | |
| } | |
| newGrid.push(newRow); | |
| } | |
| } else { | |
| // rotate 90 degrees counterclockwise: transpose then reverse column order | |
| for (let col = cols - 1; col >= 0; col--) { | |
| const newRow = []; | |
| for (let row = 0; row < rows; row++) { | |
| newRow.push(rotated[row][col]); | |
| } | |
| newGrid.push(newRow); | |
| } | |
| } | |
| rotated = newGrid; | |
| } | |
| return rotated; | |
| }, | |
| // extract channels/decks as stacks of pads (rotation handles orientation preference) | |
| extractChannels: function(grid, indexOrder, deckOrder) { | |
| const channels = {}; | |
| // always extract as columns since rotation handles row/column preference | |
| const numCols = grid[0].length; | |
| for (let col = 0; col < numCols; col++) { | |
| const column = grid.map(row => row[col]); | |
| const channelIndex = indexOrder === "ascending" ? col : (numCols - 1 - col); | |
| const deckNum = deckOrder[channelIndex]; | |
| channels[deckNum] = column; | |
| } | |
| return channels; | |
| }, | |
| // extract feature rows based on configuration | |
| extractFeatureRows: function(grid, featureConfig) { | |
| const features = {}; | |
| const rowOrder = ["nearest", "second", "third", "furthest"]; | |
| rowOrder.forEach((position, index) => { | |
| const featureType = featureConfig[position]; | |
| if (featureType) { | |
| // bottom row (index 3) is nearest, top row (index 0) is furthest | |
| const gridRowIndex = grid.length - 1 - index; | |
| if (gridRowIndex >= 0 && gridRowIndex < grid.length) { | |
| features[featureType] = [...grid[gridRowIndex]]; | |
| } | |
| } | |
| }); | |
| return features; | |
| }, | |
| // generate complete layout from configuration | |
| generateLayout: function() { | |
| const config = MPD218.Config.layout; | |
| // start with physical grid and apply rotation | |
| const rotatedGrid = this.rotateGrid( | |
| this.PHYSICAL_GRID, | |
| config.rotation, | |
| config.rotationDirection | |
| ); | |
| // extract channel assignments | |
| const channels = this.extractChannels( | |
| rotatedGrid, | |
| config.indexOrder, | |
| config.deckOrder | |
| ); | |
| // extract feature rows | |
| const features = this.extractFeatureRows(rotatedGrid, config.featureRows); | |
| // create flat notes array (manual flattening for compatibility) | |
| const allNotes = []; | |
| for (let row = 0; row < rotatedGrid.length; row++) { | |
| for (let col = 0; col < rotatedGrid[row].length; col++) { | |
| allNotes.push(rotatedGrid[row][col]); | |
| } | |
| } | |
| return { | |
| NOTES: allNotes, | |
| CHANNELS: channels, | |
| FEATURES: features, | |
| GRID: rotatedGrid // for debugging | |
| }; | |
| } | |
| }; | |
| // generate the actual layout used by the controller | |
| MPD218.PadLayout = MPD218.LayoutGenerator.generateLayout(); | |
| // MARK: BANK MAPPING GENERATOR | |
| // generates bank mappings based on current layout | |
| MPD218.BankGenerator = { | |
| // generate feature bank (bank 1) from layout | |
| generateFeatureBank: function(layout) { | |
| const pads = {}; | |
| // map each feature type to its corresponding channel pads | |
| // use the layout's channel assignments to ensure proper deck mapping | |
| Object.entries(layout.FEATURES).forEach(([featureType, notes]) => { | |
| notes.forEach((note, index) => { | |
| // find which deck this note belongs to by checking channel assignments | |
| let deckNum = index + 1; // fallback | |
| Object.entries(layout.CHANNELS).forEach(([channelNum, channelNotes]) => { | |
| if (channelNotes.includes(note)) { | |
| deckNum = parseInt(channelNum); | |
| } | |
| }); | |
| pads[note] = { | |
| type: featureType, | |
| deck: `[Channel${deckNum}]` | |
| }; | |
| }); | |
| }); | |
| return { | |
| name: "Features", | |
| pads: pads | |
| }; | |
| }, | |
| // generate hotcue bank for specific channel | |
| generateHotcueBank: function(layout, channelNum) { | |
| const pads = {}; | |
| let hotcueNum = 1; | |
| // assign hotcues to all pads for this channel | |
| layout.NOTES.forEach(note => { | |
| pads[note] = { | |
| type: "hotcue", | |
| deck: `[Channel${channelNum}]`, | |
| number: hotcueNum++ | |
| }; | |
| }); | |
| return { | |
| name: `Channel ${channelNum} Hotcues`, | |
| pads: pads | |
| }; | |
| }, | |
| // generate transport control bank (bank 2) using hardware bank 2 notes | |
| generateTransportBank: function() { | |
| const config = MPD218.Config.layout; | |
| const pads = {}; | |
| // apply same rotation to bank 2 physical grid | |
| const rotatedGrid = MPD218.LayoutGenerator.rotateGrid( | |
| MPD218.LayoutGenerator.PHYSICAL_GRID_BANK2, | |
| config.rotation, | |
| config.rotationDirection | |
| ); | |
| // top row (after rotation) = play all decks | |
| const topRow = rotatedGrid[0]; | |
| topRow.forEach(note => { | |
| pads[note] = { | |
| type: "play_all_decks" | |
| }; | |
| }); | |
| // remaining rows: hotcues for channel 1 | |
| let hotcueNum = 1; | |
| for (let row = 1; row < rotatedGrid.length; row++) { | |
| for (let col = 0; col < rotatedGrid[row].length; col++) { | |
| pads[rotatedGrid[row][col]] = { | |
| type: "hotcue", | |
| deck: `[Channel1]`, | |
| number: hotcueNum++ | |
| }; | |
| } | |
| } | |
| return { | |
| name: "Transport Controls", | |
| pads: pads | |
| }; | |
| }, | |
| // generate all bank mappings | |
| generateAllBanks: function(layout = MPD218.PadLayout) { | |
| return { | |
| 1: this.generateFeatureBank(layout), | |
| 2: this.generateTransportBank(), | |
| 3: this.generateHotcueBank(layout, 2) | |
| }; | |
| } | |
| }; | |
| // generate the actual bank mappings used by the controller | |
| MPD218.BankMappings = MPD218.BankGenerator.generateAllBanks(); | |
| // MARK: ENCODER MAPPINGS | |
| // generator for encoder mappings to avoid duplication | |
| MPD218.generateEncoderMappings = function() { | |
| // get configuration | |
| const deckOrder = MPD218.Config.layout.deckOrder; | |
| const rotation = MPD218.Config.layout.rotation; | |
| const rotationDir = MPD218.Config.layout.rotationDirection; | |
| // physical encoder hardware layout (as manufactured, no rotation): | |
| // bank 1: MIDI ch 1,2,3,4,5,6 (left to right, top row) | |
| // bank 2: MIDI ch 7,8,9,10,11,12 (left to right, bottom row) | |
| // bank 3: MIDI ch 13,14,15,16,1,2 (left to right, reuses ch 1-2) | |
| // the 4 deck encoders are in a 2x2 grid: | |
| // bank 1 MIDI ch: 3,4 (back row), 5,6 (front row) - positions [0,1,2,3] | |
| // bank 2 MIDI ch: 7,8 (back row), 9,10 (front row) - positions [0,1,2,3] | |
| // bank 3 MIDI ch: 13,14 (back row), 15,16 (front row) - positions [0,1,2,3] | |
| // map 2x2 grid positions to deck indices (like pads) | |
| // grid positions: [back-left, back-right, front-left, front-right] | |
| // which map to deck order indices: [0, 3, 1, 2] (curved pattern) | |
| const gridToDeckIndex = [0, 3, 1, 2]; | |
| // apply rotation to the grid positions | |
| let rotatedGridToDeckIndex; | |
| if (rotation === 90 && rotationDir === "counterclockwise") { | |
| // 90Β° CCW: back-left β front-left, back-right β back-left, front-right β back-right, front-left β front-right | |
| // original: [0,3,1,2] β rotated: [1,0,2,3] | |
| rotatedGridToDeckIndex = [gridToDeckIndex[2], gridToDeckIndex[0], gridToDeckIndex[3], gridToDeckIndex[1]]; | |
| } else if (rotation === 0) { | |
| // no rotation | |
| rotatedGridToDeckIndex = gridToDeckIndex; | |
| } else if (rotation === 180) { | |
| // 180Β°: reverse all | |
| rotatedGridToDeckIndex = [gridToDeckIndex[3], gridToDeckIndex[2], gridToDeckIndex[1], gridToDeckIndex[0]]; | |
| } else if (rotation === 270 || (rotation === 90 && rotationDir === "clockwise")) { | |
| // 90Β° CW | |
| rotatedGridToDeckIndex = [gridToDeckIndex[1], gridToDeckIndex[3], gridToDeckIndex[0], gridToDeckIndex[2]]; | |
| } else { | |
| // fallback | |
| rotatedGridToDeckIndex = gridToDeckIndex; | |
| } | |
| // map encoder positions to actual decks | |
| // encoder positions: [back-left, back-right, front-left, front-right] | |
| // for bank 1: MIDI ch 3,4,5,6 | |
| // for bank 2: MIDI ch 7,8,9,10 | |
| // for bank 3: MIDI ch 13,14,15,16 | |
| const encoderToDeck = rotatedGridToDeckIndex.map(idx => deckOrder[idx]); | |
| return { | |
| // bank 1 - superknob (respects rotation + deck order) | |
| 1: { type: "zoom", deck: "[Channel1]", speed: MPD218.Config.encoders.zoomFast }, | |
| 2: { type: "zoom", deck: "[Channel1]", speed: MPD218.Config.encoders.zoomSlow }, | |
| 3: { type: "superknob", deck: `[Channel${encoderToDeck[0]}]`, speed: 4.0 }, | |
| 4: { type: "superknob", deck: `[Channel${encoderToDeck[1]}]`, speed: 4.0 }, | |
| 5: { type: "superknob", deck: `[Channel${encoderToDeck[2]}]`, speed: 4.0 }, | |
| 6: { type: "superknob", deck: `[Channel${encoderToDeck[3]}]`, speed: 4.0 }, | |
| // bank 2 - beatjump (respects rotation + deck order) | |
| 7: { type: "beatjump", deck: `[Channel${encoderToDeck[0]}]`, speed: 1.0 }, | |
| 8: { type: "beatjump", deck: `[Channel${encoderToDeck[1]}]`, speed: 1.0 }, | |
| 9: { type: "beatjump", deck: `[Channel${encoderToDeck[2]}]`, speed: 1.0 }, | |
| 10: { type: "beatjump", deck: `[Channel${encoderToDeck[3]}]`, speed: 1.0 }, | |
| // bank 3 - beatgrid (respects rotation + deck order) | |
| 13: { type: "beatgrid", deck: `[Channel${encoderToDeck[0]}]`, speed: MPD218.Config.encoders.beatgridSpeed }, | |
| 14: { type: "beatgrid", deck: `[Channel${encoderToDeck[1]}]`, speed: MPD218.Config.encoders.beatgridSpeed }, | |
| 15: { type: "beatgrid", deck: `[Channel${encoderToDeck[2]}]`, speed: MPD218.Config.encoders.beatgridSpeed }, | |
| 16: { type: "beatgrid", deck: `[Channel${encoderToDeck[3]}]`, speed: MPD218.Config.encoders.beatgridSpeed } | |
| // note: MIDI ch 1,2 (zoom encoders) remain as zoom on all banks | |
| // RETIRED: playposition scrub system (replaced by beatjump scrub in bank 2) | |
| // 13: { type: "scrub", deck: `[Channel${encoderToDeck[0]}]`, speed: MPD218.Config.encoders.scrubSpeed }, | |
| // 14: { type: "scrub", deck: `[Channel${encoderToDeck[1]}]`, speed: MPD218.Config.encoders.scrubSpeed }, | |
| // 15: { type: "scrub", deck: `[Channel${encoderToDeck[2]}]`, speed: MPD218.Config.encoders.scrubSpeed }, | |
| // 16: { type: "scrub", deck: `[Channel${encoderToDeck[3]}]`, speed: MPD218.Config.encoders.scrubSpeed } | |
| }; | |
| }; | |
| // simple channel-to-function mapping for NRPN encoders | |
| MPD218.EncoderMappings = MPD218.generateEncoderMappings(); | |
| // MARK: LED MANAGER | |
| MPD218.LEDManager = { | |
| // turn LED on/off for a specific pad note | |
| setPadLED: function(note, state) { | |
| try { | |
| // use pad channel (9) for LED control | |
| const velocity = state ? MPD218.MIDI.LED_ON : MPD218.MIDI.LED_OFF; | |
| const status = state ? (MPD218.MIDI.NOTE_ON + MPD218.MIDI.PAD_CHANNEL) : (MPD218.MIDI.NOTE_OFF + MPD218.MIDI.PAD_CHANNEL); | |
| midi.sendShortMsg(status, note, velocity); | |
| // also send CC message as backup for LED control | |
| if (state) { | |
| midi.sendShortMsg(MPD218.MIDI.CC + MPD218.MIDI.PAD_CHANNEL, note, MPD218.MIDI.LED_ON); | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`LED ${state ? 'ON' : 'OFF'}: note 0x${note.toString(16)} status 0x${status.toString(16)} vel ${velocity}`); | |
| } | |
| } catch (e) { | |
| console.log(`β MIDI error setting LED for note 0x${note.toString(16)}: ${e.message}`); | |
| } | |
| }, | |
| // turn all pad LEDs off (all hardware banks) | |
| allPadsOff: function() { | |
| // turn off all pads from all three hardware banks | |
| const allBanks = [ | |
| MPD218.HARDWARE.PAD_NOTES, | |
| MPD218.HARDWARE.PAD_NOTES_BANK2, | |
| MPD218.HARDWARE.PAD_NOTES_BANK3 | |
| ]; | |
| allBanks.forEach(bank => { | |
| Object.values(bank).forEach(row => { | |
| row.forEach(note => { | |
| this.setPadLED(note, false); | |
| }); | |
| }); | |
| }); | |
| }, | |
| // sync feature LEDs with current engine state | |
| syncFeatureLEDs: function() { | |
| const currentBank = MPD218.BankMappings[MPD218.State.currentBank]; | |
| if (!currentBank) { | |
| console.log("β no current bank found for LED sync"); | |
| return; | |
| } | |
| if (!currentBank.pads) { | |
| console.log("β current bank has no pad mappings"); | |
| return; | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π syncing LEDs for bank ${MPD218.State.currentBank} (${currentBank.name})`); | |
| } | |
| Object.entries(currentBank.pads).forEach(([note, mapping]) => { | |
| const noteNum = parseInt(note); | |
| let state = false; | |
| if (mapping.type === "hotcue") { | |
| state = engine.getValue(mapping.deck, `hotcue_${mapping.number}_status`) > 0; | |
| } else if (mapping.type === "play_all_decks") { | |
| // check if all decks are playing | |
| state = true; | |
| for (let i = 1; i <= MPD218.HARDWARE.LIMITS.DECK_COUNT; i++) { | |
| if (!engine.getValue(`[Channel${i}]`, "play")) { | |
| state = false; | |
| break; | |
| } | |
| } | |
| } else { | |
| // feature toggle (bpmlock, keylock, etc.) | |
| state = engine.getValue(mapping.deck, mapping.type) > 0; | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`LED sync: 0x${noteNum.toString(16)} (${mapping.deck} ${mapping.type}) = ${state}`); | |
| } | |
| this.setPadLED(noteNum, state); | |
| }); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("β LED sync complete"); | |
| } | |
| }, | |
| // zoom level feedback on all 16 pads | |
| showZoomFeedback: function(deck, zoomLevel) { | |
| // calculate zoom level (0-15 for 16 pads) first to check if update needed | |
| const normalizedZoom = (zoomLevel - MPD218.HARDWARE.LIMITS.MIN_ZOOM) / | |
| (MPD218.HARDWARE.LIMITS.MAX_ZOOM - MPD218.HARDWARE.LIMITS.MIN_ZOOM); | |
| const zoomSteps = Math.floor(normalizedZoom * 16); | |
| const clampedSteps = Math.max(0, Math.min(15, zoomSteps)); | |
| // if this is the same level as last time, just reset the timer to avoid flicker | |
| if (MPD218.State.zoomFeedback.active && | |
| MPD218.State.zoomFeedback.lastLevel === clampedSteps && | |
| MPD218.State.zoomFeedback.deck === deck) { | |
| // just reset the timeout without changing LEDs | |
| if (MPD218.State.zoomFeedback.timer) { | |
| engine.stopTimer(MPD218.State.zoomFeedback.timer); | |
| } | |
| const feedbackDuration = MPD218.Config.zoomFeedback.duration || MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION; | |
| MPD218.State.zoomFeedback.timer = engine.beginTimer(feedbackDuration, () => { | |
| this.endZoomFeedback(); | |
| }, true); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π zoom feedback: same level ${clampedSteps}, timer reset (no flicker)`); | |
| } | |
| return; | |
| } | |
| // clear any existing zoom feedback timer | |
| if (MPD218.State.zoomFeedback.timer) { | |
| engine.stopTimer(MPD218.State.zoomFeedback.timer); | |
| } | |
| // set zoom feedback state | |
| MPD218.State.zoomFeedback.active = true; | |
| MPD218.State.zoomFeedback.deck = deck; | |
| MPD218.State.zoomFeedback.lastLevel = clampedSteps; | |
| // get pad order from bottom-left to top-right | |
| const orderedPads = this.getBottomLeftToTopRightOrder(); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π zoom feedback: ${zoomLevel.toFixed(2)} -> ${clampedSteps + 1}/16 pads (${orderedPads.length} available)`); | |
| } | |
| // on first activation, clear all pads once to override feature LEDs | |
| if (Object.keys(MPD218.State.zoomFeedback.currentPadStates).length === 0) { | |
| this.allPadsOff(); | |
| } | |
| // calculate which pads should be lit | |
| const targetPadStates = {}; | |
| for (let i = 0; i <= clampedSteps && i < orderedPads.length; i++) { | |
| targetPadStates[orderedPads[i]] = true; | |
| } | |
| // get current zoom feedback pad states | |
| const currentStates = MPD218.State.zoomFeedback.currentPadStates; | |
| // differential update - only change pads that need to change | |
| // first, turn off pads that should no longer be lit | |
| Object.keys(currentStates).forEach(noteHex => { | |
| const note = parseInt(noteHex); | |
| if (currentStates[noteHex] && !targetPadStates[note]) { | |
| this.setPadLED(note, false); | |
| delete currentStates[noteHex]; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(` turned off pad 0x${note.toString(16)}`); | |
| } | |
| } | |
| }); | |
| // then, turn on pads that should now be lit | |
| Object.keys(targetPadStates).forEach(note => { | |
| const noteNum = parseInt(note); | |
| const noteHex = noteNum.toString(); | |
| if (!currentStates[noteHex]) { | |
| this.setPadLED(noteNum, true); | |
| currentStates[noteHex] = true; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(` turned on pad 0x${noteNum.toString(16)}`); | |
| } | |
| } | |
| }); | |
| // schedule return to normal after timeout | |
| const feedbackDuration = MPD218.Config.zoomFeedback.duration || MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION; | |
| MPD218.State.zoomFeedback.timer = engine.beginTimer(feedbackDuration, () => { | |
| this.endZoomFeedback(); | |
| }, true); | |
| }, | |
| // get pad order from bottom-left to top-right | |
| getBottomLeftToTopRightOrder: function() { | |
| // this depends on the current rotation and layout | |
| const grid = MPD218.PadLayout.GRID; | |
| const orderedPads = []; | |
| // for bottom-left to top-right, we want: | |
| // bottom row left-to-right, then next row left-to-right, etc. | |
| for (let row = grid.length - 1; row >= 0; row--) { | |
| for (let col = 0; col < grid[row].length; col++) { | |
| orderedPads.push(grid[row][col]); | |
| } | |
| } | |
| return orderedPads; | |
| }, | |
| // end zoom feedback and return to normal LEDs | |
| endZoomFeedback: function() { | |
| // clear zoom feedback pad states first (only turn off zoom pads) | |
| const currentStates = MPD218.State.zoomFeedback.currentPadStates; | |
| Object.keys(currentStates).forEach(noteHex => { | |
| const note = parseInt(noteHex); | |
| this.setPadLED(note, false); | |
| }); | |
| // reset zoom feedback state | |
| MPD218.State.zoomFeedback.active = false; | |
| MPD218.State.zoomFeedback.deck = null; | |
| MPD218.State.zoomFeedback.timer = null; | |
| MPD218.State.zoomFeedback.lastLevel = null; | |
| MPD218.State.zoomFeedback.currentPadStates = {}; | |
| // return to normal LED state | |
| this.syncFeatureLEDs(); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("π zoom feedback ended - returned to normal LEDs"); | |
| } | |
| }, | |
| // superknob feedback on all 16 pads (center-based visualization) | |
| showSuperknobFeedback: function(deck, superknobValue) { | |
| // superknob value: 0.0 = full lpf, 0.5 = neutral, 1.0 = full hpf | |
| // visualization: start with all 16 pads lit at center (0.5) | |
| // turn left (lpf): remove pads from top-right progressively | |
| // turn right (hpf): remove pads from bottom-left progressively | |
| // calculate how many pads to show (0-16) | |
| // at 0.5 (neutral): show all 16 pads | |
| // at 0.0 (full lpf): show 0 pads | |
| // at 1.0 (full hpf): show 0 pads | |
| const distanceFromCenter = Math.abs(superknobValue - 0.5); | |
| const normalizedDistance = distanceFromCenter * 2; // 0.0 to 1.0 | |
| const padsToShow = Math.round((1.0 - normalizedDistance) * 16); | |
| const clampedPads = Math.max(0, Math.min(16, padsToShow)); | |
| // if this is the same value as last time, just reset the timer | |
| if (MPD218.State.superknobFeedback.active && | |
| MPD218.State.superknobFeedback.lastValue === superknobValue && | |
| MPD218.State.superknobFeedback.deck === deck) { | |
| // just reset the timeout without changing LEDs | |
| if (MPD218.State.superknobFeedback.timer) { | |
| engine.stopTimer(MPD218.State.superknobFeedback.timer); | |
| } | |
| const feedbackDuration = MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION; | |
| MPD218.State.superknobFeedback.timer = engine.beginTimer(feedbackDuration, () => { | |
| this.endSuperknobFeedback(); | |
| }, true); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`ποΈ superknob feedback: same value ${superknobValue.toFixed(3)}, timer reset (no flicker)`); | |
| } | |
| return; | |
| } | |
| // clear any existing superknob feedback timer | |
| if (MPD218.State.superknobFeedback.timer) { | |
| engine.stopTimer(MPD218.State.superknobFeedback.timer); | |
| } | |
| // set superknob feedback state | |
| MPD218.State.superknobFeedback.active = true; | |
| MPD218.State.superknobFeedback.deck = deck; | |
| MPD218.State.superknobFeedback.lastValue = superknobValue; | |
| // get pad order | |
| // for lpf (< 0.5): remove from bottom-left (normal order) | |
| // for hpf (> 0.5): remove from top-right (reverse order) | |
| const grid = MPD218.PadLayout.GRID; | |
| let orderedPads = []; | |
| if (superknobValue < 0.5) { | |
| // lpf mode: start from center, remove from bottom-left | |
| // order: bottom-left to top-right (normal) | |
| for (let row = grid.length - 1; row >= 0; row--) { | |
| for (let col = 0; col < grid[row].length; col++) { | |
| orderedPads.push(grid[row][col]); | |
| } | |
| } | |
| } else { | |
| // hpf mode: start from center, remove from top-right | |
| // order: top-right to bottom-left (reverse of normal) | |
| for (let row = 0; row < grid.length; row++) { | |
| for (let col = grid[row].length - 1; col >= 0; col--) { | |
| orderedPads.push(grid[row][col]); | |
| } | |
| } | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| const mode = superknobValue < 0.5 ? 'lpf' : superknobValue > 0.5 ? 'hpf' : 'neutral'; | |
| console.log(`ποΈ superknob feedback: ${superknobValue.toFixed(3)} (${mode}) -> ${clampedPads}/16 pads`); | |
| } | |
| // on first activation, clear all pads once | |
| if (Object.keys(MPD218.State.superknobFeedback.currentPadStates).length === 0) { | |
| this.allPadsOff(); | |
| } | |
| // calculate which pads should be lit | |
| const targetPadStates = {}; | |
| for (let i = 0; i < clampedPads && i < orderedPads.length; i++) { | |
| targetPadStates[orderedPads[i]] = true; | |
| } | |
| // get current superknob feedback pad states | |
| const currentStates = MPD218.State.superknobFeedback.currentPadStates; | |
| // differential update - only change pads that need to change | |
| // first, turn off pads that should no longer be lit | |
| Object.keys(currentStates).forEach(noteHex => { | |
| const note = parseInt(noteHex); | |
| if (currentStates[noteHex] && !targetPadStates[note]) { | |
| this.setPadLED(note, false); | |
| delete currentStates[noteHex]; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(` turned off pad 0x${note.toString(16)}`); | |
| } | |
| } | |
| }); | |
| // then, turn on pads that should now be lit | |
| Object.keys(targetPadStates).forEach(note => { | |
| const noteNum = parseInt(note); | |
| const noteHex = noteNum.toString(); | |
| if (!currentStates[noteHex]) { | |
| this.setPadLED(noteNum, true); | |
| currentStates[noteHex] = true; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(` turned on pad 0x${noteNum.toString(16)}`); | |
| } | |
| } | |
| }); | |
| // schedule return to normal after timeout | |
| const feedbackDuration = MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION; | |
| MPD218.State.superknobFeedback.timer = engine.beginTimer(feedbackDuration, () => { | |
| this.endSuperknobFeedback(); | |
| }, true); | |
| }, | |
| // end superknob feedback and return to normal LEDs | |
| endSuperknobFeedback: function() { | |
| // clear superknob feedback pad states first | |
| const currentStates = MPD218.State.superknobFeedback.currentPadStates; | |
| Object.keys(currentStates).forEach(noteHex => { | |
| const note = parseInt(noteHex); | |
| this.setPadLED(note, false); | |
| }); | |
| // reset superknob feedback state | |
| MPD218.State.superknobFeedback.active = false; | |
| MPD218.State.superknobFeedback.deck = null; | |
| MPD218.State.superknobFeedback.timer = null; | |
| MPD218.State.superknobFeedback.lastValue = null; | |
| MPD218.State.superknobFeedback.currentPadStates = {}; | |
| // return to normal LED state | |
| this.syncFeatureLEDs(); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("ποΈ superknob feedback ended - returned to normal LEDs"); | |
| } | |
| } | |
| }; | |
| // MARK: CONTROL HANDLERS | |
| MPD218.Controllers = { | |
| // handle pad presses | |
| handlePad: function(channel, control, value, status, group) { | |
| if (value === 0) return; // only handle press, not release | |
| // debounce: reject same note within 1ms (catches stacked handler double-fires) | |
| const now = Date.now(); | |
| if (MPD218.State.lastPadTime[control] !== undefined && | |
| now - MPD218.State.lastPadTime[control] < 1) { | |
| return; | |
| } | |
| MPD218.State.lastPadTime[control] = now; | |
| // if zoom feedback is active, ignore pad presses (they're just visual) | |
| if (MPD218.State.zoomFeedback.active) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("π ignoring pad press during zoom feedback"); | |
| } | |
| return; | |
| } | |
| // if superknob feedback is active, ignore pad presses (they're just visual) | |
| if (MPD218.State.superknobFeedback.active) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("ποΈ ignoring pad press during superknob feedback"); | |
| } | |
| return; | |
| } | |
| const currentBank = MPD218.BankMappings[MPD218.State.currentBank]; | |
| if (!currentBank || !currentBank.pads) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`no valid bank mapping for bank ${MPD218.State.currentBank}`); | |
| } | |
| return; | |
| } | |
| const mapping = currentBank.pads[control]; | |
| if (!mapping) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`no mapping for pad 0x${control.toString(16)} in bank ${MPD218.State.currentBank}`); | |
| } | |
| return; | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`pad pressed: 0x${control.toString(16)} -> ${mapping.deck} ${mapping.type} ${mapping.number || ''}`); | |
| } | |
| switch (mapping.type) { | |
| case "hotcue": | |
| engine.setValue(mapping.deck, `hotcue_${mapping.number}_activate`, 1); | |
| break; | |
| case "play_all_decks": | |
| // start playback on all 4 decks simultaneously | |
| for (let i = 1; i <= MPD218.HARDWARE.LIMITS.DECK_COUNT; i++) { | |
| engine.setValue(`[Channel${i}]`, "play", 1); | |
| } | |
| break; | |
| case "bpmlock": | |
| case "keylock": | |
| case "slip_enabled": | |
| case "quantize": | |
| const current = engine.getValue(mapping.deck, mapping.type); | |
| engine.setValue(mapping.deck, mapping.type, !current); | |
| break; | |
| } | |
| // immediately update LED to reflect the change | |
| const ledTimer = engine.beginTimer(MPD218.HARDWARE.TIMING.LED_UPDATE_DELAY, () => { | |
| if (mapping.type === "hotcue") { | |
| const state = engine.getValue(mapping.deck, `hotcue_${mapping.number}_status`) > 0; | |
| MPD218.LEDManager.setPadLED(control, state); | |
| } else if (mapping.type === "play_all_decks") { | |
| // light up if all decks are playing | |
| let allPlaying = true; | |
| for (let i = 1; i <= MPD218.HARDWARE.LIMITS.DECK_COUNT; i++) { | |
| if (!engine.getValue(`[Channel${i}]`, "play")) { | |
| allPlaying = false; | |
| break; | |
| } | |
| } | |
| MPD218.LEDManager.setPadLED(control, allPlaying); | |
| } else { | |
| const state = engine.getValue(mapping.deck, mapping.type) > 0; | |
| MPD218.LEDManager.setPadLED(control, state); | |
| } | |
| }, true); | |
| MPD218.State.addTimer(ledTimer); | |
| }, | |
| // handle NRPN messages for encoders | |
| handleNRPN: { | |
| // track NRPN parameter selection (CC 99/98) | |
| setParameter: function(channel, msb, lsb) { | |
| const midiChannel = channel + 1; | |
| if (!MPD218.State.nrpnParams[midiChannel]) { | |
| MPD218.State.nrpnParams[midiChannel] = {}; | |
| } | |
| MPD218.State.nrpnParams[midiChannel].param = (msb << 7) | lsb; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`NRPN param set: ch${midiChannel} = 0x${MPD218.State.nrpnParams[midiChannel].param.toString(16)}`); | |
| } | |
| }, | |
| // handle increment/decrement (CC 96/97) | |
| processMotion: function(channel, increment, value) { | |
| const midiChannel = channel + 1; | |
| const mapping = MPD218.EncoderMappings[midiChannel]; | |
| if (!mapping) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`β οΈ no encoder mapping for MIDI channel ${midiChannel} (available: ${Object.keys(MPD218.EncoderMappings).join(',')})`); | |
| } | |
| return; | |
| } | |
| const direction = increment ? 1 : -1; | |
| const speed = value * (mapping.speed || 1.0); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`encoder motion: ch${midiChannel} ${mapping.type} ${direction > 0 ? 'inc' : 'dec'} speed=${speed}`); | |
| } | |
| switch (mapping.type) { | |
| case "zoom": | |
| this.handleZoom(mapping.deck, direction, speed); | |
| break; | |
| case "beatgrid": | |
| this.handleBeatgrid(mapping.deck, direction, speed); | |
| break; | |
| case "jogwheel": | |
| this.handleJogwheel(mapping.deck, direction, speed); | |
| break; | |
| // RETIRED: playposition scrub (replaced by beatjump scrub) | |
| // case "scrub": | |
| // this.handleScrub(mapping.deck, direction, speed); | |
| // break; | |
| case "beatjump": | |
| this.handleBeatJump(mapping.deck, direction, speed); | |
| break; | |
| case "superknob": | |
| this.handleSuperknob(mapping.deck, direction, speed); | |
| break; | |
| } | |
| }, | |
| handleZoom: function(deck, direction, speed) { | |
| const current = engine.getValue(deck, "waveform_zoom"); | |
| // apply direction reversal if configured | |
| const actualDirection = MPD218.Config.zoomFeedback.reverseDirection ? -direction : direction; | |
| // use multiplicative zoom for more natural feel across the range | |
| const zoomFactor = 1 + (actualDirection * speed * 0.05); | |
| let newZoom = current * zoomFactor; | |
| // clamp to configured range | |
| newZoom = Math.max(MPD218.HARDWARE.LIMITS.MIN_ZOOM, Math.min(MPD218.HARDWARE.LIMITS.MAX_ZOOM, newZoom)); | |
| engine.setValue(deck, "waveform_zoom", newZoom); | |
| // show zoom level feedback on all pads (if enabled) | |
| if (MPD218.Config.zoomFeedback.enabled) { | |
| MPD218.LEDManager.showZoomFeedback(deck, newZoom); | |
| } | |
| }, | |
| handleBeatgrid: function(deck, direction, speed) { | |
| // beatgrid nudge with speed multiplier | |
| const delta = direction * (speed || 1.0); | |
| engine.setValue(deck, "beats_translate_move", delta); | |
| }, | |
| handleJogwheel: function(deck, direction, speed) { | |
| const delta = direction * speed * 0.01; | |
| engine.setValue(deck, "jog", delta); | |
| }, | |
| // RETIRED: playposition scrub (replaced by beatjump scrub in bank 2) | |
| // handleScrub: function(deck, direction, speed) { | |
| // // direct playposition control without inertia | |
| // const current = engine.getValue(deck, "playposition"); | |
| // const delta = direction * speed; | |
| // const newPos = Math.max(0, Math.min(1, current + delta)); | |
| // engine.setValue(deck, "playposition", newPos); | |
| // }, | |
| handleSuperknob: function(deck, direction, speed) { | |
| // control the superknob (quick effect super1 parameter) | |
| // superknob ranges from 0.0 (full lpf) to 1.0 (full hpf), with 0.5 as neutral | |
| const deckNum = deck.match(/\d+/)[0]; | |
| const control = `[QuickEffectRack1_${deck}]`; | |
| const current = engine.getValue(control, "super1"); | |
| // adjust sensitivity - much smaller steps for fine control | |
| const delta = direction * 0.001 * speed; | |
| const newValue = Math.max(0, Math.min(1, current + delta)); | |
| engine.setValue(control, "super1", newValue); | |
| // show superknob feedback on all pads | |
| MPD218.LEDManager.showSuperknobFeedback(deck, newValue); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`superknob: ${deck} ${direction > 0 ? '+' : '-'} -> ${newValue.toFixed(3)} (${newValue < 0.5 ? 'lpf' : newValue > 0.5 ? 'hpf' : 'neutral'})`); | |
| } | |
| }, | |
| handleBeatJump: function(deck, direction, speed) { | |
| // beat jump with rate-based multiplier | |
| // track time between increments to detect fast turning | |
| const now = Date.now(); | |
| const rateState = MPD218.State.beatjumpRate; | |
| if (!rateState.lastTime[deck]) { | |
| rateState.lastTime[deck] = now; | |
| rateState.multiplier[deck] = 1; | |
| } | |
| const timeSinceLastIncrement = now - rateState.lastTime[deck]; | |
| rateState.lastTime[deck] = now; | |
| // adjust multiplier based on increment rate | |
| // fast turning (< 50ms between increments) = increase multiplier | |
| // slow turning (> 150ms) = reset to 1 | |
| // use powers of 2: 1, 2, 4, 8 | |
| if (timeSinceLastIncrement < 50) { | |
| // very fast - jump to next power of 2 | |
| if (rateState.multiplier[deck] < 2) { | |
| rateState.multiplier[deck] = 2; | |
| } else if (rateState.multiplier[deck] < 4) { | |
| rateState.multiplier[deck] = 4; | |
| } else { | |
| rateState.multiplier[deck] = 8; | |
| } | |
| } else if (timeSinceLastIncrement < 100) { | |
| // medium fast - move to 2 if at 1 | |
| if (rateState.multiplier[deck] < 2) { | |
| rateState.multiplier[deck] = 2; | |
| } | |
| } else if (timeSinceLastIncrement > 150) { | |
| // slow - reset to 1 beat | |
| rateState.multiplier[deck] = 1; | |
| } | |
| // else maintain current multiplier | |
| const beatSize = rateState.multiplier[deck]; | |
| // check if jump would go beyond track boundaries | |
| const currentPos = engine.getValue(deck, "playposition"); | |
| const trackDuration = engine.getValue(deck, "duration"); | |
| const currentTime = currentPos * trackDuration; | |
| const bpm = engine.getValue(deck, "bpm"); | |
| if (bpm > 0 && trackDuration > 0) { | |
| // calculate jump distance in seconds | |
| const secondsPerBeat = 60.0 / bpm; | |
| const jumpSeconds = beatSize * secondsPerBeat * direction; | |
| const newTime = currentTime + jumpSeconds; | |
| // only execute if within track bounds | |
| if (newTime >= 0 && newTime <= trackDuration) { | |
| engine.setValue(deck, "beatjump_size", beatSize); | |
| const control = direction > 0 ? "beatjump_forward" : "beatjump_backward"; | |
| engine.setValue(deck, control, 1); | |
| } else { | |
| // clamp to track boundaries | |
| if (newTime < 0) { | |
| engine.setValue(deck, "playposition", 0); | |
| } else { | |
| engine.setValue(deck, "playposition", 0.999); // just before end | |
| } | |
| } | |
| } else { | |
| // fallback if no track info | |
| engine.setValue(deck, "beatjump_size", beatSize); | |
| const control = direction > 0 ? "beatjump_forward" : "beatjump_backward"; | |
| engine.setValue(deck, control, 1); | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`beatjump: ${deck} ${direction > 0 ? '+' : '-'}${beatSize} beats (rate: ${timeSinceLastIncrement}ms, mult: ${rateState.multiplier[deck].toFixed(1)})`); | |
| } | |
| } | |
| } | |
| }; | |
| // MARK: MIDI HANDLERS | |
| // individual MIDI message handlers that route to controllers | |
| MPD218.MIDIHandlers = { | |
| // pad note messages (Channel 10: 0x99 note on, 0x89 note off) | |
| padPress: function(channel, control, value, status, group) { | |
| MPD218.Controllers.handlePad(channel, control, value, status, group); | |
| }, | |
| // NRPN CC 99 (parameter MSB) | |
| nrpnMSB: function(channel, control, value, status, group) { | |
| const midiChannel = status & 0x0F; | |
| if (!MPD218.State.nrpnParams[midiChannel + 1]) { | |
| MPD218.State.nrpnParams[midiChannel + 1] = {}; | |
| } | |
| MPD218.State.nrpnParams[midiChannel + 1].msb = value; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`ποΈ NRPN MSB: channel ${midiChannel + 1}, value ${value}`); | |
| } | |
| }, | |
| // NRPN CC 98 (parameter LSB) | |
| nrpnLSB: function(channel, control, value, status, group) { | |
| const midiChannel = status & 0x0F; | |
| if (!MPD218.State.nrpnParams[midiChannel + 1]) { | |
| MPD218.State.nrpnParams[midiChannel + 1] = {}; | |
| } | |
| const params = MPD218.State.nrpnParams[midiChannel + 1]; | |
| params.lsb = value; | |
| // set parameter when both MSB and LSB received | |
| if (params.msb !== undefined) { | |
| MPD218.Controllers.handleNRPN.setParameter(midiChannel, params.msb, params.lsb); | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`ποΈ NRPN LSB: channel ${midiChannel + 1}, value ${value}`); | |
| } | |
| }, | |
| // NRPN CC 96 (data increment) | |
| nrpnIncrement: function(channel, control, value, status, group) { | |
| const midiChannel = status & 0x0F; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π NRPN INCREMENT: channel ${midiChannel + 1}, value ${value}`); | |
| } | |
| MPD218.Controllers.handleNRPN.processMotion(midiChannel, true, value); | |
| }, | |
| // NRPN CC 97 (data decrement) | |
| nrpnDecrement: function(channel, control, value, status, group) { | |
| const midiChannel = status & 0x0F; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π NRPN DECREMENT: channel ${midiChannel + 1}, value ${value}`); | |
| } | |
| MPD218.Controllers.handleNRPN.processMotion(midiChannel, false, value); | |
| } | |
| }; | |
| // MARK: ANIMATION MANAGER | |
| MPD218.AnimationManager = { | |
| // startup animation with diagonal wave | |
| runStartupAnimation: function() { | |
| console.log("π¦ initializing LEDs..."); | |
| MPD218.LEDManager.allPadsOff(); | |
| console.log("β¨ running diagonal wave startup animation..."); | |
| const startPause = 100; // quarter-second pause before animation starts | |
| // pause before starting the animation | |
| const startTimer = engine.beginTimer(startPause, () => { | |
| const channelPads = this.prepareChannelPads(); | |
| this.runDiagonalWave(channelPads); | |
| }, true); | |
| MPD218.State.addTimer(startTimer); | |
| }, | |
| // prepare channel pad assignments with correct visual ordering | |
| prepareChannelPads: function() { | |
| const channelPads = {}; | |
| Array.from({length: MPD218.HARDWARE.LIMITS.DECK_COUNT}, (_, i) => i + 1).forEach(channelNum => { | |
| const pads = MPD218.PadLayout.CHANNELS[channelNum] || []; | |
| // for the animation, we want index 0 to be closest to user | |
| // the layout generator produces pads in the order they appear in the grid | |
| // but we need to ensure they're ordered from closest to furthest from user | |
| // with 90Β° counterclockwise rotation and columns: | |
| // - original bottom row (0x24-0x27) becomes the rightmost column after rotation | |
| // - original top row (0x30-0x33) becomes the leftmost column after rotation | |
| // - so after rotation, moving down a column goes from left to right in original orientation | |
| // - which means we need to reverse the order if the generator gives us top-to-bottom | |
| let orderedPads = [...pads]; | |
| // check the actual note values to determine physical order | |
| if (pads.length >= 2) { | |
| // if the first note is higher than the last, we need to reverse | |
| // because higher note numbers are typically at the top in the original grid | |
| if (pads[0] > pads[pads.length - 1]) { | |
| orderedPads = [...pads].reverse(); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`Channel ${channelNum}: reversed pad order (was top-to-bottom, now bottom-to-top)`); | |
| } | |
| } | |
| } | |
| channelPads[channelNum] = orderedPads; | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`Channel ${channelNum} pads (bottom to top): [${orderedPads.map(p => '0x' + p.toString(16)).join(',')}]`); | |
| console.log(` Animation will use first ${channelNum} pads starting from closest to user`); | |
| } | |
| }); | |
| return channelPads; | |
| }, | |
| // diagonal wave animation from top-left to bottom-right | |
| // uses a single stepping timer instead of one timer per diagonal to avoid | |
| // QJSEngine heap corruption from rapid concurrent one-shot timer creation | |
| runDiagonalWave: function(channelPads) { | |
| const grid = MPD218.PadLayout.GRID; | |
| const stepDuration = 83; // 83ms per diagonal step | |
| // identify which pads should remain on (channel number display) | |
| const channelDisplayPads = new Set(); | |
| for (let channelNum = 1; channelNum <= MPD218.HARDWARE.LIMITS.DECK_COUNT; channelNum++) { | |
| const pads = channelPads[channelNum]; | |
| if (pads && pads.length >= channelNum) { | |
| for (let i = 0; i < channelNum; i++) { | |
| channelDisplayPads.add(pads[i]); | |
| } | |
| } | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`channel display pads that will remain on: [${Array.from(channelDisplayPads).map(p => '0x' + p.toString(16)).join(',')}]`); | |
| } | |
| // build diagonals from top-left to bottom-right | |
| const rows = grid.length; | |
| const cols = grid[0].length; | |
| const diagonals = []; | |
| for (let d = 0; d < rows + cols - 1; d++) { | |
| const diagonal = []; | |
| for (let row = 0; row < rows; row++) { | |
| const col = d - row; | |
| if (col >= 0 && col < cols) { | |
| diagonal.push(grid[row][col]); | |
| } | |
| } | |
| diagonals.push(diagonal); | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`diagonal wave: ${diagonals.length} diagonals`); | |
| diagonals.forEach((diag, i) => { | |
| console.log(` diagonal ${i}: [${diag.map(p => '0x' + p.toString(16)).join(',')}]`); | |
| }); | |
| } | |
| // animation phases driven by a single repeating timer: | |
| // steps 0..(N-1) : on-wave (turn on diagonal i) | |
| // steps N..(2N-1) : off-wave (turn off diagonal i, skipping display pads) | |
| // step 2N : all pads off | |
| // step 2N+1 : sync feature LEDs, stop timer | |
| const N = diagonals.length; | |
| let step = 0; | |
| const animTimer = engine.beginTimer(stepDuration, () => { | |
| if (step < N) { | |
| // on-wave: turn on diagonal `step` | |
| const diagonal = diagonals[step]; | |
| diagonal.forEach(pad => MPD218.LEDManager.setPadLED(pad, true)); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`diagonal ${step} on: [${diagonal.map(p => '0x' + p.toString(16)).join(',')}]`); | |
| } | |
| } else if (step < 2 * N) { | |
| // off-wave: turn off diagonal `step - N`, skip display pads | |
| const offIdx = step - N; | |
| const diagonal = diagonals[offIdx]; | |
| diagonal.forEach(pad => { | |
| if (!channelDisplayPads.has(pad)) { | |
| MPD218.LEDManager.setPadLED(pad, false); | |
| } | |
| }); | |
| if (MPD218.isDebugEnabled()) { | |
| const padsToTurnOff = diagonal.filter(p => !channelDisplayPads.has(p)); | |
| if (padsToTurnOff.length > 0) { | |
| console.log(`diagonal ${offIdx} off: [${padsToTurnOff.map(p => '0x' + p.toString(16)).join(',')}]`); | |
| } | |
| } | |
| } else if (step === 2 * N) { | |
| // all pads off for a quarter second | |
| MPD218.LEDManager.allPadsOff(); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("all pads off for quarter second"); | |
| } | |
| } else { | |
| // sync feature LEDs and stop | |
| MPD218.LEDManager.syncFeatureLEDs(); | |
| console.log("β startup animation complete - LEDs synced"); | |
| engine.stopTimer(animTimer); | |
| MPD218.State.removeTimer(animTimer); | |
| return; | |
| } | |
| step++; | |
| }, false); | |
| MPD218.State.addTimer(animTimer); | |
| }, | |
| // shutdown animation: sequential pad sweep | |
| runShutdownAnimation: function() { | |
| // shutdown animation works for actual shutdowns (shows on final exit) | |
| // script reloads happen too fast for the animation to complete | |
| console.log("β¨ running shutdown animation..."); | |
| if (!MPD218.PadLayout || !MPD218.PadLayout.NOTES) { | |
| // fallback if layout not available | |
| MPD218.LEDManager.allPadsOff(); | |
| console.log("β MPD218 controller shutdown complete"); | |
| return; | |
| } | |
| let currentPad = 0; | |
| const shutdownTimer = engine.beginTimer(MPD218.HARDWARE.TIMING.SHUTDOWN_ANIMATION_INTERVAL, () => { | |
| // turn off previous pad | |
| if (currentPad > 0) { | |
| MPD218.LEDManager.setPadLED(MPD218.PadLayout.NOTES[currentPad - 1], false); | |
| } | |
| // turn on current pad | |
| if (currentPad < MPD218.PadLayout.NOTES.length) { | |
| MPD218.LEDManager.setPadLED(MPD218.PadLayout.NOTES[currentPad], true); | |
| currentPad++; | |
| } else { | |
| // animation complete - turn off last pad | |
| MPD218.LEDManager.setPadLED(MPD218.PadLayout.NOTES[currentPad - 1], false); | |
| engine.stopTimer(shutdownTimer); | |
| MPD218.LEDManager.allPadsOff(); | |
| console.log("β MPD218 controller shutdown complete"); | |
| } | |
| }, false); | |
| } | |
| }; | |
| // MARK: INITIALIZATION MANAGER | |
| MPD218.InitManager = { | |
| // clean up previous state and prepare for initialization | |
| cleanup: function() { | |
| console.log("=".repeat(60)); | |
| console.log("ποΈ INITIALIZING AKAI MPD218 CONTROLLER (Clean Rewrite)"); | |
| console.log("=".repeat(60)); | |
| // clear all timers safely | |
| MPD218.State.cleanupAllTimers(); | |
| }, | |
| // register all MIDI handlers | |
| registerHandlers: function() { | |
| this.registerPadHandlers(); | |
| this.registerEncoderHandlers(); | |
| }, | |
| // register pad note handlers for all pad notes | |
| registerPadHandlers: function() { | |
| MPD218.PadLayout.NOTES.forEach(note => { | |
| const padStatus = MPD218.MIDI.NOTE_ON + MPD218.MIDI.PAD_CHANNEL; | |
| midi.makeInputHandler(padStatus, note, MPD218.MIDIHandlers.padPress); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`registered pad handler: note 0x${note.toString(16)} status 0x${padStatus.toString(16)}`); | |
| } | |
| }); | |
| }, | |
| // register NRPN handlers for encoder channels | |
| registerEncoderHandlers: function() { | |
| // register for all MIDI channels to catch any encoder input | |
| // we'll filter in the handlers based on what we actually want to handle | |
| for (let channel = 0; channel < MPD218.HARDWARE.LIMITS.MIDI_CHANNELS; channel++) { | |
| const status = MPD218.MIDI.CC + channel; // 0-based channel | |
| midi.makeInputHandler(status, MPD218.MIDI.NRPN_MSB, MPD218.MIDIHandlers.nrpnMSB); | |
| midi.makeInputHandler(status, MPD218.MIDI.NRPN_LSB, MPD218.MIDIHandlers.nrpnLSB); | |
| midi.makeInputHandler(status, MPD218.MIDI.NRPN_INCREMENT, MPD218.MIDIHandlers.nrpnIncrement); | |
| midi.makeInputHandler(status, MPD218.MIDI.NRPN_DECREMENT, MPD218.MIDIHandlers.nrpnDecrement); | |
| } | |
| if (MPD218.isDebugEnabled()) { | |
| console.log("registered NRPN handlers for all 16 MIDI channels"); | |
| console.log("encoder mappings:", Object.keys(MPD218.EncoderMappings).join(',')); | |
| console.log("π turn encoders to see which channels they actually use"); | |
| } | |
| }, | |
| // connect engine callbacks for LED updates | |
| connectCallbacks: function() { | |
| const decks = Array.from({length: MPD218.HARDWARE.LIMITS.DECK_COUNT}, (_, i) => `[Channel${i + 1}]`); | |
| decks.forEach(deck => { | |
| // hotcue callbacks | |
| for (let i = 1; i <= MPD218.HARDWARE.LIMITS.MAX_HOTCUES; i++) { | |
| MPD218.State.connections.push( | |
| engine.makeConnection(deck, `hotcue_${i}_status`, MPD218.EngineCallbacks.hotcueChanged) | |
| ); | |
| } | |
| // feature callbacks | |
| MPD218.State.connections.push(engine.makeConnection(deck, "bpmlock", MPD218.EngineCallbacks.featureChanged)); | |
| MPD218.State.connections.push(engine.makeConnection(deck, "keylock", MPD218.EngineCallbacks.featureChanged)); | |
| MPD218.State.connections.push(engine.makeConnection(deck, "slip_enabled", MPD218.EngineCallbacks.featureChanged)); | |
| MPD218.State.connections.push(engine.makeConnection(deck, "quantize", MPD218.EngineCallbacks.featureChanged)); | |
| }); | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`connected ${MPD218.State.connections.length} engine callbacks`); | |
| } | |
| }, | |
| // finalize initialization | |
| finalizeInit: function() { | |
| MPD218.State.initialized = true; | |
| MPD218.State.lastInitTime = Date.now(); | |
| console.log("β MPD218 controller initialization complete!"); | |
| } | |
| }; | |
| // MARK: UTILITY FUNCTIONS | |
| // common utility functions to reduce code duplication | |
| MPD218.Utils = { | |
| // find pad note that maps to specific deck and feature/hotcue | |
| findPadForMapping: function(deck, type, number = null) { | |
| const currentBank = MPD218.BankMappings[MPD218.State.currentBank]; | |
| if (!currentBank || !currentBank.pads) return null; | |
| for (const [note, mapping] of Object.entries(currentBank.pads)) { | |
| if (mapping.deck === deck && mapping.type === type) { | |
| if (type === "hotcue" && mapping.number === number) { | |
| return parseInt(note); | |
| } else if (type !== "hotcue") { | |
| return parseInt(note); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| }; | |
| // MARK: ENGINE CALLBACKS | |
| // callbacks for engine value changes to update LEDs | |
| MPD218.EngineCallbacks = { | |
| // hotcue status changed | |
| hotcueChanged: function(value, group, control) { | |
| // extract hotcue number from control name (e.g., "hotcue_1_status" -> 1) | |
| const match = control.match(/hotcue_(\d+)_status/); | |
| if (!match) return; | |
| const hotcueNum = parseInt(match[1]); | |
| const padNote = MPD218.Utils.findPadForMapping(group, "hotcue", hotcueNum); | |
| if (padNote !== null) { | |
| MPD218.LEDManager.setPadLED(padNote, value > 0); | |
| } | |
| }, | |
| // feature toggle changed (bpmlock, keylock, etc.) | |
| featureChanged: function(value, group, control) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(`π feature changed: ${group} ${control} = ${value}`); | |
| } | |
| const padNote = MPD218.Utils.findPadForMapping(group, control); | |
| if (padNote !== null) { | |
| if (MPD218.isDebugEnabled()) { | |
| console.log(` β updating LED for pad 0x${padNote.toString(16)} to ${value > 0}`); | |
| } | |
| MPD218.LEDManager.setPadLED(padNote, value > 0); | |
| } else if (MPD218.isDebugEnabled()) { | |
| console.log(` β no pad found for ${group} ${control} in bank ${MPD218.State.currentBank}`); | |
| } | |
| } | |
| }; | |
| // MARK: INITIALIZATION | |
| // clean initialization function - now just orchestrates the process | |
| MPD218.init = function() { | |
| MPD218.InitManager.cleanup(); | |
| MPD218.InitManager.registerHandlers(); | |
| MPD218.InitManager.connectCallbacks(); | |
| MPD218.AnimationManager.runStartupAnimation(); | |
| MPD218.InitManager.finalizeInit(); | |
| }; | |
| // clean shutdown function | |
| MPD218.shutdown = function() { | |
| console.log("ποΈ shutting down MPD218 controller..."); | |
| // mark as not initialized | |
| MPD218.State.initialized = false; | |
| // disconnect all engine connections | |
| MPD218.State.connections.forEach(conn => { | |
| try { conn.disconnect(); } catch (e) { /* ignore */ } | |
| }); | |
| MPD218.State.connections = []; | |
| MPD218.State.lastPadTime = {}; | |
| // stop all existing timers safely | |
| MPD218.State.cleanupAllTimers(); | |
| // turn off all LEDs synchronously before animation, in case timers are killed early | |
| MPD218.LEDManager.allPadsOff(); | |
| // run shutdown animation (works on final exit, not script reloads) | |
| MPD218.AnimationManager.runShutdownAnimation(); | |
| }; | |
| // MARK: UTILITY FUNCTIONS | |
| // add some utility functions for testing and convenience | |
| MPD218.test = function() { | |
| console.log("π§ͺ testing MPD218 controller..."); | |
| console.log(`initialized: ${MPD218.State.initialized}`); | |
| console.log(`current bank: ${MPD218.State.currentBank}`); | |
| console.log(`debug enabled: ${MPD218.isDebugEnabled()}`); | |
| // flash all LEDs briefly | |
| console.log("flashing all LEDs..."); | |
| MPD218.PadLayout.NOTES.forEach(note => { | |
| MPD218.LEDManager.setPadLED(note, true); | |
| }); | |
| engine.beginTimer(MPD218.HARDWARE.TIMING.FLASH_TEST_DURATION, () => { | |
| MPD218.LEDManager.allPadsOff(); | |
| console.log("β test complete"); | |
| }, true); | |
| return "test executed - check console for details"; | |
| }; | |
| // test zoom feedback feature | |
| MPD218.testZoomFeedback = function(zoomLevel = 8.0) { | |
| console.log(`π testing zoom feedback at level ${zoomLevel}...`); | |
| MPD218.LEDManager.showZoomFeedback("[Channel1]", zoomLevel); | |
| return `zoom feedback test started - ${zoomLevel} displayed for ${MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION}ms`; | |
| }; | |
| // test zoom feedback with different levels | |
| MPD218.testZoomLevels = function() { | |
| const levels = [0.1, 1.0, 8.0, 32.0, 64.0]; | |
| console.log("π testing multiple zoom levels..."); | |
| levels.forEach((level, index) => { | |
| engine.beginTimer(index * 2000, () => { | |
| console.log(`\n--- Testing zoom level ${level} ---`); | |
| MPD218.LEDManager.showZoomFeedback("[Channel1]", level); | |
| }, true); | |
| }); | |
| return "zoom level progression test started"; | |
| }; | |
| // test smooth zoom transitions (no flicker) | |
| MPD218.testSmoothZoom = function() { | |
| console.log("π testing smooth zoom transitions (should not flicker)..."); | |
| // rapid zoom changes to test differential updates | |
| const levels = [1, 2, 3, 4, 5, 6, 7, 8, 7, 6, 5, 4, 3, 2, 1]; | |
| levels.forEach((level, index) => { | |
| engine.beginTimer(index * 200, () => { | |
| MPD218.LEDManager.showZoomFeedback("[Channel1]", level); | |
| }, true); | |
| }); | |
| return "smooth zoom test started - watch for flicker-free updates"; | |
| }; | |
| // test superknob feedback feature | |
| MPD218.testSuperknobFeedback = function(value = 0.5) { | |
| console.log(`ποΈ testing superknob feedback at value ${value}...`); | |
| MPD218.LEDManager.showSuperknobFeedback("[Channel1]", value); | |
| return `superknob feedback test started - ${value} displayed for ${MPD218.HARDWARE.TIMING.ZOOM_FEEDBACK_DURATION}ms`; | |
| }; | |
| // test superknob feedback with different values | |
| MPD218.testSuperknobLevels = function() { | |
| const values = [0.5, 0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 0.75, 0.5]; | |
| console.log("ποΈ testing multiple superknob values..."); | |
| values.forEach((value, index) => { | |
| engine.beginTimer(index * 2000, () => { | |
| const mode = value < 0.5 ? 'lpf' : value > 0.5 ? 'hpf' : 'neutral'; | |
| console.log(`\n--- testing superknob value ${value.toFixed(2)} (${mode}) ---`); | |
| MPD218.LEDManager.showSuperknobFeedback("[Channel1]", value); | |
| }, true); | |
| }); | |
| return "superknob value progression test started"; | |
| }; | |
| // test smooth superknob transitions (no flicker) | |
| MPD218.testSmoothSuperknob = function() { | |
| console.log("ποΈ testing smooth superknob transitions (should not flicker)..."); | |
| // rapid superknob changes to test differential updates | |
| // sweep from neutral to lpf and back, then to hpf and back | |
| const values = [0.5, 0.4, 0.3, 0.2, 0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5]; | |
| values.forEach((value, index) => { | |
| engine.beginTimer(index * 200, () => { | |
| MPD218.LEDManager.showSuperknobFeedback("[Channel1]", value); | |
| }, true); | |
| }); | |
| return "smooth superknob test started - watch for flicker-free updates"; | |
| }; | |
| // change bank (for testing) | |
| MPD218.setBank = function(bankNum) { | |
| if (bankNum >= 1 && bankNum <= MPD218.HARDWARE.LIMITS.MAX_BANKS) { | |
| MPD218.State.currentBank = bankNum; | |
| console.log(`switched to bank ${bankNum}: ${MPD218.BankMappings[bankNum].name}`); | |
| MPD218.LEDManager.syncFeatureLEDs(); | |
| } else { | |
| console.log(`bank must be 1-${MPD218.HARDWARE.LIMITS.MAX_BANKS}`); | |
| } | |
| }; | |
| // toggle debug logging | |
| MPD218.setDebug = function(enabled) { | |
| if (enabled !== undefined) { | |
| MPD218.Config.system.debugEnabled = enabled; | |
| console.log(`π debug logging ${enabled ? 'ENABLED' : 'DISABLED'}`); | |
| return `debug ${enabled ? 'enabled' : 'disabled'}`; | |
| } else { | |
| // toggle current state | |
| MPD218.Config.system.debugEnabled = !MPD218.Config.system.debugEnabled; | |
| console.log(`π debug logging ${MPD218.Config.system.debugEnabled ? 'ENABLED' : 'DISABLED'}`); | |
| return `debug ${MPD218.Config.system.debugEnabled ? 'enabled' : 'disabled'}`; | |
| } | |
| }; | |
| // configure zoom feedback settings | |
| MPD218.setZoomFeedback = function(enabled, duration, reverse) { | |
| if (enabled !== undefined) MPD218.Config.zoomFeedback.enabled = enabled; | |
| if (duration !== undefined) MPD218.Config.zoomFeedback.duration = duration; | |
| if (reverse !== undefined) MPD218.Config.zoomFeedback.reverseDirection = reverse; | |
| console.log(`π zoom feedback updated: ${MPD218.Config.zoomFeedback.enabled ? 'enabled' : 'disabled'} (${MPD218.Config.zoomFeedback.duration}ms, reverse: ${MPD218.Config.zoomFeedback.reverseDirection})`); | |
| return "zoom feedback settings updated"; | |
| }; | |
| // configure encoder speeds | |
| MPD218.setEncoderSpeeds = function(zoomFast, zoomSlow, beatgrid, jogwheel, scrub) { | |
| // validate inputs | |
| const isValidSpeed = (val) => val === undefined || (typeof val === 'number' && val > 0 && val < MPD218.HARDWARE.LIMITS.MAX_ENCODER_SPEED); | |
| if (!isValidSpeed(zoomFast) || !isValidSpeed(zoomSlow) || !isValidSpeed(beatgrid) || !isValidSpeed(jogwheel) || !isValidSpeed(scrub)) { | |
| console.log(`β encoder speeds must be positive numbers < ${MPD218.HARDWARE.LIMITS.MAX_ENCODER_SPEED}`); | |
| return "invalid encoder speed values"; | |
| } | |
| if (zoomFast !== undefined) MPD218.Config.encoders.zoomFast = zoomFast; | |
| if (zoomSlow !== undefined) MPD218.Config.encoders.zoomSlow = zoomSlow; | |
| if (beatgrid !== undefined) MPD218.Config.encoders.beatgridSpeed = beatgrid; | |
| if (jogwheel !== undefined) MPD218.Config.encoders.jogwheelSpeed = jogwheel; | |
| if (scrub !== undefined) MPD218.Config.encoders.scrubSpeed = scrub; | |
| // regenerate encoder mappings with new speeds | |
| MPD218.EncoderMappings = MPD218.generateEncoderMappings(); | |
| console.log(`ποΈ encoder speeds updated: zoom fast=${MPD218.Config.encoders.zoomFast}, slow=${MPD218.Config.encoders.zoomSlow}, beatgrid=${MPD218.Config.encoders.beatgridSpeed}, jogwheel=${MPD218.Config.encoders.jogwheelSpeed}, scrub=${MPD218.Config.encoders.scrubSpeed}`); | |
| return "encoder speeds updated"; | |
| }; | |
| // MARK: CONFIGURATION UTILITIES | |
| // functions to help configure and reconfigure the layout | |
| // reconfigure layout (for runtime changes) | |
| MPD218.reconfigure = function() { | |
| console.log("π§ reconfiguring layout..."); | |
| // regenerate layout and mappings from current config | |
| MPD218.PadLayout = MPD218.LayoutGenerator.generateLayout(); | |
| MPD218.BankMappings = MPD218.BankGenerator.generateAllBanks(); | |
| console.log("β layout reconfigured"); | |
| // if controller is initialized, re-register handlers and sync LEDs | |
| if (MPD218.State.initialized) { | |
| console.log("π re-initializing with new layout..."); | |
| MPD218.shutdown(); | |
| engine.beginTimer(MPD218.HARDWARE.TIMING.RECONFIGURE_DELAY, () => { | |
| MPD218.init(); | |
| }, true); | |
| } | |
| return MPD218.showLayout(); | |
| }; | |
| // show current layout configuration | |
| MPD218.showLayout = function() { | |
| console.log("π current controller configuration:"); | |
| console.log(` rotation: ${MPD218.Config.layout.rotation}Β° ${MPD218.Config.layout.rotationDirection}`); | |
| console.log(` index order: ${MPD218.Config.layout.indexOrder}`); | |
| console.log(` deck order: [${MPD218.Config.layout.deckOrder.join(', ')}]`); | |
| console.log(` features: ${Object.entries(MPD218.Config.layout.featureRows).map(([pos, feat]) => `${pos}=${feat}`).join(', ')}`); | |
| console.log("\nποΈ encoder settings:"); | |
| console.log(` zoom speeds: fast=${MPD218.Config.encoders.zoomFast}, slow=${MPD218.Config.encoders.zoomSlow}`); | |
| console.log(` other speeds: beatgrid=${MPD218.Config.encoders.beatgridSpeed}, jogwheel=${MPD218.Config.encoders.jogwheelSpeed}, scrub=${MPD218.Config.encoders.scrubSpeed}`); | |
| console.log("\nπ zoom feedback:"); | |
| console.log(` enabled: ${MPD218.Config.zoomFeedback.enabled}, duration: ${MPD218.Config.zoomFeedback.duration}ms, reverse: ${MPD218.Config.zoomFeedback.reverseDirection}`); | |
| console.log("\nπ system settings:"); | |
| console.log(` debug logging: ${MPD218.Config.system.debugEnabled ? 'ENABLED' : 'disabled'}`); | |
| console.log("\nποΈ generated layout:"); | |
| console.log(" channels:", Object.entries(MPD218.PadLayout.CHANNELS).map(([deck, notes]) => | |
| `CH${deck}=[${notes.map(n => '0x' + n.toString(16)).join(',')}]`).join(' ')); | |
| console.log(" features:", Object.entries(MPD218.PadLayout.FEATURES).map(([feat, notes]) => | |
| `${feat}=[${notes.map(n => '0x' + n.toString(16)).join(',')}]`).join(' ')); | |
| console.log("\nποΈ encoder mappings (left to right):"); | |
| const deckOrder = MPD218.Config.layout.deckOrder; | |
| console.log(` bank 1 beatgrid: deck ${deckOrder[0]}, deck ${deckOrder[1]}, deck ${deckOrder[2]}, deck ${deckOrder[3]}`); | |
| console.log(` bank 2 beatjump: deck ${deckOrder[0]}, deck ${deckOrder[1]}, deck ${deckOrder[2]}, deck ${deckOrder[3]}`); | |
| console.log(` bank 3 superknob: deck ${deckOrder[0]}, deck ${deckOrder[1]}, deck ${deckOrder[2]}, deck ${deckOrder[3]}`); | |
| return "layout info logged to console"; | |
| }; | |
| // debug function to show pad mappings in current bank | |
| MPD218.showBankMappings = function(bankNum = MPD218.State.currentBank) { | |
| const bank = MPD218.BankMappings[bankNum]; | |
| if (!bank) { | |
| console.log(`β bank ${bankNum} not found`); | |
| return; | |
| } | |
| console.log(`ποΈ bank ${bankNum} (${bank.name}) pad mappings:`); | |
| Object.entries(bank.pads).forEach(([note, mapping]) => { | |
| const noteHex = '0x' + parseInt(note).toString(16); | |
| if (mapping.type === "hotcue") { | |
| console.log(` ${noteHex} -> ${mapping.deck} hotcue ${mapping.number}`); | |
| } else { | |
| console.log(` ${noteHex} -> ${mapping.deck} ${mapping.type}`); | |
| } | |
| }); | |
| return "bank mappings logged to console"; | |
| }; | |
| // debug function to test different layout configurations | |
| MPD218.testLayoutConfigs = function() { | |
| console.log("π§ͺ testing layout configuration redundancy..."); | |
| const configs = [ | |
| { name: "90Β° CCW (vertical strips)", rotation: 90, rotationDirection: "counterclockwise" }, | |
| { name: "0Β° (horizontal strips)", rotation: 0, rotationDirection: "clockwise" }, | |
| { name: "180Β° (inverted vertical)", rotation: 180, rotationDirection: "clockwise" }, | |
| { name: "270Β° CW (alt vertical)", rotation: 270, rotationDirection: "clockwise" } | |
| ]; | |
| const original = JSON.parse(JSON.stringify(MPD218.Config.layout)); | |
| configs.forEach(config => { | |
| console.log(`\n--- testing: ${config.name} ---`); | |
| // temporarily apply config | |
| Object.assign(MPD218.Config.layout, config); | |
| const layout = MPD218.LayoutGenerator.generateLayout(); | |
| console.log("channels:", Object.entries(layout.CHANNELS).map(([deck, notes]) => | |
| `CH${deck}=[${notes.map(n => '0x' + n.toString(16)).join(',')}]`).join(' ')); | |
| console.log("features:", Object.entries(layout.FEATURES).map(([feat, notes]) => | |
| `${feat}=[${notes.map(n => '0x' + n.toString(16)).join(',')}]`).join(' ')); | |
| }); | |
| // restore original config | |
| Object.assign(MPD218.Config.layout, original); | |
| console.log("\nβ layout config test complete"); | |
| return "test results logged to console"; | |
| }; | |
| // MARK: FUTURE CONFIGURATION POSSIBILITIES | |
| /* | |
| π POTENTIAL ADVANCED OPTIONS: | |
| HARDWARE BEHAVIOR: | |
| - ledBrightness: "dim"|"medium"|"full" - LED intensity control | |
| - padSensitivity: "low"|"medium"|"high" - velocity sensitivity | |
| - doubleClickTime: 300 - ms for double-click detection | |
| - holdTime: 500 - ms for pad hold actions | |
| - encoderAcceleration: true - faster response on quick turns | |
| ANIMATION & FEEDBACK: | |
| - animationSpeed: "slow"|"normal"|"fast"|"off" - startup animation speed | |
| - ledFeedback: "instant"|"delayed"|"off" - LED response timing | |
| - animationStyle: "channels"|"sweep"|"flash"|"custom" - startup pattern | |
| - shutdownStyle: "sweep"|"flash"|"fade"|"off" - shutdown pattern | |
| LAYOUT & GROUPING: | |
| - grouping: "4x4"|"2x8"|"8x2"|"linear" - pad arrangement strategy | |
| - bankAutoSwitch: true - auto-switch banks based on deck focus | |
| - mirrorLayout: true - mirror layout for left-handed users | |
| - channelSpacing: 1 - gap between channel groups | |
| INTERACTION MODES: | |
| - bankSwitchMode: "manual"|"auto"|"momentary" - bank switching behavior | |
| - padMode: "toggle"|"hold"|"momentary" - pad behavior mode | |
| - contextSensitive: true - pads adapt to current Mixxx state | |
| - shiftMode: "modifier"|"bank"|"layer" - shift key behavior | |
| ADVANCED FEATURES: | |
| - customActions: {...} - user-defined pad behaviors | |
| - midiPassthrough: true - allow non-script MIDI through | |
| - profileSwitching: true - multiple saved configurations | |
| - smartLEDs: true - LEDs react to audio analysis | |
| - crossfaderAssign: true - auto-assign channels to crossfader | |
| */ | |
| console.log("ποΈ MPD218 controller script loaded successfully!"); | |
| console.log("π‘ use MPD218.showLayout() to see current configuration"); | |
| console.log("π§ modify MPD218.Config at the top and call MPD218.reconfigure() to apply changes"); | |
| console.log("π use MPD218.showBankMappings() to see current bank's pad-to-deck mappings"); | |
| console.log("π§ͺ use MPD218.testLayoutConfigs() to compare different orientations"); | |
| console.log("π use MPD218.testZoomFeedback(level) to test zoom visualization"); | |
| console.log("π use MPD218.testZoomLevels() to test zoom progression"); | |
| console.log("β¨ use MPD218.testSmoothZoom() to test flicker-free updates"); | |
| console.log("ποΈ use MPD218.testSuperknobFeedback(value) to test superknob visualization"); | |
| console.log("ποΈ use MPD218.testSuperknobLevels() to test superknob progression"); | |
| console.log("β¨ use MPD218.testSmoothSuperknob() to test flicker-free superknob updates"); | |
| console.log("βοΈ use MPD218.setZoomFeedback(enabled, duration, reverse) to configure zoom feedback"); | |
| console.log("ποΈ use MPD218.setEncoderSpeeds(zoomFast, zoomSlow, beatgrid, jogwheel) to configure speeds"); | |
| console.log("π use MPD218.setDebug() to toggle debug logging"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment