/* * input_setup — per-instrument input-device selection & calibration. * * Bundled core plugin (constitution P-II vanilla JS). It: * 1. supplies a Web-MIDI source provider to the core `midi-input` domain; * 2. owns the `input-calibration` capability domain (run / status / inspect); * 3. renders the onboarding input-setup wizard (one pass per instrument): * - guitar/bass → pick via `audio-input`, then launch note_detect's * Calibration Wizard (note-detection is a deferred surface — JS API); * - keys/drums → pick via `midi-input`, then a live "play a note / * hit a pad" confirmation. * * Idempotent (plugin-runtime-idempotent.v1): re-hydration is a no-op. */ (function () { 'use strict'; window.feedBack = window.feedBack || {}; if (window.feedBackInputSetup && window.feedBackInputSetup.version === 1) return; const capabilities = window.feedBack.capabilities; const DONE_KEY = (inst) => `input_setup.done.${inst}`; const INSTRUMENTS = { guitar: { label: 'Guitar', mode: 'audio' }, bass: { label: 'Bass', mode: 'audio' }, keys: { label: 'Keys / Piano', mode: 'midi' }, piano: { label: 'Keys / Piano', mode: 'midi' }, drums: { label: 'Drums', mode: 'midi' }, }; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function _isDone(inst) { try { return window.localStorage.getItem(DONE_KEY(inst)) === '1'; } catch (_) { return false; } } function _markDone(inst, v) { try { if (v) window.localStorage.setItem(DONE_KEY(inst), '1'); else window.localStorage.removeItem(DONE_KEY(inst)); } catch (_) { /* private mode */ } } // The Web-MIDI source provider now ships built-in with the core midi-input // domain (static/capabilities/midi-input.js), so input_setup is a pure // consumer — it just discovers/selects/opens through `window.feedBack.midiInput`. // ── audio-input helper (guitar/bass device context) ───────────────────── async function _audioSources() { if (!capabilities || typeof capabilities.command !== 'function') return { sources: [], selected: null }; try { const r = await capabilities.command('audio-input', 'list-sources', { requester: 'input_setup' }); const p = (r && r.payload) || {}; let sources = Array.isArray(p.sources) ? p.sources : []; // Exclude MIDI devices some plugins export into audio-input // (e.g. keys-highway-3d's pseudonymized 'midi-input-N'): they aren't // audio inputs and the cryptic labels confuse this guitar/bass picker. sources = sources.filter((s) => s && !/midi/i.test(String(s.providerId || '')) && !/^midi-input/i.test(String(s.label || ''))); // No label de-dupe here. The audio-input capability already // collapses exact duplicates by logicalSourceKey // (_visibleInputSources), so nothing it returns shares a key. A // device that enumerates under several driver types (ASIO / Windows // Audio / DirectSound) has a DISTINCT key per type and is now // labelled with its driver type (e.g. "Focusrite (ASIO)") — each is // a real, separately-selectable input the user must be able to see. // The old bare-label collapse also kept whichever variant sorted // first, which could silently drop the one that was actually // `selected` below. const selected = sources.find((s) => s && s.selected) || null; return { sources, selected }; } catch (_) { return { sources: [], selected: null }; } } // ── Wizard UI ──────────────────────────────────────────────────────────── // Renders sequential per-instrument panels into `host`. Resolves the // returned promise to { completed:[...], skipped:[...] } when finished. function _runWizard(opts) { opts = opts || {}; const instruments = (Array.isArray(opts.instruments) ? opts.instruments : []) .map((i) => String(i).toLowerCase()).filter((i) => INSTRUMENTS[i]); // De-dupe keys/piano (same MIDI flow under one label). const seen = new Set(); const queue = instruments.filter((i) => { const k = INSTRUMENTS[i].label; if (seen.has(k)) return false; seen.add(k); return true; }); const completed = []; const skipped = []; let idx = 0; return new Promise((resolve) => { const host = opts.host; if (!host) { resolve({ completed, skipped }); return; } function finish() { _emitOwner('calibration-done', { completed: completed.slice(), skipped: skipped.slice() }); if (typeof opts.onComplete === 'function') { try { opts.onComplete({ completed, skipped }); } catch (_) {} } resolve({ completed, skipped }); } // Per-panel teardown run on EVERY exit (Continue or the generic "Skip // for now"), so an opened MIDI session/listener never leaks past the // panel that opened it. let _activeCleanup = null; function next() { if (idx >= queue.length) { finish(); return; } renderPanel(queue[idx]); } function advance(inst, didComplete) { if (_activeCleanup) { try { _activeCleanup(); } catch (_) {} _activeCleanup = null; } if (didComplete) { _markDone(inst, true); if (!completed.includes(inst)) completed.push(inst); } else { if (!skipped.includes(inst)) skipped.push(inst); } idx += 1; next(); } function shell(inst, bodyHtml, footHtml) { const meta = INSTRUMENTS[inst]; host.innerHTML = '
' + '
Input setup — step ' + (idx + 1) + ' of ' + queue.length + '
' + '

Set up your ' + esc(meta.label) + '

' + '
' + bodyHtml + '
' + '
' + '' + '
' + (footHtml || '') + '
'; host.querySelector('[data-is-skip]').addEventListener('click', () => advance(inst, false)); } // ── per-instrument panels ─────────────────────────────────────── async function renderPanel(inst) { const meta = INSTRUMENTS[inst]; if (meta.mode === 'audio') return renderAudioPanel(inst); return renderMidiPanel(inst); } // Guitar/bass: show the audio source (audio-input) and launch the // note_detect Calibration Wizard for the deep work. async function renderAudioPanel(inst) { const { sources, selected } = await _audioSources(); const opts2 = sources.map((s) => '').join(''); const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function'); const body = '

Pick your audio input, then run the calibration to set levels, channel and latency.

' + (sources.length ? '' + '' : '

No audio input detected yet — plug in your interface, or skip and set this up later.

') + (hasDetector ? '' : '

The note detector isn’t loaded here — you can calibrate later from the player.

'); const foot = ''; shell(inst, body, foot); const sel = host.querySelector('[data-is-audio]'); const commitAudio = (key) => { if (!capabilities || !key) return; capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {}); }; if (sel) { sel.addEventListener('change', () => commitAudio(sel.value)); // The ' + '

Waiting for input…

', ''); const wrap = host.querySelector('[data-is-midi-wrap]'); const select = host.querySelector('[data-is-midi]'); const testEl = host.querySelector('[data-is-test]'); const nextBtn = host.querySelector('[data-is-next]'); let activeKey = null; let listener = null; let activeHandle = null; let openSeq = 0; async function openSelected() { // Tear down the previous device + RESET the confirmation // state, so a hit on a prior device can't leave Continue // enabled for a newly-selected device that hasn't been heard. const myGen = ++openSeq; if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} } if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} } activeHandle = null; listener = null; nextBtn.disabled = true; _markDone(inst, false); // Capture the requested key in a local: a newer openSelected() // overwrites the shared `activeKey`, so comparing it after the // awaits would let a stale open bind the wrong device. const requestedKey = select.value; activeKey = requestedKey; if (!requestedKey) { testEl.textContent = ''; return; } testEl.textContent = 'Waiting for input…'; await mi.select(requestedKey); const res = await mi.open({ requester: 'input_setup', logicalSourceKey: requestedKey }); // Discard a stale open if a newer openSelected() superseded us. if (myGen !== openSeq) { try { if (res) mi.close({ requester: 'input_setup', logicalSourceKey: requestedKey }); } catch (_) {} return; } if (!res || !res.handle) { testEl.textContent = 'Could not open this device.'; activeKey = null; return; } activeHandle = res.handle; listener = (data) => { // 0x90 = note-on (any channel); velocity > 0. if (data && (data[0] & 0xf0) === 0x90 && data[2] > 0) { testEl.innerHTML = '✓ Got it — device is working.'; nextBtn.disabled = false; _markDone(inst, true); } }; activeHandle.addListener(listener); } host.querySelector('[data-is-scan]').addEventListener('click', async () => { await mi.discover(); // Show every source the midi-input domain surfaces — not just // the built-in Web-MIDI provider — so a native/desktop MIDI // adapter registered with the domain is selectable too. const sources = window.feedBack.midiInput.listSources() || []; if (!sources.length) { testEl && (testEl.textContent = ''); wrap.classList.remove('hidden'); select.innerHTML = ''; select.disabled = true; return; } wrap.classList.remove('hidden'); select.disabled = false; select.innerHTML = sources.map((s) => '').join(''); openSelected(); }); select.addEventListener('change', openSelected); // Close the open session/listener on ANY exit (Continue or the // generic Skip), so a scanned+selected device doesn't keep its // Web-MIDI input live after the panel advances. _activeCleanup = () => { if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} } if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} } activeHandle = null; listener = null; activeKey = null; }; nextBtn.addEventListener('click', () => advance(inst, true)); } _emitOwner('calibration-started', { instruments: queue.slice() }); next(); }); } function _emitOwner(event, detail) { try { capabilities && capabilities.emitEvent && capabilities.emitEvent('input-calibration', event, detail || {}); } catch (_) {} } // ── input-calibration owner domain ─────────────────────────────────────── function _statusPayload(instruments) { const list = (Array.isArray(instruments) && instruments.length ? instruments : Object.keys(INSTRUMENTS)) .map((i) => String(i).toLowerCase()); const status = {}; list.forEach((i) => { if (INSTRUMENTS[i]) status[i] = _isDone(i) ? 'done' : 'needs-setup'; }); return status; } if (capabilities && typeof capabilities.registerOwner === 'function') { capabilities.registerOwner('input-calibration', { pluginId: 'input_setup', kind: 'command', safety: 'safe', commands: ['run', 'status', 'inspect'], events: ['calibration-started', 'calibration-done', 'calibration-skipped'], description: 'Per-instrument input-setup wizard workflow (audio via audio-input + note_detect; MIDI via midi-input).', handlers: { inspect: () => ({ outcome: 'handled', payload: { available: true, status: _statusPayload() } }), status: (ctx) => ({ outcome: 'handled', payload: { status: _statusPayload((ctx.payload || {}).instruments) } }), // `run` is fire-and-launch: an interactive wizard far exceeds the // ~250ms handler timeout, so it starts the overlay and returns // immediately. Completion is signaled by the `calibration-done` // event (mirrors audio-monitoring `start`). A second `run` while // one is open is a no-op (single overlay). run: (ctx) => { const instruments = ((ctx.payload || {}).instruments) || []; if (!document.getElementById('input-setup-overlay')) launch(instruments); return { outcome: 'handled', payload: { started: true, instruments } }; }, }, }); } // ── public surface (onboarding + Settings re-entry) ────────────────────── function mount(container, options) { options = options || {}; return _runWizard({ host: container, instruments: options.instruments || [], onComplete: options.onComplete, onSkip: options.onSkip }); } function launch(instruments) { const overlay = document.createElement('div'); overlay.id = 'input-setup-overlay'; overlay.className = 'fixed inset-0 z-[210] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; overlay.innerHTML = '
'; document.body.appendChild(overlay); const host = overlay.querySelector('[data-is-host]'); return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; }); } window.feedBackInputSetup = { version: 1, mount, launch, status: (instruments) => _statusPayload(instruments), }; // Settings-panel re-entry (settings.html "Set up input devices" button). // Re-runs the wizard for the player's selected instrument paths, falling // back to all instruments when progression isn't available. window._inputSetupRelaunch = async function () { let instruments = []; try { const r = await fetch('/api/progression'); if (r.ok) { const d = await r.json(); const paths = Array.isArray(d.paths) ? d.paths : []; instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean); } } catch (_) { /* offline — fall back below */ } if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums']; launch(instruments); }; })();