mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
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:
parent
313348a1ff
commit
fb06e288e1
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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. |
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
46
plugins/input_setup/plugin.json
Normal file
46
plugins/input_setup/plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
353
plugins/input_setup/screen.js
Normal file
353
plugins/input_setup/screen.js
Normal 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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 isn’t 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 isn’t 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 it’s 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);
|
||||
};
|
||||
})();
|
||||
16
plugins/input_setup/settings.html
Normal file
16
plugins/input_setup/settings.html
Normal 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 & 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 && 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>
|
||||
110
specs/012-midi-input-domain/spec.md
Normal file
110
specs/012-midi-input-domain/spec.md
Normal 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).
|
||||
75
specs/013-midi-control-mappings/spec.md
Normal file
75
specs/013-midi-control-mappings/spec.md
Normal 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).
|
||||
@ -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' });
|
||||
|
||||
404
static/capabilities/midi-input.js
Normal file
404
static/capabilities/midi-input.js
Normal 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();
|
||||
})();
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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. We’ll 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 you’ll 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 couldn’t 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 {
|
||||
|
||||
216
tests/js/midi_input_domain.test.js
Normal file
216
tests/js/midi_input_domain.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user