Skip to content

Instantly share code, notes, and snippets.

@kmorrill
Last active February 2, 2025 16:25
Show Gist options
  • Save kmorrill/aa2b15351d32b92dd1f63066a615d7f9 to your computer and use it in GitHub Desktop.
Save kmorrill/aa2b15351d32b92dd1f63066a615d7f9 to your computer and use it in GitHub Desktop.
OP-XY Midi Controller
<!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