feat(onboarding): input-device setup step + core-owned midi-input domain (#526)

* feat(capabilities): add core-owned midi-input control-plane domain (#873, #880)

The MIDI analog of audio-input: a core-owned provider-coordinator over MIDI
device discovery, selection, and shared open/close sessions. Separate from
audio-input (whose source/open contract is audio-frame-centric) and not owned
by any feature plugin, so the device-access boundary outlives the input-setup
wizard. `discover` is the Web-MIDI permission boundary; selection persists by
redaction-safe logicalSourceKey; diagnostics redact device labels and never
carry raw MIDI messages.

- static/capabilities/midi-input.js + load-order wiring in both shells
- spec 012 + capability-domains/safety-matrix entries; midi-control narrowed
  to mappings-only (split)
- 9 domain tests against the real runtime

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(input_setup): bundled plugin owning input-calibration + Web-MIDI provider (#872)

Bundled core plugin that supplies the Web-MIDI source provider to the core
midi-input domain, owns the input-calibration workflow domain (run/status/
inspect), and renders the per-instrument wizard (guitar/bass -> audio-input +
note_detect; keys/drums -> midi-input live note/pad test). Idempotent
hydration; redaction-safe. .gitignore allowlists the in-tree plugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(onboarding): input-device setup step between paths and calibration (#874)

After instrument-path selection and before the note-detect calibration
challenge, dispatch input-calibration `run` (fire-and-launch) and await the
`calibration-done` event. Fail-soft: a non-handled outcome (plugin/runtime
absent) advances immediately so onboarding can never be stranded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(midi-input): ship a built-in Web-MIDI provider in the core domain

Move the Web-MIDI source provider out of input_setup and into the core
midi-input domain so every consumer (piano, drums, input_setup) gets MIDI
devices from the domain without depending on any one plugin being loaded.
input_setup is now a pure midi-input requester (manifest role updated).
Prepares piano/drums full consumption (#876/#877). +1 domain test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(input_setup): Settings panel to re-run input setup (#878)

Adds a settings.html with a "Set up input devices" button (window
._inputSetupRelaunch) that re-runs the wizard for the player's selected
instrument paths (from /api/progression; falls back to all instruments). Makes
the calibration wizard re-launchable outside first-run onboarding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(midi-control): formalize the midi-input/midi-control split (#882)

Narrow the reserved midi-control domain to mappings ONLY (CC/pitchbend/note →
action routing), consuming the delivered midi-input domain for device access.
Adds spec 013 defining the contract + intended consumers (feedback-plugin-midi,
drums learn-mode), updates the safety-matrix row, and cross-references it from
capability-domains. Per governance, midi-control stays RESERVED (no runtime
domain) until a concrete mapping consumer + tests exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(onboarding): wait for input_setup before the calibration step (#874)

The input-setup wizard is a mandatory onboarding step, but plugins load
asynchronously — in the desktop app (40+ plugins) the user can reach path
selection and click Next before input_setup has registered its
input-calibration owner. The dispatch then got a no-owner outcome and
onboarding fell through to the calibration challenge, silently skipping the
wizard. Now wait (bounded, 8s) for the plugin's public global before
dispatching; fall through only if it never appears. Race-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(onboarding): add Song directory step after name+avatar (#874)

New first-run step (now step 2 of 4: name+avatar → song directory → paths →
calibration challenge) where the player sets their songs folder, fixing the
"folder not configured" error on a fresh install. Saves to settings (dlc_dir)
and kicks a library scan; persists to config.json so it survives restart. A
native folder picker is offered on desktop (window.slopsmithDesktop
.pickDirectory); web users type/paste the path. "Skip for now" leaves it
unconfigured (settable later in Settings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(input_setup): filter MIDI entries out of the guitar audio-input picker (#876)

Other plugins export pseudonymized MIDI sources ('midi-input-N') into the
audio-input domain; they aren't audio inputs and the cryptic labels confused
the guitar/bass device dropdown. Filter them out so only real audio inputs show.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(input_setup): de-dupe audio input picker entries (#876)

The desktop audio engine enumerates the same device under multiple driver
types, so the guitar audio-input dropdown showed repeated entries. De-dupe by
display label (paired with the desktop fix that surfaces real device names).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi-input): drop vanished devices on re-discovery; reset setup confirm on switch

Codex preflight findings:
- midi-input domain `_discover()` only upserted enumerated sources, so an
  unplugged device (statechange re-discovery) lingered in list-sources and later
  open/select hit stale state. Reconcile each provider's sources against the
  fresh enumeration (close any live session, keep the selectedKey preference).
- input_setup MIDI panel left "Continue" enabled (and the instrument marked
  done) after switching the device selection following a prior hit. Reset the
  waiting state + disable Continue on every selection change, and discard a
  stale open if the selection changed mid-await. +1 reconciliation test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi-input): coalesce concurrent opens; commit shown audio source pre-calibration

Codex re-review (round 2):
- midi-input domain: two concurrent open-source calls for the same source both
  passed the `sessions.get` guard and each called provider.open(), which for the
  built-in Web-MIDI provider overwrites the shared input.onmidimessage handler
  and orphans the earlier session — leaving the device silent. Coalesce in-flight
  opens onto one provider session (await the pending open, adopt its session;
  re-check after open and release a redundant handle if another open won). +test.
- input_setup: the guitar/bass audio <select> shows its first option by default
  but fires no `change`, so on a first run with nothing selected, audio-input was
  never told before launchCalibration(). Commit the shown option on render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi-input): longer timeout for MIDI permission commands; stale-open guard in wizard

Codex re-review (round 3):
- The advertised command surface ran `discover`/`open-source` through the 250 ms
  default handler timeout, but those front a real Web-MIDI permission prompt /
  device open that commonly takes longer, so dispatch returned `failed` while the
  operation was still completing. Add per-(capability,command) timeout overrides
  (15 s for those two), folding the existing audio-mix special-case into the same
  table so both the command() and dispatch() paths honor it.
- input_setup MIDI panel: openSelected() compared the mutable shared `activeKey`
  after its awaits, so a device switch mid-open could bind the old device's
  listener / close the wrong session. Capture the requested key in a local and
  use a generation guard to discard a superseded open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(onboarding): detect 200-with-error song-dir saves; close MIDI session on skip

Codex re-review (round 4):
- /api/settings reports an invalid folder as a 200 response with an `error` body
  (a bare dict return, not a non-2xx status), so saveSongDir's res.ok-only check
  treated the failure as success and advanced onboarding without saving. Parse
  the body and throw on `error` too.
- input_setup: the opened MIDI test session was only closed on the Continue
  button, so using the generic "Skip for now" after scanning leaked the listener
  and kept the Web-MIDI input live. Run teardown on every panel exit via a
  per-panel cleanup hook invoked by advance().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(input_setup): don't hard-code Web MIDI in the device wizard

Codex re-review (round 5): the MIDI panel gated availability on
navigator.requestMIDIAccess and filtered sources to providerId === 'web-midi',
which defeats the midi-input domain's provider-coordinator abstraction — a
native/desktop MIDI adapter registered with the domain would be reported
unavailable and hidden from the picker. Gate availability on the domain
(window.slopsmith.midiInput) and show every source it surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos 2026-06-19 16:31:16 +02:00 committed by GitHub
parent 313348a1ff
commit fb06e288e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1396 additions and 19 deletions

2
.gitignore vendored
View File

@ -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/

View File

@ -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:

View File

@ -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. |

View File

@ -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.

View File

@ -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
}
}
}

View File

@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =
'<div class="space-y-4">' +
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim">Input setup — step ' + (idx + 1) + ' of ' + queue.length + '</div>' +
'<h3 class="text-lg font-bold text-fb-text mt-0.5">Set up your ' + esc(meta.label) + '</h3></div>' +
'<div data-is-body>' + bodyHtml + '</div>' +
'<div class="flex justify-between items-center pt-1">' +
'<button type="button" data-is-skip class="text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
'<div data-is-foot>' + (footHtml || '') + '</div></div></div>';
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) =>
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
const body =
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
(sources.length
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
const foot =
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
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 <select> shows its first option by default, but no `change`
// fires for that implicit pick — so on a first run with nothing yet
// selected, audio-input would calibrate against the wrong/no source.
// Commit the shown option up-front so the displayed device is the
// one calibrated (idempotent if it was already selected).
if (!selected) commitAudio(sel.value);
}
// Tell the tuner tables / note_detect which instrument this is.
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
window.noteDetect.launchCalibration({
instrument: inst,
onDone: () => advance(inst, true),
onCancel: () => { /* stay on this panel; user can skip or retry */ },
});
} else {
advance(inst, true);
}
});
}
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
async function renderMidiPanel(inst) {
const mi = window.slopsmith.midiInput;
// Availability is the midi-input DOMAIN being present, not the
// Web-MIDI browser API — the domain coordinates providers (the
// built-in Web-MIDI one, plus any native/desktop adapter), so
// gating on navigator.requestMIDIAccess would hide a usable
// non-Web-MIDI provider before discover() is ever called.
const midiAvailable = !!(mi && mi.version === 1);
if (!midiAvailable) {
shell(inst,
'<p class="text-sm text-fb-accent">MIDI input isnt available here. Connect a MIDI keyboard/e-kit in a supported environment, or skip for now.</p>',
'<button type="button" data-is-skip2 class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">Continue</button>');
host.querySelector('[data-is-skip2]').addEventListener('click', () => advance(inst, false));
return;
}
const verb = inst === 'drums' ? 'hit a pad' : 'play a note';
shell(inst,
'<p class="text-sm text-fb-textDim">Connect your MIDI device, pick it below, then ' + verb + ' to confirm its working.</p>' +
'<div class="mt-3 flex items-center gap-2">' +
'<button type="button" data-is-scan class="text-sm text-fb-primary hover:text-fb-primaryHi">Scan for MIDI devices</button></div>' +
'<div data-is-midi-wrap class="hidden mt-2">' +
'<select data-is-midi class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none"></select>' +
'<p data-is-test class="text-sm text-fb-textDim mt-2">Waiting for input…</p></div>',
'<button type="button" data-is-next disabled class="bg-fb-primary disabled:opacity-40 text-white px-5 py-2 rounded-md font-medium">Continue</button>');
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 = '<span class="text-fb-primary font-semibold">✓ Got it</span> — 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 = '<option>No MIDI devices found</option>'; select.disabled = true; return; }
wrap.classList.remove('hidden');
select.disabled = false;
select.innerHTML = sources.map((s) => '<option value="' + esc(s.logicalSourceKey) + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label) + '</option>').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 = '<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6" data-is-host></div>';
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);
};
})();

