import init, { yamlToParameters, parametersToYaml, liveParameterIds } from './assets/mfctl_core.js'; import { createYamlEditor } from './editor.js'; const PREFIX = [0xf0, 0, 0x20, 0x6b, 7]; const SAMPLE_HASH = 0x070b; const SELECTORS = [0x0000, 0x0200, 0x0503, 0x0304]; const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); const same = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]); function query(sequence, opcode, id) { return [...PREFIX, 1, sequence, 3, opcode, 0, id >> 8, id & 255, 0xf7]; } function identityReply(bytes) { if (![15, 17].includes(bytes.length) || bytes[0] !== 0xf0 || bytes[1] !== 0x7e || bytes[3] !== 6 || bytes[4] !== 2 || bytes.at(-1) !== 0xf7 || bytes.slice(1, -1).some(value => value >= 128)) return undefined; return { microfreak: bytes.length === 17 && same(bytes.slice(5, 12), [0, 0x20, 0x6b, 6, 0, 6, 1]), version: bytes.slice(-5, -1), }; } function parameterReply(bytes, id) { if (bytes.length !== 15 || !same(bytes.slice(0, 5), PREFIX) || ![1, 0x7f].includes(bytes[5]) || bytes[7] !== 5 || bytes[8] !== 0x40 || bytes[14] !== 0xf7 || bytes.slice(5, 14).some(value => value >= 128) || ((bytes[10] << 8) | bytes[11]) !== id) return undefined; return ((bytes[12] | ((bytes[9] & 0x10) << 3)) << 8) | bytes[13] | ((bytes[9] & 0x08) << 4); } class MidiClient { constructor(input, output) { this.input = input; this.output = output; this.sequence = 0; } nextSequence() { const result = this.sequence; this.sequence = (result + 1) & 127; return result; } exchange(packet, decode) { return new Promise((resolve, reject) => { const sent = performance.now(); const finish = (error, value) => { clearTimeout(timer); this.input.removeEventListener('midimessage', receive); this.input.removeEventListener('statechange', disconnected); this.output.removeEventListener('statechange', disconnected); if (error) reject(error); else resolve(value); }; const receive = event => { if (event.timeStamp < sent) return; const value = decode(Array.from(event.data)); if (value !== undefined) finish(null, value); }; const disconnected = () => { if (this.input.state === 'disconnected' || this.output.state === 'disconnected') { finish(new Error('MIDI device disconnected.')); } }; const timer = setTimeout(() => { const error = new Error('MIDI reply timed out.'); error.name = 'TimeoutError'; finish(error); }, 1000); this.input.addEventListener('midimessage', receive); this.input.addEventListener('statechange', disconnected); this.output.addEventListener('statechange', disconnected); try { this.output.send(packet); } catch (error) { finish(error); } }); } async identify() { const request = [0xf0, 0x7e, 0x7f, 6, 1, 0xf7]; let identity; try { identity = await this.exchange(request, identityReply); } catch (error) { if (error.name !== 'TimeoutError') throw error; // Read-only header query wakes devices that ignore the first identity request. await this.exchange(query(this.nextSequence(), 0x19, 0), bytes => { if (bytes.length === 45 && same(bytes.slice(0, 5), PREFIX) && bytes[5] === 1 && bytes[7] === 35 && bytes[8] === 0x52 && bytes[44] === 0xf7 && bytes.slice(5, 44).every(value => value < 128)) return true; return undefined; }); await pause(30); identity = await this.exchange(request, identityReply); } if (!identity.microfreak) throw new Error('Selected device is not an Arturia MicroFreak.'); if (identity.version[0] !== 5) { throw new Error(`Firmware 5 required; device reports ${identity.version.join('.')}.`); } } async read(id) { for (let attempt = 0; ; attempt++) { try { return await this.exchange(query(this.nextSequence(), 0x41, id), bytes => parameterReply(bytes, id)); } catch (error) { if (error.name === 'TimeoutError' && attempt < 2) continue; throw new Error(`Parameter 0x${id.toString(16)}: ${error.message}`); } } } async write(id, value) { const payload = Array.from(new TextEncoder().encode(`synth param ${id} ${value}`)); this.output.send([...PREFIX, 1, this.nextSequence(), payload.length, 0x53, ...payload, 0xf7]); await pause(30); } } async function getPatch(client, progress) { const raw = yamlToParameters('spec: {}'); // Capture selectors first and check them again before exposing the result. const selectors = {}; for (const id of SELECTORS) selectors[id] = await client.read(id); raw.parameters = { ...selectors }; const ids = Array.from(liveParameterIds()).filter(id => !SELECTORS.includes(id)); for (const [index, id] of ids.entries()) { progress(`Reading ${index + 1}/${ids.length}…`); raw.parameters[id] = await client.read(id); } for (const id of SELECTORS) { if (await client.read(id) !== selectors[id]) { throw new Error('Oscillator, envelope, LFO clock, or glide mode changed; retry Get.'); } } return parametersToYaml(raw); } async function applyPatch(client, raw, progress) { const targets = Object.entries(raw.parameters) .map(([id, value]) => [Number(id), value]) .filter(([id]) => id !== SAMPLE_HASH) .sort(([a], [b]) => a - b); const baseline = new Map(); for (const [index, [id]] of targets.entries()) { progress(`Reading baseline ${index + 1}/${targets.length}…`); baseline.set(id, await client.read(id)); } // Baselines must all succeed before writing. Later failures may leave partial changes. try { for (const [index, [id, value]] of targets.entries()) { progress(`Applying ${index + 1}/${targets.length}…`); if (baseline.get(id) !== value) await client.write(id, value); } for (const [index, [id, value]] of targets.entries()) { progress(`Verifying ${index + 1}/${targets.length}…`); const actual = await client.read(id); if (actual !== value) { throw new Error(`Read-back mismatch at 0x${id.toString(16)}: expected ${value}, got ${actual}.`); } } } catch (error) { throw new Error(`${error.message} Device may be partially changed.`); } } async function start() { const elements = Object.fromEntries( ['device', 'factory', 'get', 'apply', 'status'].map(id => [id, document.getElementById(id)]) ); let editor; let midi; let devices = new Map(); let busy = false; let factoryReady = false; const status = (text, error = false) => { elements.status.textContent = text; elements.status.classList.toggle('error', error); }; const update = () => { elements.device.disabled = busy || !midi; elements.get.disabled = elements.apply.disabled = busy || !devices.has(elements.device.value); elements.factory.disabled = busy || !editor || !factoryReady; editor?.updateOptions({ readOnly: busy }); }; const refreshPorts = () => { if (busy) return; const previous = elements.device.value; const inputs = [...midi.inputs.values()].filter(port => port.state === 'connected'); const outputs = [...midi.outputs.values()].filter(port => port.state === 'connected'); const sameDevice = (a, b) => a.name === b.name && a.manufacturer === b.manufacturer; devices = new Map(); // WebMIDI has separate, unrelated port IDs. Pair unambiguous names/manufacturers. for (const input of inputs) { const matches = outputs.filter(output => sameDevice(input, output)); if (matches.length === 1 && inputs.filter(port => sameDevice(input, port)).length === 1) { devices.set(input.id, { input, output: matches[0] }); } } elements.device.replaceChildren(new Option('Select MIDI device…', '')); for (const [id, { input }] of devices) elements.device.add(new Option(input.name || id, id)); const microfreaks = [...devices].filter(([, { input }]) => /micro[\s_-]*freak/i.test(input.name)); elements.device.value = devices.has(previous) ? previous : microfreaks.length === 1 ? microfreaks[0][0] : ''; update(); }; const run = async action => { busy = true; update(); try { await action(); } catch (error) { status(error.message, true); } finally { busy = false; if (midi) refreshPorts(); update(); } }; const device = async () => { const selected = devices.get(elements.device.value); if (!selected) throw new Error('Select a MIDI device.'); const { input, output } = selected; status('Opening MIDI ports…'); // Some browsers leave a redundant open() pending on an already-open port. if (input.connection !== 'open') await input.open(); if (output.connection !== 'open') await output.open(); const client = new MidiClient(input, output); status('Identifying MicroFreak…'); await client.identify(); return client; }; elements.factory.onchange = () => run(async () => { const path = elements.factory.value; if (!path) return; const url = `./assets/factory/${path.split('/').map(encodeURIComponent).join('/')}`; const response = await fetch(url); if (!response.ok) throw new Error(`Could not load factory preset (${response.status}).`); const yaml = await response.text(); yamlToParameters(yaml); editor.setValue(yaml); status('Factory preset loaded into editor. Use Apply to send it to the device.'); }); elements.device.onchange = update; elements.get.onclick = () => run(async () => { const yaml = await getPatch(await device(), status); editor.setValue(yaml); status('Current controls loaded. Preset name and sample hash are not available over live MIDI.'); }); elements.apply.onclick = () => run(async () => { // Parse and encode before opening any MIDI port. const raw = yamlToParameters(editor.getValue()); await applyPatch(await device(), raw, status); status('Applied and verified. Preset memory was not saved.'); }); try { await init(); editor = createYamlEditor(document.getElementById('editor'), () => { if (!elements.apply.disabled) return elements.apply.onclick(); }, document.getElementById('vim-mode'), document.getElementById('vim-status')); update(); try { const response = await fetch('./assets/factory/index.json'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const paths = await response.json(); for (const path of paths) { const label = path.replace(/\.(yaml|yml)$/i, '').replaceAll('/', ' / ').replaceAll('_', ' '); elements.factory.add(new Option(label, path)); } factoryReady = true; update(); } catch (error) { status(`Factory presets unavailable: ${error.message}. Run make web.`, true); } if (navigator.requestMIDIAccess) { await run(async () => { status('Requesting MIDI permission…'); const access = await navigator.requestMIDIAccess({ sysex: true }); if (!access.sysexEnabled) throw new Error('SysEx permission is required.'); midi = access; midi.onstatechange = refreshPorts; status('Select your MicroFreak device, then Get or Apply.'); }); } else { status('WebMIDI is unavailable. Use a supporting browser (such as Chrome) on HTTPS or localhost.', true); } } catch (error) { status(`Startup failed: ${error.message}. Build with wasm-pack --target web and run make web.`, true); } } if (typeof document !== 'undefined') start();