Files
client/www/js/strudel-midi.mjs
T
2026-04-17 16:52:51 -04:00

333 lines
10 KiB
JavaScript

/**
* Strudel MIDI Module
* Handles MIDI device detection, learn mode, known device DB, and AI context
*/
// ============================================================
// KNOWN DEVICE DATABASE
// Maps device name substrings to their default CC/note layouts
// ============================================================
const KNOWN_DEVICES = {
'Launch Control': {
name: 'Novation Launch Control',
inputs: [
{ label: 'Top Knob 1', type: 'cc', cc: 21, channel: 1 },
{ label: 'Top Knob 2', type: 'cc', cc: 22, channel: 1 },
{ label: 'Top Knob 3', type: 'cc', cc: 23, channel: 1 },
{ label: 'Top Knob 4', type: 'cc', cc: 24, channel: 1 },
{ label: 'Top Knob 5', type: 'cc', cc: 25, channel: 1 },
{ label: 'Top Knob 6', type: 'cc', cc: 26, channel: 1 },
{ label: 'Top Knob 7', type: 'cc', cc: 27, channel: 1 },
{ label: 'Top Knob 8', type: 'cc', cc: 28, channel: 1 },
{ label: 'Bottom Knob 1', type: 'cc', cc: 41, channel: 1 },
{ label: 'Bottom Knob 2', type: 'cc', cc: 42, channel: 1 },
{ label: 'Bottom Knob 3', type: 'cc', cc: 43, channel: 1 },
{ label: 'Bottom Knob 4', type: 'cc', cc: 44, channel: 1 },
{ label: 'Bottom Knob 5', type: 'cc', cc: 45, channel: 1 },
{ label: 'Bottom Knob 6', type: 'cc', cc: 46, channel: 1 },
{ label: 'Bottom Knob 7', type: 'cc', cc: 47, channel: 1 },
{ label: 'Bottom Knob 8', type: 'cc', cc: 48, channel: 1 },
{ label: 'Pad 1', type: 'note', note: 9, channel: 10 },
{ label: 'Pad 2', type: 'note', note: 10, channel: 10 },
{ label: 'Pad 3', type: 'note', note: 11, channel: 10 },
{ label: 'Pad 4', type: 'note', note: 12, channel: 10 },
{ label: 'Pad 5', type: 'note', note: 25, channel: 10 },
{ label: 'Pad 6', type: 'note', note: 26, channel: 10 },
{ label: 'Pad 7', type: 'note', note: 27, channel: 10 },
{ label: 'Pad 8', type: 'note', note: 28, channel: 10 },
]
},
'nanoKONTROL2': {
name: 'Korg nanoKONTROL2',
inputs: [
{ label: 'Fader 1', type: 'cc', cc: 0, channel: 1 },
{ label: 'Fader 2', type: 'cc', cc: 1, channel: 1 },
{ label: 'Fader 3', type: 'cc', cc: 2, channel: 1 },
{ label: 'Fader 4', type: 'cc', cc: 3, channel: 1 },
{ label: 'Fader 5', type: 'cc', cc: 4, channel: 1 },
{ label: 'Fader 6', type: 'cc', cc: 5, channel: 1 },
{ label: 'Fader 7', type: 'cc', cc: 6, channel: 1 },
{ label: 'Fader 8', type: 'cc', cc: 7, channel: 1 },
{ label: 'Knob 1', type: 'cc', cc: 16, channel: 1 },
{ label: 'Knob 2', type: 'cc', cc: 17, channel: 1 },
{ label: 'Knob 3', type: 'cc', cc: 18, channel: 1 },
{ label: 'Knob 4', type: 'cc', cc: 19, channel: 1 },
{ label: 'Knob 5', type: 'cc', cc: 20, channel: 1 },
{ label: 'Knob 6', type: 'cc', cc: 21, channel: 1 },
{ label: 'Knob 7', type: 'cc', cc: 22, channel: 1 },
{ label: 'Knob 8', type: 'cc', cc: 23, channel: 1 },
]
},
'nanoKEY': {
name: 'Korg nanoKEY',
inputs: []
},
'Launchpad': {
name: 'Novation Launchpad',
inputs: []
}
};
// ============================================================
// STATE
// ============================================================
const STORAGE_KEY = 'strudel-midi-maps';
let midiAccess = null;
let learnMode = false;
let learnCallback = null;
let learnedControls = {}; // { deviceName: [ {label, type, cc/note, channel, lastValue} ] }
// ============================================================
// STORAGE
// ============================================================
/**
* Load learned control maps from localStorage
*/
export function loadMidiMaps() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
learnedControls = JSON.parse(stored);
}
} catch (e) {
console.warn('[strudel-midi] Failed to load MIDI maps:', e);
learnedControls = {};
}
return learnedControls;
}
/**
* Save learned control maps to localStorage
*/
export function saveMidiMaps() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(learnedControls));
} catch (e) {
console.warn('[strudel-midi] Failed to save MIDI maps:', e);
}
}
/**
* Clear all learned maps
*/
export function clearMidiMaps() {
learnedControls = {};
saveMidiMaps();
}
// ============================================================
// DEVICE DETECTION
// ============================================================
/**
* Initialize Web MIDI access
* @returns {Promise<{inputs: Array, outputs: Array}>}
*/
export async function initMidi() {
if (!navigator.requestMIDIAccess) {
throw new Error('Web MIDI API not supported in this browser. Use Chrome/Chromium.');
}
try {
midiAccess = await navigator.requestMIDIAccess({ sysex: false });
const devices = getConnectedDevices();
// Auto-load known device maps
for (const input of devices.inputs) {
const knownKey = Object.keys(KNOWN_DEVICES).find(k => input.name.includes(k));
if (knownKey && !learnedControls[input.name]) {
learnedControls[input.name] = KNOWN_DEVICES[knownKey].inputs.map(ctrl => ({ ...ctrl }));
console.log(`[strudel-midi] Auto-loaded map for ${input.name}`);
}
}
return devices;
} catch (e) {
throw new Error(`MIDI access denied: ${e.message}`);
}
}
/**
* Get currently connected MIDI devices
* @returns {{inputs: Array, outputs: Array}}
*/
export function getConnectedDevices() {
if (!midiAccess) return { inputs: [], outputs: [] };
const inputs = [];
const outputs = [];
for (const [, input] of midiAccess.inputs) {
inputs.push({ id: input.id, name: input.name, manufacturer: input.manufacturer });
}
for (const [, output] of midiAccess.outputs) {
outputs.push({ id: output.id, name: output.name, manufacturer: output.manufacturer });
}
return { inputs, outputs };
}
// ============================================================
// LEARN MODE
// ============================================================
/**
* Start MIDI learn mode — listens for incoming CC/note messages
* @param {Function} onControl - Called with {deviceName, type, cc/note, channel, value, label}
*/
export function startLearnMode(onControl) {
if (!midiAccess) {
console.warn('[strudel-midi] MIDI not initialized');
return;
}
learnMode = true;
learnCallback = onControl;
for (const [, input] of midiAccess.inputs) {
input.onmidimessage = (msg) => handleMidiMessage(msg, input.name);
}
console.log('[strudel-midi] Learn mode started');
}
/**
* Stop MIDI learn mode
*/
export function stopLearnMode() {
learnMode = false;
learnCallback = null;
if (midiAccess) {
for (const [, input] of midiAccess.inputs) {
input.onmidimessage = null;
}
}
console.log('[strudel-midi] Learn mode stopped');
}
/**
* Handle incoming MIDI message during learn mode
*/
function handleMidiMessage(msg, deviceName) {
const [status, data1, data2] = msg.data;
const channel = (status & 0x0F) + 1;
const type = status & 0xF0;
let control = null;
if (type === 0xB0 && data2 > 0) {
// CC message (only when value > 0 to avoid noise)
control = { type: 'cc', cc: data1, channel, value: data2, deviceName };
} else if (type === 0x90 && data2 > 0) {
// Note On
control = { type: 'note', note: data1, channel, value: data2, deviceName };
}
if (!control) return;
// Add to learned controls if not already there
if (!learnedControls[deviceName]) {
learnedControls[deviceName] = [];
}
const existing = learnedControls[deviceName].find(c => {
if (control.type === 'cc') return c.type === 'cc' && c.cc === control.cc && c.channel === control.channel;
if (control.type === 'note') return c.type === 'note' && c.note === control.note && c.channel === control.channel;
return false;
});
if (!existing) {
const idx = learnedControls[deviceName].length + 1;
const label = control.type === 'cc'
? `CC#${control.cc} ch${control.channel}`
: `Note ${control.note} ch${control.channel}`;
const entry = { ...control, label };
learnedControls[deviceName].push(entry);
saveMidiMaps();
console.log(`[strudel-midi] Learned: ${deviceName} → ${label}`);
} else {
// Update last value
existing.lastValue = control.value;
}
if (learnCallback) {
learnCallback({ ...control, label: existing?.label || control.label });
}
}
// ============================================================
// AI CONTEXT
// ============================================================
/**
* Build a MIDI context string for the AI system prompt
* @returns {string}
*/
export function buildMidiContext() {
const devices = getConnectedDevices();
if (devices.inputs.length === 0 && devices.outputs.length === 0) {
return '';
}
const lines = ['', '## Connected MIDI Devices'];
if (devices.inputs.length > 0) {
lines.push('### Inputs:');
for (const input of devices.inputs) {
lines.push(`- **${input.name}**`);
const map = learnedControls[input.name];
if (map && map.length > 0) {
lines.push(' Controls:');
for (const ctrl of map) {
if (ctrl.type === 'cc') {
lines.push(` - ${ctrl.label}: CC#${ctrl.cc} ch${ctrl.channel} → use \`midi(${ctrl.cc})\` or \`cc(${ctrl.cc})\``);
} else {
lines.push(` - ${ctrl.label}: Note ${ctrl.note} ch${ctrl.channel}`);
}
}
lines.push(` Usage: \`let midi = await midin('${input.name}')\``);
lines.push(` Example: \`note("c3 e3").s("sawtooth").lpf(midi(${map.find(c => c.type === 'cc')?.cc ?? 21}).range(200, 8000))\``);
}
}
}
if (devices.outputs.length > 0) {
lines.push('### Outputs:');
for (const output of devices.outputs) {
lines.push(`- **${output.name}** → use \`.midi('${output.name}')\``);
}
}
return lines.join('\n');
}
/**
* Get the learned control map for a device
* @param {string} deviceName
* @returns {Array}
*/
export function getDeviceMap(deviceName) {
return learnedControls[deviceName] || [];
}
/**
* Get all learned control maps
* @returns {Object}
*/
export function getAllMaps() {
return { ...learnedControls };
}
/**
* Check if MIDI is available
* @returns {boolean}
*/
export function isMidiAvailable() {
return !!midiAccess;
}