Last active
February 2, 2025 16:25
-
-
Save kmorrill/aa2b15351d32b92dd1f63066a615d7f9 to your computer and use it in GitHub Desktop.
OP-XY Midi Controller
This file contains 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
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>OP-XY MIDI Control</title> | |
</head> | |
<body> | |
<div id="status"></div> | |
<button onclick="opxy.start()">Start</button> | |
<button onclick="opxy.stop()">Stop</button> | |
<script> | |
const PARAMETER_DEFINITIONS = { | |
trackVolume: { cc: 7, defaultValue: 64 }, | |
trackMute: { cc: 9, defaultValue: 0 }, | |
trackPan: { cc: 10, defaultValue: 64 }, | |
param1: { cc: 12, defaultValue: 50 }, | |
param2: { cc: 13, defaultValue: 50 }, | |
param3: { cc: 14, defaultValue: 50 }, | |
param4: { cc: 15, defaultValue: 50 }, | |
ampAttack: { cc: 20, defaultValue: 64 }, | |
ampDecay: { cc: 21, defaultValue: 64 }, | |
ampSustain: { cc: 22, defaultValue: 64 }, | |
ampRelease: { cc: 23, defaultValue: 64 }, | |
filterAttack: { cc: 24, defaultValue: 64 }, | |
filterDecay: { cc: 25, defaultValue: 64 }, | |
filterSustain: { cc: 26, defaultValue: 64 }, | |
filterRelease: { cc: 27, defaultValue: 64 }, | |
filterCutoff: { cc: 32, defaultValue: 64 }, | |
resonance: { cc: 33, defaultValue: 64 }, | |
envAmount: { cc: 34, defaultValue: 64 }, | |
keyTracking: { cc: 35, defaultValue: 64 }, | |
sendToExt: { cc: 36, defaultValue: 0 }, | |
sendToTape: { cc: 37, defaultValue: 0 }, | |
sendToFX1: { cc: 38, defaultValue: 0 }, | |
sendToFX2: { cc: 39, defaultValue: 0 }, | |
lfoShape: { cc: 40, defaultValue: 0 }, | |
lfoOnsetDest: { cc: 41, defaultValue: 0 }, | |
}; | |
const MIDI_CCS = Object.fromEntries( | |
Object.entries(PARAMETER_DEFINITIONS).map(([key, value]) => [ | |
key.toUpperCase(), | |
value.cc, | |
]) | |
); | |
class EventEmitter { | |
constructor() { | |
this.events = new Map(); | |
} | |
on(event, callback) { | |
if (!this.events.has(event)) { | |
this.events.set(event, []); | |
} | |
this.events.get(event).push(callback); | |
return this; | |
} | |
emit(event, data) { | |
if (this.events.has(event)) { | |
this.events.get(event).forEach((callback) => callback(data)); | |
} | |
return this; | |
} | |
} | |
class Automation { | |
constructor({ | |
target, | |
startValue, | |
endValue, | |
startBeat = 0, | |
duration = 4, | |
repeat = false, | |
}) { | |
this.target = target; // Reference to parameter | |
this.startValue = startValue; | |
this.endValue = endValue; | |
this.startBeat = startBeat; // When automation begins | |
this.duration = duration; // Length in beats | |
this.repeat = repeat; // Whether to loop | |
this.currentBeat = 0; | |
} | |
getValue(beat) { | |
if (!this.repeat && beat > this.startBeat + this.duration) { | |
return this.endValue; | |
} | |
// Handle repeating automations | |
const normalizedBeat = this.repeat | |
? (beat - this.startBeat) % this.duration | |
: beat - this.startBeat; | |
if (normalizedBeat < 0) return this.startValue; | |
if (normalizedBeat > this.duration) return this.endValue; | |
const progress = normalizedBeat / this.duration; | |
return this.startValue + (this.endValue - this.startValue) * progress; | |
} | |
} | |
class Note { | |
constructor({ | |
pitch, | |
velocity = 100, | |
start = 0, | |
duration = 0.25, | |
channel = 1, | |
}) { | |
this.pitch = pitch; | |
this.velocity = velocity; | |
this.start = start; | |
this.duration = duration; | |
this.channel = channel; | |
} | |
getMIDINoteNumber() { | |
const notes = [ | |
"C", | |
"C#", | |
"D", | |
"D#", | |
"E", | |
"F", | |
"F#", | |
"G", | |
"G#", | |
"A", | |
"A#", | |
"B", | |
]; | |
const match = this.pitch.match(/([A-G]#?)(\d+)/); | |
if (!match) { | |
throw new Error(`Invalid pitch format: ${this.pitch}`); | |
} | |
const [, note, octave] = match; | |
return notes.indexOf(note) + (parseInt(octave) + 1) * 12; | |
} | |
clone(overrides = {}) { | |
return new Note({ | |
pitch: this.pitch, | |
velocity: this.velocity, | |
start: this.start, | |
duration: this.duration, | |
channel: this.channel, | |
...overrides, | |
}); | |
} | |
} | |
class Track extends EventEmitter { | |
constructor({ name, channel, type = "instrument" }) { | |
super(); | |
this.name = name; | |
this.channel = channel; | |
this.type = type; | |
this.patterns = []; | |
this.currentPattern = null; | |
this.automations = new Map(); | |
this.parameters = Object.fromEntries( | |
Object.entries(PARAMETER_DEFINITIONS).map( | |
([key, { defaultValue }]) => [key, defaultValue] | |
) | |
); | |
} | |
setParameter(parameterName, value) { | |
// Update your in-memory parameter | |
this.parameters[parameterName] = value; | |
// Find the corresponding MIDI CC number | |
const ccNumber = MIDI_CCS[parameterName.toUpperCase()]; | |
// Emit the parameterChange event, passing the info needed to send CC | |
this.emit("parameterChange", { | |
ccNumber, | |
value, | |
channel: this.channel, | |
}); | |
} | |
get currentPattern() { | |
return this._currentPattern; | |
} | |
set currentPattern(pattern) { | |
this._currentPattern = pattern; | |
this.emit("patternChange", pattern); | |
} | |
addAutomation(parameterName, automation) { | |
if (!this.automations.has(parameterName)) { | |
this.automations.set(parameterName, []); | |
} | |
this.automations.get(parameterName).push(automation); | |
} | |
updateAutomations(beat) { | |
this.automations.forEach((automations, parameterName) => { | |
const activeAutomations = automations.filter( | |
(a) => | |
beat >= a.startBeat && | |
(a.repeat || beat <= a.startBeat + a.duration) | |
); | |
if (activeAutomations.length > 0) { | |
// Use the last active automation if multiple overlap | |
const value = | |
activeAutomations[activeAutomations.length - 1].getValue(beat); | |
this.setParameter(parameterName, Math.round(value)); | |
} | |
}); | |
} | |
} | |
class Pattern { | |
constructor({ length = 16, resolution = 16 }) { | |
this.length = length; // Pattern length in beats | |
this.resolution = resolution; // Steps per beat | |
this.notes = []; | |
this.loop = true; // Patterns loop by default | |
} | |
getNoteEventsInTimeRange(startBeat, endBeat) { | |
const events = []; | |
if (!this.loop) { | |
return this.notes.filter( | |
(note) => note.start >= startBeat && note.start < endBeat | |
); | |
} | |
// Handle looping patterns | |
const patternDuration = this.length; | |
const startPattern = Math.floor(startBeat / patternDuration); | |
const endPattern = Math.ceil(endBeat / patternDuration); | |
for (let i = startPattern; i < endPattern; i++) { | |
this.notes.forEach((note) => { | |
const noteStart = note.start + i * patternDuration; | |
if (noteStart >= startBeat && noteStart < endBeat) { | |
// clone the note so we keep the prototype (getMIDINoteNumber) | |
events.push(note.clone({ start: noteStart })); | |
} | |
}); | |
} | |
return events; | |
} | |
} | |
class OPXY extends EventEmitter { | |
constructor() { | |
super(); | |
this.tracks = new Map(); | |
this.isPlaying = false; | |
this.TICKS_PER_QUARTER = 24; | |
this.STEPS_PER_BEAT = 4; | |
this.TICKS_PER_STEP = this.TICKS_PER_QUARTER / this.STEPS_PER_BEAT; | |
this.clockTickCount = 0; | |
this.currentBeat = 0; | |
this.tempo = 120; | |
this.lastTickTime = null; | |
this.midiOutput = null; | |
this.initializeTracks(); | |
} | |
async initialize() { | |
try { | |
const access = await navigator.requestMIDIAccess(); | |
console.log("MIDI Inputs:", Array.from(access.inputs.values())); | |
console.log("MIDI Outputs:", Array.from(access.outputs.values())); | |
const outputs = Array.from(access.outputs.values()); | |
if (outputs.length > 0) { | |
this.midiOutput = outputs[0]; | |
this.updateStatus( | |
`Connected to MIDI output: ${this.midiOutput.name}` | |
); | |
} else { | |
this.updateStatus("No MIDI outputs found."); | |
} | |
if (access.inputs.size > 0) { | |
for (let input of access.inputs.values()) { | |
input.onmidimessage = this.handleMidiMessage.bind(this); | |
} | |
} else { | |
this.updateStatus("No MIDI inputs found."); | |
} | |
access.onstatechange = this.handleMidiStateChange.bind(this); | |
// Start animation frame loop for automations | |
this._updateAutomations(); | |
return true; | |
} catch (error) { | |
this.updateStatus("Failed to initialize MIDI: " + error.message); | |
return false; | |
} | |
} | |
sendMidiCC(channel, ccNumber, value) { | |
// 0xB0 indicates a Control Change message | |
// channel - 1 because MIDI channels are 0–15 internally | |
if (this.midiOutput) { | |
this.midiOutput.send([0xb0 | (channel - 1), ccNumber, value]); | |
} | |
} | |
_updateAutomations() { | |
const now = performance.now(); | |
for (const track of this.tracks.values()) { | |
track.update(now); | |
} | |
requestAnimationFrame(() => this._updateAutomations()); | |
} | |
updateStatus(message) { | |
const statusDiv = document.getElementById("status"); | |
if (statusDiv) { | |
statusDiv.textContent = message; | |
} | |
} | |
start() { | |
if (!this.isPlaying) { | |
this.isPlaying = true; | |
this.clockTickCount = 0; | |
this.updateStatus("Started"); | |
} | |
} | |
stop() { | |
if (this.isPlaying) { | |
this.isPlaying = false; | |
this.updateStatus("Stopped"); | |
} | |
} | |
initializeTracks() { | |
// Create 8 instrument tracks (channels 1–8) | |
for (let i = 1; i <= 8; i++) { | |
const track = new Track({ | |
name: `track${i}`, // Use backticks here | |
channel: i, | |
type: "instrument", | |
}); | |
// Store the track in the OPXY's Map | |
this.tracks.set(`track${i}`, track); | |
// Listen for parameterChange events from this track | |
track.on("parameterChange", ({ ccNumber, value, channel }) => { | |
// Ensure you have defined sendMidiCC in OPXY | |
this.sendMidiCC(channel, ccNumber, value); | |
}); | |
} | |
// Create three auxiliary tracks: "tape", "fx1", and "fx2" | |
// Assign them to channels 14, 15, and 16 (or any set you desire) | |
["tape", "fx1", "fx2"].forEach((name, i) => { | |
const track = new Track({ | |
name, | |
channel: i + 14, // e.g., "tape" => channel 14, "fx1" => channel 15, etc. | |
type: "auxiliary", | |
}); | |
this.tracks.set(name, track); | |
track.on("parameterChange", ({ ccNumber, value, channel }) => { | |
this.sendMidiCC(channel, ccNumber, value); | |
}); | |
}); | |
} | |
processClockTick() { | |
if (!this.isPlaying) return; | |
const now = performance.now(); | |
if (!this.lastTickTime) { | |
this.lastTickTime = now; | |
return; | |
} | |
// Calculate current beat position | |
const ticksElapsed = this.clockTickCount % this.TICKS_PER_QUARTER; | |
const beatsElapsed = this.clockTickCount / this.TICKS_PER_QUARTER; | |
this.currentBeat = beatsElapsed; | |
// Process automations for all tracks | |
this.tracks.forEach((track) => { | |
track.updateAutomations(this.currentBeat); | |
}); | |
// Process notes for the next time window | |
const LOOK_AHEAD = 0.1; // Look ahead 100ms | |
const endBeat = this.currentBeat + LOOK_AHEAD; | |
this.tracks.forEach((track) => { | |
if (track.currentPattern) { | |
const notes = track.currentPattern.getNoteEventsInTimeRange( | |
this.currentBeat, | |
endBeat | |
); | |
notes.forEach((note) => { | |
const noteOn = [ | |
0x90 | (track.channel - 1), | |
note.getMIDINoteNumber(), | |
note.velocity, | |
]; | |
const noteOff = [ | |
0x80 | (track.channel - 1), | |
note.getMIDINoteNumber(), | |
0, | |
]; | |
if (this.midiOutput) { | |
const noteStartDelay = | |
(note.start - this.currentBeat) * (60000 / this.tempo); | |
const noteDuration = note.duration * (60000 / this.tempo); | |
setTimeout( | |
() => this.midiOutput.send(noteOn), | |
noteStartDelay | |
); | |
setTimeout( | |
() => this.midiOutput.send(noteOff), | |
noteStartDelay + noteDuration | |
); | |
} | |
}); | |
} | |
}); | |
this.lastTickTime = now; | |
} | |
handleMidiMessage(message) { | |
const [status] = message.data; | |
switch (status) { | |
case 0xfa: // Start | |
this.start(); | |
break; | |
case 0xfc: // Stop | |
this.stop(); | |
break; | |
case 0xf8: // Clock Tick | |
if (this.isPlaying) { | |
this.clockTickCount++; | |
this.processClockTick(); | |
} | |
break; | |
} | |
} | |
handleMidiStateChange(event) { | |
this.updateStatus( | |
`MIDI connection state changed: ${event.port.state}` | |
); | |
} | |
} | |
// Create and initialize the OPXY instance | |
const opxy = new OPXY(); | |
opxy.initialize().then(() => { | |
console.log("OPXY initialized and ready"); | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment