diff --git a/.gitignore b/.gitignore index 6722cbc..46c3979 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ plugins/minigames/__pycache__/ !plugins/tuner/ !plugins/tuner/** plugins/tuner/__pycache__/ +!plugins/input_setup/ +!plugins/input_setup/** node_modules/ test-results/ playwright-report/ diff --git a/docs/capability-domains.md b/docs/capability-domains.md index ec6cf08..0a1b7ea 100644 --- a/docs/capability-domains.md +++ b/docs/capability-domains.md @@ -153,6 +153,18 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin Diagnostics live under `slopsmith.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity. +## MIDI-Input Domain + +The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls. + +Native providers register source summaries with `providerId`, a stable `sourceId`, a derived redaction-safe `logicalSourceKey` (`providerId::sourceId`), `kind: "midi"`, a label, and `availability`. The public command surface is `inspect`, `list-sources`, `discover`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never request MIDI access. Unlike audio (where `getUserMedia` gates labels and `open-source` is the prompt), Web-MIDI's `requestMIDIAccess()` gates the whole input list, so **`discover` is the permission boundary** and records `denied`/`unavailable` outcomes; `open-source` then attaches a shared listener session and never re-prompts. + +Selected input is persisted by `logicalSourceKey` (`slopsmith.midiInput.selectedLogicalSourceKey`) when browser storage is available. Compatible requesters share one open session per source; each later calls `close-source`, and the provider receives `source.close` only after the last requester releases. Live MIDI message delivery (for the "play a note / hit a pad" calibration check) is exposed to in-page consumers through the public `window.slopsmith.midiInput` session handle only — never as raw capability events or diagnostics. + +The reserved `midi-control` domain is the planned **sibling** for control mappings (CC/pitchbend/note → action routing) and will consume `midi-input` for device access (spec 013 / #882); this slice carves the device control plane out so `midi-control` can stay mappings-only. `midi-control` stays RESERVED (documentation-only) until a concrete mapping consumer + tests exist, per the future-domain governance. + +Diagnostics live under `slopsmith.midi_input.diagnostics.v1` and contain provider ids, source ids/keys/kinds/availability, the selected key, and open-session keys — **device labels are redacted** and no raw MIDI messages are ever included. + ## Capability Roles Use capability declarations for provider/requester/observer relationships: diff --git a/docs/capability-roadmap.md b/docs/capability-roadmap.md index 46699dc..eb97399 100644 --- a/docs/capability-roadmap.md +++ b/docs/capability-roadmap.md @@ -108,7 +108,7 @@ These domains are planned but should stay out of the runtime graph until a host | `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. | | `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. | | `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. | -| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. | +| `midi-control` | multi-provider | sensitive | MIDI control mappings only (CC/pitchbend/note → action routing), consuming `midi-input` for device access. Device discovery/selection/open is split out to the delivered `midi-input` domain (spec 012). | A concrete mapping/routing workflow on top of the `midi-input` device plane (#882). | | `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. | | `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. | diff --git a/docs/capability-safety-matrix.md b/docs/capability-safety-matrix.md index 274457a..b007ffa 100644 --- a/docs/capability-safety-matrix.md +++ b/docs/capability-safety-matrix.md @@ -21,6 +21,8 @@ Core domains also have a review scope. **Active contract** domains are wired to | note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. | +| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. | + Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers. ## Expected Future Domains @@ -38,7 +40,7 @@ These domains are expected future capability contracts, not current runtime grap | ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. | | plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. | | jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. | -| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. | +| midi-control | multi-provider | sensitive | list-mappings, get-mapping, set-mapping, delete-mapping, activate-mapping, inspect | Mappings ONLY — CC/pitchbend/note → semantic action routing (spec 013). Device discovery/selection/open is NOT this domain's job: it consumes the delivered `midi-input` domain for device access. Needs a concrete mapping consumer (the MIDI control plugin / drums learn-mode) + redacted diagnostics (no raw MIDI streams) before promotion. | | tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. | Planned domains should also stay out of the runtime graph until Slopsmith ships the corresponding user-facing workflows. diff --git a/plugins/input_setup/plugin.json b/plugins/input_setup/plugin.json new file mode 100644 index 0000000..7f2704f --- /dev/null +++ b/plugins/input_setup/plugin.json @@ -0,0 +1,46 @@ +{ + "id": "input_setup", + "name": "Input Setup", + "version": "0.1.0", + "bundled": true, + "private": false, + "standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"], + "script": "screen.js", + "settings": { "html": "settings.html" }, + "description": "Per-instrument input-device selection and calibration, used during onboarding and re-launchable from Settings.", + "category": "practice", + "capabilities": { + "input-calibration": { + "roles": ["owner"], + "commands": ["run", "status", "inspect"], + "events": ["calibration-started", "calibration-done", "calibration-skipped"], + "kind": "command", + "mode": "active", + "compatibility": "none", + "ownership": "exclusive-owner", + "safety": "safe", + "description": "Owns the per-instrument input-setup wizard workflow; onboarding and Settings dispatch through the runtime.", + "version": 1 + }, + "audio-input": { + "roles": ["requester"], + "requests": ["list-sources", "select-source", "open-source"], + "mode": "active", + "compatibility": "degrade-noop", + "ownership": "requester-only", + "safety": "sensitive", + "description": "Picks the guitar/bass audio input device through the core audio-input domain.", + "version": 1 + }, + "midi-input": { + "roles": ["requester"], + "requests": ["discover", "list-sources", "select-source", "open-source", "close-source"], + "mode": "active", + "compatibility": "degrade-noop", + "ownership": "requester-only", + "safety": "sensitive", + "description": "Picks the keys/drums MIDI device through the core midi-input domain (Web-MIDI provider ships built-in with the domain).", + "version": 1 + } + } +} diff --git a/plugins/input_setup/screen.js b/plugins/input_setup/screen.js new file mode 100644 index 0000000..d8e6fd4 --- /dev/null +++ b/plugins/input_setup/screen.js @@ -0,0 +1,353 @@ +/* + * 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.slopsmith = window.slopsmith || {}; + if (window.slopsmithInputSetup && window.slopsmithInputSetup.version === 1) return; + + const capabilities = window.slopsmith.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.slopsmith.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 || ''))); + // De-dupe by display label — the desktop engine enumerates the same + // device under several driver types, so the same name can repeat. + const seen = new Set(); + sources = sources.filter((s) => { + const key = String(s.label || '').toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + 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.slopsmith.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.slopsmithInputSetup = { + 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); + }; +})(); diff --git a/plugins/input_setup/settings.html b/plugins/input_setup/settings.html new file mode 100644 index 0000000..935912a --- /dev/null +++ b/plugins/input_setup/settings.html @@ -0,0 +1,16 @@ +
+
+

Input devices & calibration

+

+ Re-run the input setup wizard for your instrument paths — pick your audio + input or MIDI device and confirm it's working. Guitar/bass also opens the + calibration wizard. +

+ +

+
+
diff --git a/specs/012-midi-input-domain/spec.md b/specs/012-midi-input-domain/spec.md new file mode 100644 index 0000000..e94e14c --- /dev/null +++ b/specs/012-midi-input-domain/spec.md @@ -0,0 +1,110 @@ +# Spec 012 — MIDI-Input Control-Plane Capability Domain + +**Status:** active (control-plane slice) · **Issues:** #873 (impl), #880 (this spec) · **Base:** `release/v0.3.0` + +## Summary + +`midi-input` is a **core-owned provider-coordinator** capability domain for MIDI +device discovery, selection, and open/close session lifecycle — the MIDI analog +of `audio-input` (spec 006). It gives every MIDI consumer in Slopsmith (the +`input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and — as +a follow-up — note-detection's Web-MIDI provider) **one device-access boundary**: +one permission prompt, one source list, one redaction boundary. + +## Motivation + +Today each MIDI consumer calls `navigator.requestMIDIAccess()` privately +(piano, drums, plugin-midi, note-detection's `midi` provider kind), so there is +no shared source list, no single permission prompt, and no common redaction of +device labels. The onboarding input-setup step (#874/#876/#877) needs a single +governed surface to pick and verify a MIDI device per instrument. + +## Why not reuse `audio-input` + +`audio-input`'s source/`source.open` contract is audio-frame-centric: +`channelSummary`/`channelCount`/`channelShape`, `requiredChannelShape`, and +redaction keyed to audio handles/buffers/samples. MIDI carries discrete messages +and has no channel shape. Folding MIDI in would overload the audio contract and +its redaction boundary. A sibling domain keeps both contracts clean and lets +each evolve independently — the same reasoning that made `audio-input` and +`audio-monitoring` siblings rather than one domain. + +## Why core-owned (not plugin-owned) + +An input control plane outlives any one feature; `audio-input` is +`core.audio.session`-owned, not owned by a feature plugin. If `input_setup` +owned `midi-input`, the domain's lifetime would be coupled to the wizard, and +migrating ownership later (every consumer, persistence key, diagnostics schema +references the owner) is costly. The domain is `core.midi-input`. + +## Contract + +- **Owner:** `core.midi-input`, kind `provider-coordinator`, safety `sensitive`. +- **Public commands:** `inspect`, `list-sources`, `discover`, `select-source`, + `open-source`, `close-source`. +- **Provider operations:** `source.enumerate`, `source.describe`, `source.open`, + `source.close`. +- **Events:** `provider-registered`, `provider-unregistered`, + `availability-changed`, `sources-changed`, `source-selected`, `source-opened`, + `source-closed`. + +### Sources & identity + +Providers register source summaries with `providerId`, a stable `sourceId`, a +derived **redaction-safe** `logicalSourceKey` (`providerId::sourceId`), +`kind: "midi"`, a label, and `availability`. Persistence and diagnostics use the +`logicalSourceKey`, never the human device label. + +### Permission model (Web-MIDI nuance) + +`requestMIDIAccess()` gates the **whole input list**, so **`discover` is the +permission boundary** (not `open-source`, as it is for audio). `inspect` / +`list-sources` / `select-source` are **prompt-free** and never request access. +`discover` records `denied` / `unavailable` outcomes; `open-source` attaches a +shared listener session to an already-discovered source and never re-prompts. + +### Sessions + +One shared open session per source across requesters (refcounted); the provider +receives `source.close` only after the last requester releases. Live MIDI +message delivery (for the "play a note / hit a pad" calibration check) is exposed +to in-page consumers via the public `window.slopsmith.midiInput` session handle +**only** — never as raw capability events or in diagnostics. + +### Persistence & redaction + +Selected source persists under `slopsmith.midiInput.selectedLogicalSourceKey`. +Diagnostics (`slopsmith.midi_input.diagnostics.v1`) carry provider ids, source +ids/keys/kinds/availability, the selected key, and open-session keys; device +**labels are redacted** and **no raw MIDI messages** are ever included. + +## Split from `midi-control` + +The reserved `midi-control` domain is narrowed to **control mappings only** +(CC/pitchbend/note → action routing) and will consume `midi-input` for device +access. This spec carves out the device control plane so `midi-control` can stay +mappings-only (#882). + +## Consumers (separate issues) + +- `input_setup` onboarding wizard — keys/drums device pick + verify (#876/#877). +- `piano` / `drums` plugins — consume `midi-input` instead of private + `requestMIDIAccess()` (via the sub-flow issues; legacy retired through bridges). +- note-detection's Web-MIDI provider migrates onto `midi-input` (#881). + +## Acceptance + +- Owner registers; appears in the Capability Inspector with the commands above. +- `discover` is the only command that triggers `requestMIDIAccess()`; + `inspect`/`list-sources`/`select-source` never prompt. +- Selection persists across reload by `logicalSourceKey`. +- Diagnostics contain no device labels or raw MIDI messages. +- A consumer can `discover` → `select-source` → `open-source` → receive live + note-on for the calibration check → `close-source` (session refcount releases). + +## Out of scope (follow-ups) + +- `midi-control` mapping/routing domain (#882). +- note-detection provider migration onto `midi-input` (#881). +- Retiring per-plugin `requestMIDIAccess()` in piano/drums via compatibility + bridges (tracked with the sub-flow issues). diff --git a/specs/013-midi-control-mappings/spec.md b/specs/013-midi-control-mappings/spec.md new file mode 100644 index 0000000..8551cea --- /dev/null +++ b/specs/013-midi-control-mappings/spec.md @@ -0,0 +1,75 @@ +# Spec 013 — `midi-control` Mappings Domain (the midi-input/midi-control split) + +**Status:** documented future contract (RESERVED — not in the runtime graph) · +**Issue:** #882 · **Depends on:** spec 012 (`midi-input`, delivered) · **Base:** `feedback/main` + +## Summary + +`midi-control` is the planned sibling of `midi-input`: it owns **MIDI control +mappings** — routing CC / pitchbend / note messages to *semantic actions* (drum +lane, transport command, effect parameter, etc.) — and **consumes `midi-input`** +for device access. It does **not** discover, select, or open devices; that is +`midi-input`'s job (spec 012, delivered). + +This spec records the **split** so the boundary is unambiguous and the contract +is ready for whoever builds the runtime slice. Per project governance +(`docs/capability-safety-matrix.md`, `docs/capability-roadmap.md`), a future +domain stays **documentation-only until a PR ships its host workflow, a concrete +consumer, and tests** — so `midi-control` remains `RESERVED` in +`static/capabilities.js` `RESERVED_FUTURE_DOMAINS` until then. This spec does not +register a runtime domain. + +## Why split it out + +Before `midi-input` existed, "MIDI" meant two conflated concerns: getting bytes +from a device, and mapping those bytes to actions. The reserved `midi-control` +entry originally covered both. With `midi-input` delivered as the device control +plane, `midi-control` is narrowed to **mappings only** — mirroring how +`audio-input` (devices) is separate from `audio-effects`/`audio-mix` (what you do +with the signal). Keeping them separate prevents a future god-domain and lets the +device plane stabilize independently of mapping semantics. + +## Boundary (normative) + +- **`midi-input` owns:** device discovery (`discover`), source list, selection, + open/close sessions, the Web-MIDI permission boundary, redacted device + diagnostics. The raw MIDI message stream is delivered to in-page consumers via + its session handle. +- **`midi-control` will own:** named mappings from MIDI events (note / CC / + pitchbend, optionally channel-scoped) to semantic actions, mapping persistence, + active-mapping selection, and "learn" capture. It **consumes** a `midi-input` + session for the live stream; it never calls `requestMIDIAccess` or enumerates + devices. + +## Proposed contract (for the future implementation slice) + +- **Owner:** `core.midi-control` (or a first-party MIDI-control plugin), + `multi-provider`, safety `sensitive`. +- **Commands:** `list-mappings`, `get-mapping`, `set-mapping`, `delete-mapping`, + `activate-mapping`, `inspect`. +- **Mapping shape (sketch):** `{ id, label, trigger: { type: 'note'|'cc'|'pitchbend', + number?, channel? }, action: { domain?, command?|actionId, params? } }`. +- **Learn mode:** open a `midi-input` session, capture the next matching event, + and bind it to the pending action (the per-plugin "learn" UIs in drums today + are the reference behaviour to generalise). +- **Diagnostics:** `slopsmith.midi_control.diagnostics.v1` — mapping summaries + + bounded recent activations; **no raw MIDI streams, no device labels**. + +## Intended consumers (promotion trigger) + +The domain should be promoted out of RESERVED when a concrete consumer needs +shared mappings, e.g.: +- the generic **MIDI control plugin** (`feedback-plugin-midi`) — today an ad-hoc + event→action mapper; the canonical first adopter. +- **drums** note→lane mapping + "learn mode" (`feedback-plugin-drums`, + `feedback-plugin-drum-highway-3d`) — currently per-plugin; could adopt + `midi-control` to share mapping logic once the contract is proven. + +Until such a consumer-driven slice exists (with host workflow + tests), this +remains a documented contract only. + +## Out of scope + +- Any runtime registration / handlers (governance: no premature domain). +- Migrating the drums/keys per-plugin mapping now — deferred to the consumer slice. +- The device plane — owned by `midi-input` (spec 012, done). diff --git a/static/capabilities.js b/static/capabilities.js index 7e68c32..14720f4 100644 --- a/static/capabilities.js +++ b/static/capabilities.js @@ -79,6 +79,19 @@ const MAX_DECISIONS = 100; const MAX_SNAPSHOT_BYTES = 64 * 1024; const DEFAULT_HANDLER_TIMEOUT_MS = 250; + // Per-(capability, command) handler-timeout overrides. A few commands front a + // user-action / OS-permission prompt (fader reads that await a UI; MIDI/mic + // device access) that legitimately runs far longer than the default budget, + // so the dispatch/command surface must not fail them at 250 ms while the + // operation is still completing through the provider. + const COMMAND_TIMEOUTS_MS = { + 'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 }, + 'midi-input': { 'discover': 15000, 'open-source': 15000 }, + }; + function _commandTimeoutFor(capability, commandName) { + const byCap = COMMAND_TIMEOUTS_MS[capability]; + return byCap ? byCap[commandName] : undefined; + } const RESERVED_FUTURE_DOMAINS = new Set([ 'ui.navigation', 'ui.plugin-screens', @@ -960,7 +973,7 @@ if (typeof handler !== 'function') continue; let decision; try { - const timeoutMs = Number(commandContext.timeoutMs || DEFAULT_HANDLER_TIMEOUT_MS); + const timeoutMs = Number(commandContext.timeoutMs || _commandTimeoutFor(capabilityName, commandName) || DEFAULT_HANDLER_TIMEOUT_MS); const result = await _withTimeout(Promise.resolve(handler(commandContext)), timeoutMs, participant); decision = _normalizeDecision(participant, result); } catch (err) { @@ -1376,7 +1389,7 @@ target: source.target || source.args?.target || null, payload: source.args || source.payload || {}, claim: source.claim, - timeoutMs: source.timeoutMs || (capability === 'audio-mix' && (commandName === 'get-fader-value' || commandName === 'set-fader-value') ? 2100 : undefined), + timeoutMs: source.timeoutMs || _commandTimeoutFor(capability, commandName), }); const status = _dispatchStatus(result); _emitEvent(capability, 'dispatched', { command: commandName, status, result, source: source.source || source.requester || 'dispatch' }); diff --git a/static/capabilities/midi-input.js b/static/capabilities/midi-input.js new file mode 100644 index 0000000..5193144 --- /dev/null +++ b/static/capabilities/midi-input.js @@ -0,0 +1,404 @@ +// Core MIDI-input capability domain (spec 012 control plane). +// +// The MIDI analog of `audio-input`: a core-owned provider-coordinator over MIDI +// device discovery, selection, and open/close session lifecycle. It is NOT +// owned by any feature plugin (an input plane outlives any feature, exactly as +// `audio-input` is `core.audio.session`-owned), and it is deliberately separate +// from `audio-input` (whose source/open contract is audio-frame-centric: +// channel shapes, sample buffers) — MIDI carries discrete messages, not audio. +// +// Consumers (the input_setup wizard, the piano/keys and drums plugins, and — +// later — note-detection's Web-MIDI provider) converge on ONE device-access +// boundary here: one permission prompt, one source list, one redaction +// boundary, replacing private per-plugin `navigator.requestMIDIAccess()` calls. +// +// Web-MIDI nuance vs audio: `requestMIDIAccess()` gates the whole input LIST, so +// `discover` (not `open-source`) is the permission boundary for MIDI. `inspect` +// / `list-sources` / `select-source` stay prompt-free and never request access. +// +// Live message delivery (needed by the "play a note / hit a pad" calibration +// check) is exposed to in-page consumers through the public global's session +// handle, never as raw capability events or diagnostics. +(function () { + 'use strict'; + + window.slopsmith = window.slopsmith || {}; + const capabilities = window.slopsmith.capabilities; + if (!capabilities || capabilities.version !== 1) return; + if (window.slopsmith.midiInput && window.slopsmith.midiInput.version === 1) return; + + const STORAGE_KEY = 'slopsmith.midiInput.selectedLogicalSourceKey'; + + // providerId → { id, label, participantId, handlers:{ enumerate, open, close } } + // handlers are LIVE functions supplied in-page via the public global; they + // never travel through the capability `command` payload. + const providers = new Map(); + // logicalSourceKey → { sourceId, providerId, logicalSourceKey, kind, label, availability } + const sources = new Map(); + // logicalSourceKey → { refs:Set, handle } — one shared open + // session per source; the provider is closed only after the last release. + const sessions = new Map(); + // logicalSourceKey → Promise — in-flight provider.open() calls, so concurrent + // opens for the same source coalesce onto one provider session instead of each + // calling provider.open() (which, for Web-MIDI, would overwrite the shared + // input.onmidimessage handler and orphan the earlier session/handle). + const opening = new Map(); + let selectedKey = _readStorage(); + let lastOutcome = null; + + // ── outcome helpers (mirror note-detection.js) ────────────────────────── + function _handled(payload = {}) { lastOutcome = { outcome: 'handled' }; return { outcome: 'handled', payload }; } + function _degraded(reason, payload = {}) { lastOutcome = { outcome: 'degraded', reason }; return { outcome: 'degraded', reason, payload }; } + function _denied(reason, payload = {}) { lastOutcome = { outcome: 'denied', reason }; return { outcome: 'denied', reason, payload }; } + function _unavailable(reason, payload = {}) { lastOutcome = { outcome: 'unavailable', reason }; return { outcome: 'unavailable', reason, payload }; } + + function _emit(name, detail) { + try { capabilities.emitEvent('midi-input', name, detail || {}); } + catch (_) { /* eventing must not break input */ } + } + + function _readStorage() { + try { return window.localStorage.getItem(STORAGE_KEY) || null; } + catch (_) { return null; } + } + function _writeStorage(key) { + try { if (key) window.localStorage.setItem(STORAGE_KEY, key); else window.localStorage.removeItem(STORAGE_KEY); return true; } + catch (_) { return false; } + } + + function _str(v, fallback) { const s = (v == null ? '' : String(v)).trim(); return s || fallback; } + + // A stable, redaction-safe key for persistence: provider + a stable source + // id, NOT the human device label. + function _logicalKey(providerId, sourceId) { return `${providerId}::${sourceId}`; } + + // ── snapshots ─────────────────────────────────────────────────────────── + // listShape keeps labels (this feeds the picker UI); diagShape strips them + // (device labels are PII-adjacent). + function _sourceListShape() { + return Array.from(sources.values()).map((s) => ({ + logicalSourceKey: s.logicalSourceKey, + sourceId: s.sourceId, + providerId: s.providerId, + kind: s.kind, + label: s.label, + availability: s.availability, + selected: s.logicalSourceKey === selectedKey, + open: sessions.has(s.logicalSourceKey), + })); + } + + function _snapshot(extra = {}) { + return { + available: providers.size > 0, + providers: Array.from(providers.values()).map((p) => ({ id: p.id, label: p.label })), + sources: _sourceListShape(), + selected: selectedKey, + openSessions: Array.from(sessions.keys()), + lastOutcome: lastOutcome ? { ...lastOutcome } : null, + ...extra, + }; + } + + function _contributeDiagnostics() { + const diagnostics = window.slopsmith && window.slopsmith.diagnostics; + if (!diagnostics || typeof diagnostics.contribute !== 'function') return; + try { + const snap = _snapshot(); + // Redact device labels everywhere — keep ids/kind/availability for + // operational observability only. No raw MIDI messages ever. + const redacted = { + ...snap, + providers: snap.providers.map(({ label: _l, ...safe }) => safe), + sources: snap.sources.map(({ label: _l, ...safe }) => safe), + }; + diagnostics.contribute('midi-input-capability', { + schema: 'slopsmith.midi_input.diagnostics.v1', + ...redacted, + }); + } catch (_) { /* diagnostics must not break input */ } + } + + // ── provider registry (live handlers via the public global) ───────────── + function _registerProvider(input = {}) { + const providerId = _str(input.providerId || input.id, ''); + if (!providerId) return null; + const handlers = { + enumerate: typeof input.enumerate === 'function' ? input.enumerate : null, + open: typeof input.open === 'function' ? input.open : null, + close: typeof input.close === 'function' ? input.close : null, + }; + const participantId = _str(input.participantId, providerId); + const wasAvailable = providers.size > 0; + providers.set(providerId, { id: providerId, label: _str(input.label, providerId), participantId, handlers }); + // Mirror a serializable declaration into the capability graph so the + // Inspector/diagnostics can reason about the provider relationship. + try { + capabilities.registerParticipant(participantId, { + 'midi-input': { + roles: ['provider'], + operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'], + mode: 'active', + safety: 'sensitive', + runtime: true, + description: `MIDI input provider ${_str(input.label, providerId)}.`, + provider_policy: { providerId }, + }, + }); + } catch (_) { /* declaration is best-effort */ } + _emit('provider-registered', { providerId }); + if (!wasAvailable) _emit('availability-changed', { available: true }); + _contributeDiagnostics(); + return { providerId }; + } + + function _unregisterProvider(providerId) { + providerId = _str(providerId, ''); + const provider = providers.get(providerId); + if (!provider) return false; + // Drop the provider's sources + any open sessions. + for (const [key, s] of Array.from(sources.entries())) { + if (s.providerId === providerId) { + _closeSessionInternal(key, 'provider-unregistered'); + sources.delete(key); + } + } + providers.delete(providerId); + if (typeof capabilities.unregisterParticipant === 'function') { + try { capabilities.unregisterParticipant(provider.participantId, 'midi-input'); } catch (_) { /* best-effort */ } + } + _emit('provider-unregistered', { providerId }); + if (providers.size === 0) _emit('availability-changed', { available: false }); + _contributeDiagnostics(); + return true; + } + + // ── discovery (the Web-MIDI permission boundary) ──────────────────────── + async function _discover() { + if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot()); + let found = 0; + for (const provider of providers.values()) { + if (!provider.handlers.enumerate) continue; + let list; + try { list = await provider.handlers.enumerate(); } + catch (e) { + // requestMIDIAccess rejection = permission denied / unsupported. + return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot()); + } + const fresh = new Set(); + for (const raw of (Array.isArray(list) ? list : [])) { + const sourceId = _str(raw.sourceId || raw.id, ''); + if (!sourceId) continue; + const key = _logicalKey(provider.id, sourceId); + fresh.add(key); + sources.set(key, { + sourceId, + providerId: provider.id, + logicalSourceKey: key, + kind: 'midi', + label: _str(raw.label || raw.name, 'MIDI input'), + availability: _str(raw.availability, 'available'), + }); + found += 1; + } + // Reconcile: drop this provider's sources that vanished since the + // last enumeration (e.g. a device unplugged, firing statechange). + // Without this, list-sources keeps showing disconnected devices and + // selecting/opening them later fails on stale state. + for (const [key, s] of Array.from(sources.entries())) { + if (s.providerId === provider.id && !fresh.has(key)) { + // Close any live session but KEEP the selectedKey preference + // — the device may be replugged and should re-select. + _closeSessionInternal(key, 'device-removed'); + sources.delete(key); + } + } + } + // Restore a previously-selected source if it reappeared. + if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ } + _emit('sources-changed', { count: found }); + _contributeDiagnostics(); + return _handled(_snapshot({ discovered: found })); + } + + function _selectSource(ctx = {}) { + const payload = ctx.payload || {}; + const key = _str(payload.logicalSourceKey, ''); + if (!key) return _degraded('select-source requires a logicalSourceKey', _snapshot()); + if (!sources.has(key)) return _degraded(`Unknown MIDI source: ${key}`, _snapshot()); + selectedKey = key; + _writeStorage(key); + _emit('source-selected', { logicalSourceKey: key }); + _contributeDiagnostics(); + return _handled(_snapshot({ selected: key })); + } + + async function _openSource(ctx = {}) { + const payload = ctx.payload || {}; + const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown'); + const key = _str(payload.logicalSourceKey, selectedKey || ''); + if (!key) return _degraded('No MIDI source selected', _snapshot()); + const source = sources.get(key); + if (!source) return _degraded(`Unknown MIDI source: ${key}`, _snapshot()); + const provider = providers.get(source.providerId); + if (!provider || !provider.handlers.open) return _unavailable('Provider cannot open MIDI input', _snapshot()); + + // Share one open session per source across requesters. + let session = sessions.get(key); + if (session) { + session.refs.add(requester); + return _handled(_snapshot({ sessionId: session.sessionId, shared: true })); + } + // Coalesce concurrent opens for the same source: if a provider.open() is + // already in flight for this key, await it and adopt the resulting session + // rather than opening the device a second time. + if (opening.has(key)) { + try { await opening.get(key); } catch (_) { /* fall through to retry below */ } + session = sessions.get(key); + if (session) { + session.refs.add(requester); + return _handled(_snapshot({ sessionId: session.sessionId, shared: true })); + } + } + let handle; + const openPromise = provider.handlers.open(source.sourceId, { requester }); + opening.set(key, openPromise); + try { handle = await openPromise; } + catch (e) { return _denied(_str(e && e.message, 'Could not open MIDI input'), _snapshot()); } + finally { if (opening.get(key) === openPromise) opening.delete(key); } + // A concurrent open may have won the race while we awaited; adopt its + // session and release our redundant handle so we don't orphan a device. + const existing = sessions.get(key); + if (existing) { + if (provider.handlers.close) { + try { provider.handlers.close(source.sourceId, handle); } catch (_) { /* best-effort */ } + } + existing.refs.add(requester); + return _handled(_snapshot({ sessionId: existing.sessionId, shared: true })); + } + session = { sessionId: `mis-${key}`, refs: new Set([requester]), handle }; + sessions.set(key, session); + _emit('source-opened', { logicalSourceKey: key, requester }); + _contributeDiagnostics(); + return _handled(_snapshot({ sessionId: session.sessionId })); + } + + function _closeSessionInternal(key, reason) { + const session = sessions.get(key); + if (!session) return; + const source = sources.get(key); + const provider = source && providers.get(source.providerId); + if (provider && provider.handlers.close) { + try { provider.handlers.close(source.sourceId, session.handle); } catch (_) { /* best-effort */ } + } + sessions.delete(key); + _emit('source-closed', { logicalSourceKey: key, reason: reason || 'closed' }); + } + + function _closeSource(ctx = {}) { + const payload = ctx.payload || {}; + const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown'); + const key = _str(payload.logicalSourceKey, selectedKey || ''); + const session = sessions.get(key); + if (!session) return _handled(_snapshot({ closed: key, alreadyClosed: true })); + session.refs.delete(requester); + if (session.refs.size === 0) { + _closeSessionInternal(key, 'released'); + _contributeDiagnostics(); + } + return _handled(_snapshot({ closed: key })); + } + + capabilities.registerOwner('midi-input', { + pluginId: 'core.midi-input', + kind: 'provider-coordinator', + safety: 'sensitive', + commands: [ + 'inspect', 'list-sources', 'discover', + 'select-source', 'open-source', 'close-source', + ], + operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'], + events: [ + 'provider-registered', 'provider-unregistered', 'availability-changed', + 'sources-changed', 'source-selected', 'source-opened', 'source-closed', + ], + description: 'Core-owned MIDI device control plane: discovery, selection, and shared open/close sessions. `discover` is the Web-MIDI permission boundary.', + handlers: { + inspect: () => _handled(_snapshot()), + 'list-sources': () => _handled(_snapshot()), // prompt-free; never requests access + discover: (ctx) => _discover(ctx), // permission boundary + 'select-source': (ctx) => _selectSource(ctx), // prompt-free + 'open-source': (ctx) => _openSource(ctx), + 'close-source': (ctx) => _closeSource(ctx), + }, + }); + + // ── public global (live surface for in-page consumers) ────────────────── + // Providers register live handlers here; consumers (input_setup, piano, + // drums) get a live session handle for the "play a note" check. + window.slopsmith.midiInput = { + version: 1, + snapshot: _snapshot, + listSources: () => _sourceListShape(), + getSelected: () => selectedKey, + registerProvider: _registerProvider, + unregisterProvider: _unregisterProvider, + discover: () => _discover(), + select: (logicalSourceKey) => _selectSource({ payload: { logicalSourceKey } }), + // Returns { outcome, sessionId, handle } where handle is the provider's + // live MIDI input wrapper (exposes addListener/removeListener). The live + // handle is surfaced ONLY through this in-page global, never through the + // serializable `open-source` command payload. Use for calibration + // note/pad checks. + open: async (opts = {}) => { + const result = await _openSource({ source: opts.requester || 'in-page', payload: opts }); + const key = _str(opts.logicalSourceKey, selectedKey || ''); + const session = sessions.get(key); + return { ...result, handle: session ? session.handle : null, sessionId: session ? session.sessionId : null }; + }, + close: (opts = {}) => _closeSource({ source: opts.requester || 'in-page', payload: opts }), + }; + + // ── built-in Web-MIDI provider ────────────────────────────────────────── + // Ship a default Web-MIDI source provider so every consumer (piano, drums, + // input_setup, …) gets MIDI devices from the domain without any one plugin + // having to register the provider. Guarded by Web-MIDI support; + // requestMIDIAccess() is the permission boundary, called lazily on discover. + (function _registerBuiltinWebMidiProvider() { + if (typeof navigator === 'undefined' || typeof navigator.requestMIDIAccess !== 'function') return; + const BLOCK = /(midi through|thru|iac)/i; // loopback / passthrough ports + let access = null; + _registerProvider({ + providerId: 'web-midi', + label: 'Web MIDI', + participantId: 'core.midi-input', + enumerate: async () => { + access = await navigator.requestMIDIAccess({ sysex: false }); + try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ } + const out = []; + access.inputs.forEach((input) => { + if (BLOCK.test(input.name || '')) return; + out.push({ sourceId: input.id, label: input.name || 'MIDI input', availability: 'available' }); + }); + return out; + }, + open: async (sourceId) => { + if (!access) access = await navigator.requestMIDIAccess({ sysex: false }); + const input = access.inputs.get(sourceId); + if (!input) throw new Error('MIDI input not found'); + const listeners = new Set(); + input.onmidimessage = (e) => { listeners.forEach((fn) => { try { fn(e.data); } catch (_) { /* listener isolation */ } }); }; + return { + addListener: (fn) => { if (typeof fn === 'function') listeners.add(fn); }, + removeListener: (fn) => listeners.delete(fn), + _input: input, + }; + }, + close: (sourceId, handle) => { + if (handle && handle._input) { try { handle._input.onmidimessage = null; } catch (_) { /* best-effort */ } } + }, + }); + })(); + + _contributeDiagnostics(); +})(); diff --git a/static/index.html b/static/index.html index 01ae44c..152a2d9 100644 --- a/static/index.html +++ b/static/index.html @@ -31,6 +31,7 @@ + diff --git a/static/v3/index.html b/static/v3/index.html index cc795cb..e1dd861 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -94,6 +94,7 @@ + diff --git a/static/v3/profile.js b/static/v3/profile.js index 146fa19..75c8cb7 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -153,6 +153,55 @@ // diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway). // The profile POST always lands before the step-3 choice so onboarded=1 is // never blocked by the calibration decision. Editing keeps the single form. + // Run the input-device setup wizard (the input_setup plugin's + // `input-calibration` domain) for the chosen instrument paths — BETWEEN path + // selection and the calibration challenge — so the diagnostic runs against a + // calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch) + // and await the `calibration-done` event. Degrades gracefully when the + // plugin/runtime is absent so onboarding can never be stranded. + // Wait (bounded) for the bundled input_setup plugin to finish registering + // its input-calibration owner. Plugins load asynchronously, so onboarding + // can reach this step before the plugin is ready — without this wait the + // mandatory wizard is skipped by a load-order race (it dispatches, gets a + // no-owner outcome, and falls through to the calibration challenge). The + // public global is set at the end of the plugin's screen.js, after the + // owner is registered, so it is a reliable readiness signal. + function waitForInputSetup(timeoutMs) { + const ready = () => !!(window.slopsmithInputSetup && typeof window.slopsmithInputSetup.launch === 'function'); + return new Promise((resolve) => { + if (ready()) { resolve(true); return; } + const t0 = Date.now(); + const iv = setInterval(() => { + if (ready()) { clearInterval(iv); resolve(true); } + else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); } + }, 100); + }); + } + + async function runInputSetup(paths) { + const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase()); + if (!instruments.length) return; + // Don't let a plugin-load race skip the mandatory input-setup step. + if (!(await waitForInputSetup(8000))) return; + const caps = window.slopsmith && window.slopsmith.capabilities; + if (!caps || typeof caps.command !== 'function') { + try { await window.slopsmithInputSetup.launch(instruments); } catch (e) { /* proceed */ } + return; + } + await new Promise((resolve) => { + let settled = false; + let unsub = null; + const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); }; + try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; } + // `run` is fire-and-launch; completion arrives via the event above. + // A non-handled outcome (no owner / plugin absent / error) means + // nothing was launched, so proceed immediately. + caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } }) + .then((r) => { if (!r || r.outcome !== 'handled') done(); }) + .catch(() => done()); + }); + } + function show(profile, opts) { opts = opts || {}; const editing = !!opts.editing; @@ -160,7 +209,7 @@ const stepDots = editing ? '' : '
' + - [1, 2, 3].map((n) => '').join('') + + [1, 2, 3, 4].map((n) => '').join('') + '
'; const overlay = document.createElement('div'); @@ -184,13 +233,22 @@ '' + '' + '' + - // Step 2 — instrument paths (first-run only; tiles filled on entry). + // Step 2 — song directory (where the user's songs live). '' + + // Step 3 — instrument paths (first-run only; tiles filled on entry). + '' + - // Step 3 — calibration offer (first-run only). - '