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 = + '
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