View File

@ -0,0 +1,16 @@
<div class="space-y-4 py-2">
<div class="bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<h3 class="text-sm font-medium text-gray-200">Input devices &amp; calibration</h3>
<p class="text-[11px] text-gray-500 mt-1">
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.
</p>
<button type="button"
onclick="window._inputSetupRelaunch &amp;&amp; window._inputSetupRelaunch()"
class="mt-3 px-4 py-2 bg-accent hover:bg-accent-light text-white text-sm font-medium rounded-lg">
Set up input devices
</button>
<p id="input-setup-settings-status" class="text-[11px] text-gray-500 mt-2"></p>
</div>
</div>

View File

@ -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).

View File

@ -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).

View File

@ -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' });

View File

@ -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<requester>, 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();
})();

View File

@ -31,6 +31,7 @@
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="bg-dark-900 text-gray-200 font-display">

View File

@ -94,6 +94,7 @@
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">

View File

@ -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 ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
@ -184,13 +233,22 @@
'<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
'<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
'<span id="v3-ob-preview"></span></div></div></div>' +
// Step 2 — instrument paths (first-run only; tiles filled on entry).
// Step 2 — song directory (where the user's songs live).
'<div id="v3-ob-step2" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Song directory</label>' +
'<p class="text-sm text-fb-textDim mb-3">Choose the folder where your songs are stored. Well scan it to build your library. You can change this later in Settings.</p>' +
'<div class="flex gap-2">' +
'<input id="v3-ob-songdir" type="text" placeholder="Path to your songs folder" ' +
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
'</div></div>' +
// Step 3 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 3 — calibration offer (first-run only).
'<div id="v3-ob-step3" class="hidden">' +
// Step 4 — calibration offer (first-run only).
'<div id="v3-ob-step4" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@ -211,9 +269,12 @@
const skipBtn = overlay.querySelector('#v3-ob-skip');
let selected = null; // { type:'default', value } | { type:'upload', value:url }
let step = 1; // first-run wizard step (editing stays on 1)
let selectedPaths = []; // step-2 picks
let songDir = ''; // step-2 song directory pick
let selectedPaths = []; // step-3 picks
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
let diagnosticFilename = null; // from /api/progression (step-3 "Play it now")
let diagnosticFilename = null; // from /api/progression (step-4 "Play it now")
const songDirEl = overlay.querySelector('#v3-ob-songdir');
const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse');
if (editing && profile) {
nameEl.value = profile.display_name || '';
@ -229,6 +290,10 @@
const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
} else if (step === 2) {
// Song directory — require a non-empty path to proceed; "Skip
// for now" is available for users who'll set it later.
submit.disabled = !songDir.trim();
} else if (step === 3) {
// ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1;
@ -240,7 +305,7 @@
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= 4; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@ -250,11 +315,14 @@
const subtitle = overlay.querySelector('#v3-ob-subtitle');
if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Choose your instrument paths'
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 3 ? 'Play it now' : 'Next';
skipBtn.classList.toggle('hidden', n !== 3);
submit.textContent = n === 4 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4));
refreshSubmit();
}
@ -347,6 +415,43 @@
if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());
// ── Song directory (step 2) ──────────────────────────────────────────
if (songDirEl) {
songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); });
}
// Native folder picker on desktop; web users type/paste the path.
const _desktop = window.slopsmithDesktop;
if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') {
songDirBrowse.classList.remove('hidden');
songDirBrowse.addEventListener('click', async () => {
try {
const picked = await _desktop.pickDirectory();
if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); }
} catch (e) { /* user cancelled / unavailable */ }
});
}
// Save the song directory to settings + kick a library scan so the
// user's songs appear. Throws (with a message) on an invalid folder.
async function saveSongDir() {
const dir = ((songDirEl && songDirEl.value) || '').trim();
if (!dir) return; // skipped — leave unconfigured (settable later)
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dlc_dir: dir }),
});
// /api/settings reports an invalid folder as a 200 with an `error`
// field (a bare dict return, not a non-2xx status), so a res.ok-only
// check would treat the failure as success and advance without saving.
// Inspect the body too.
let data = null;
try { data = await res.json(); } catch (e) { /* non-JSON body */ }
if (!res.ok || (data && data.error)) {
throw new Error((data && data.error) || 'That folder couldnt be set — check the path and try again.');
}
// Non-fatal: scan kicks off the library build in the background.
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ }
}
async function postProfile() {
const res = await fetch('/api/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -380,12 +485,23 @@
}
if (step === 1) {
setStep(2);
loadPathTiles();
setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50);
return;
}
if (step === 2) {
// Save the song directory + kick a library scan, then continue
// to instrument paths. "Skip for now" leaves it unconfigured.
submit.disabled = true;
try {
await saveSongDir();
setStep(3);
loadPathTiles();
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
return;
}
if (step === 3) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at step 3 can never lose the profile.
// so closing the overlay at the challenge can never lose the profile.
submit.disabled = true;
try {
_profile = await postProfile();
@ -403,11 +519,14 @@
throw new Error(msg);
}
}
setStep(3);
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(4);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 3 — "Play it now": leave calibration pending (it completes
// Step 4 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish();
@ -415,7 +534,14 @@
});
skipBtn.addEventListener('click', async () => {
// Step 3 — skip: Mastery Rank 1 immediately, calibration stays
// Step 2 — skip the song directory (the user can set it later in
// Settings). Proceed straight to instrument paths.
if (step === 2) {
setStep(3);
loadPathTiles();
return;
}
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
skipBtn.disabled = true;
try {

View File

@ -0,0 +1,216 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const MIDI_INPUT_JS = path.join(ROOT, 'static', 'capabilities', 'midi-input.js');
function loadMidiInput(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
return window;
}
// A fake provider whose enumerate/open/close are observable by the test.
function fakeProvider(window, overrides = {}) {
const calls = { enumerate: 0, open: [], close: [] };
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi',
label: 'Web MIDI',
participantId: 'input_setup',
enumerate: async () => { calls.enumerate += 1; return overrides.sources || [{ sourceId: 'dev1', label: 'My Keyboard' }]; },
open: async (sourceId) => { calls.open.push(sourceId); return { addListener() {}, removeListener() {}, _id: sourceId }; },
close: (sourceId, handle) => { calls.close.push(sourceId); },
...overrides.handlers,
});
return calls;
}
test('midi-input registers an active sensitive provider-coordinator', () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const pipeline = api.inspect('midi-input');
assert.ok(pipeline, 'midi-input pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.midi-input');
assert.ok(owner, 'core.midi-input owner registered');
assert.equal(owner.safety, 'sensitive');
assert.equal(owner.kind, 'provider-coordinator');
for (const cmd of ['inspect', 'list-sources', 'discover', 'select-source', 'open-source', 'close-source']) {
assert.ok(owner.commands.includes(cmd), `owner exposes ${cmd}`);
}
assert.equal(window.slopsmith.midiInput.version, 1);
});
test('list-sources and select-source are prompt-free (never enumerate)', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
const listed = await api.dispatch({ capability: 'midi-input', command: 'list-sources', source: 'tester' });
assert.equal(listed.outcome, 'handled');
assert.equal(calls.enumerate, 0, 'list-sources must not request MIDI access');
});
test('discover is the permission boundary and surfaces sources', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'handled');
assert.equal(calls.enumerate, 1, 'discover requests MIDI access exactly once');
const sources = window.slopsmith.midiInput.listSources();
assert.equal(sources.length, 1);
assert.equal(sources[0].logicalSourceKey, 'web-midi::dev1');
assert.equal(sources[0].kind, 'midi');
});
test('re-discovery drops sources for devices that vanished', async () => {
const window = loadMidiInput();
let devices = [{ sourceId: 'dev1', label: 'A' }, { sourceId: 'dev2', label: 'B' }];
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi', label: 'Web MIDI',
enumerate: async () => devices,
open: async () => ({ addListener() {}, removeListener() {} }),
close: () => {},
});
await window.slopsmith.midiInput.discover();
assert.equal(window.slopsmith.midiInput.listSources().length, 2);
devices = [{ sourceId: 'dev1', label: 'A' }]; // dev2 unplugged
await window.slopsmith.midiInput.discover();
const keys = window.slopsmith.midiInput.listSources().map((s) => s.logicalSourceKey);
assert.equal(keys.length, 1, 'vanished device is dropped from the source list');
assert.equal(keys[0], 'web-midi::dev1');
});
test('discover with no provider reports unavailable', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'unavailable');
});
test('discover surfaces denied when MIDI access is rejected', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
fakeProvider(window, { handlers: { enumerate: async () => { throw new Error('SecurityError: permission denied'); } } });
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'denied');
assert.match(r.reason, /denied/i);
});
test('select-source persists by logicalSourceKey', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
fakeProvider(window);
await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
const sel = await api.dispatch({ capability: 'midi-input', command: 'select-source', source: 'tester', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(sel.outcome, 'handled');
assert.equal(window.__storage.get('slopsmith.midiInput.selectedLogicalSourceKey'), 'web-midi::dev1');
assert.ok(window.slopsmith.midiInput.listSources()[0].selected);
});
test('open/close share one session and release on the last requester', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const a = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
const b = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(a.outcome, 'handled');
assert.equal(b.outcome, 'handled');
assert.equal(calls.open.length, 1, 'provider.open called once for a shared session');
// First release keeps the session open; second closes it.
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close.length, 0, 'session stays open while a requester holds it');
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close.length, 1, 'provider.close after the last release');
});
test('concurrent opens for one source coalesce onto a single provider.open', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
// A provider whose open() stays pending until we release it, so both
// dispatches are genuinely in flight at the same time.
let release;
const gate = new Promise((r) => { release = r; });
const calls = { open: 0, close: 0 };
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi', label: 'Web MIDI',
enumerate: async () => [{ sourceId: 'dev1', label: 'My Keyboard' }],
open: async () => { calls.open += 1; await gate; return { addListener() {}, removeListener() {} }; },
close: () => { calls.close += 1; },
});
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const p1 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
const p2 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
release();
const [a, b] = await Promise.all([p1, p2]);
assert.equal(a.outcome, 'handled');
assert.equal(b.outcome, 'handled');
assert.equal(calls.open, 1, 'provider.open called exactly once despite concurrent opens');
// Both requesters joined the single shared session: it survives the first
// release and only closes on the last, with exactly one provider.close.
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close, 0, 'shared session stays open while reqB holds it');
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close, 1, 'provider.close once after the last requester releases');
});
test('public open() surfaces the live handle (in-page only)', async () => {
const window = loadMidiInput();
fakeProvider(window);
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const res = await window.slopsmith.midiInput.open({ requester: 'input_setup', logicalSourceKey: 'web-midi::dev1' });
assert.equal(res.outcome, 'handled');
assert.ok(res.handle && typeof res.handle.addListener === 'function', 'live handle exposed via public global');
});
// Load the domain with a Web-MIDI-capable navigator so the built-in provider
// self-registers (the shared harness has no navigator, so it normally skips).
function loadWithWebMidi(inputs) {
const window = createWindow();
window.navigator = {
requestMIDIAccess: async () => ({
onstatechange: null,
inputs: new Map(inputs.map((i) => [i.id, { id: i.id, name: i.name, onmidimessage: null }])),
}),
};
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
return window;
}
test('built-in Web-MIDI provider self-registers + discovers, filtering loopback ports', async () => {
const window = loadWithWebMidi([
{ id: 'kb1', name: 'My Keyboard' },
{ id: 'thru', name: 'Midi Through Port-0' }, // loopback → filtered out
]);
const api = window.slopsmith.capabilities;
assert.ok(api.inspect('midi-input').participants.some(p => p.pluginId === 'core.midi-input'),
'built-in provider registered without any plugin');
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'handled');
const sources = window.slopsmith.midiInput.listSources();
assert.equal(sources.length, 1, 'loopback/passthrough ports are filtered');
assert.equal(sources[0].logicalSourceKey, 'web-midi::kb1');
});
test('diagnostics are redaction-safe (no device labels, no raw messages)', async () => {
const window = loadMidiInput();
fakeProvider(window);
await window.slopsmith.midiInput.discover();
const contrib = window.slopsmith.diagnostics.snapshotContributions()['midi-input-capability'];
assert.ok(contrib, 'midi-input contributes diagnostics');
assert.equal(contrib.schema, 'slopsmith.midi_input.diagnostics.v1');
const serialized = JSON.stringify(contrib);
assert.ok(!serialized.includes('My Keyboard'), 'device labels are redacted from diagnostics');
for (const s of contrib.sources) assert.ok(!('label' in s), 'source entries carry no label');
});