333 lines
10 KiB
JavaScript
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;
|
|
}
|