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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-19 16:31:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 313348a1ff
commit fb06e288e1
15 changed files with 1396 additions and 19 deletions
+15 -2
View File
@@ -79,6 +79,19 @@
const MAX_DECISIONS = 100;
const MAX_SNAPSHOT_BYTES = 64 * 1024;
const DEFAULT_HANDLER_TIMEOUT_MS = 250;
// Per-(capability, command) handler-timeout overrides. A few commands front a
// user-action / OS-permission prompt (fader reads that await a UI; MIDI/mic
// device access) that legitimately runs far longer than the default budget,
// so the dispatch/command surface must not fail them at 250 ms while the
// operation is still completing through the provider.
const COMMAND_TIMEOUTS_MS = {
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
'midi-input': { 'discover': 15000, 'open-source': 15000 },
};
function _commandTimeoutFor(capability, commandName) {
const byCap = COMMAND_TIMEOUTS_MS[capability];
return byCap ? byCap[commandName] : undefined;
}
const RESERVED_FUTURE_DOMAINS = new Set([
'ui.navigation',
'ui.plugin-screens',
@@ -960,7 +973,7 @@
if (typeof handler !== 'function') continue;
let decision;
try {
const timeoutMs = Number(commandContext.timeoutMs || DEFAULT_HANDLER_TIMEOUT_MS);
const timeoutMs = Number(commandContext.timeoutMs || _commandTimeoutFor(capabilityName, commandName) || DEFAULT_HANDLER_TIMEOUT_MS);
const result = await _withTimeout(Promise.resolve(handler(commandContext)), timeoutMs, participant);
decision = _normalizeDecision(participant, result);
} catch (err) {
@@ -1376,7 +1389,7 @@
target: source.target || source.args?.target || null,
payload: source.args || source.payload || {},
claim: source.claim,
timeoutMs: source.timeoutMs || (capability === 'audio-mix' && (commandName === 'get-fader-value' || commandName === 'set-fader-value') ? 2100 : undefined),
timeoutMs: source.timeoutMs || _commandTimeoutFor(capability, commandName),
});
const status = _dispatchStatus(result);
_emitEvent(capability, 'dispatched', { command: commandName, status, result, source: source.source || source.requester || 'dispatch' });
+404
View File
@@ -0,0 +1,404 @@
// Core MIDI-input capability domain (spec 012 control plane).
//
// The MIDI analog of `audio-input`: a core-owned provider-coordinator over MIDI
// device discovery, selection, and open/close session lifecycle. It is NOT
// owned by any feature plugin (an input plane outlives any feature, exactly as
// `audio-input` is `core.audio.session`-owned), and it is deliberately separate
// from `audio-input` (whose source/open contract is audio-frame-centric:
// channel shapes, sample buffers) — MIDI carries discrete messages, not audio.
//
// Consumers (the input_setup wizard, the piano/keys and drums plugins, and —
// later — note-detection's Web-MIDI provider) converge on ONE device-access
// boundary here: one permission prompt, one source list, one redaction
// boundary, replacing private per-plugin `navigator.requestMIDIAccess()` calls.
//
// Web-MIDI nuance vs audio: `requestMIDIAccess()` gates the whole input LIST, so
// `discover` (not `open-source`) is the permission boundary for MIDI. `inspect`
// / `list-sources` / `select-source` stay prompt-free and never request access.
//
// Live message delivery (needed by the "play a note / hit a pad" calibration
// check) is exposed to in-page consumers through the public global's session
// handle, never as raw capability events or diagnostics.
(function () {
'use strict';
window.slopsmith = window.slopsmith || {};
const capabilities = window.slopsmith.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.slopsmith.midiInput && window.slopsmith.midiInput.version === 1) return;
const STORAGE_KEY = 'slopsmith.midiInput.selectedLogicalSourceKey';
// providerId → { id, label, participantId, handlers:{ enumerate, open, close } }
// handlers are LIVE functions supplied in-page via the public global; they
// never travel through the capability `command` payload.
const providers = new Map();
// logicalSourceKey → { sourceId, providerId, logicalSourceKey, kind, label, availability }
const sources = new Map();
// logicalSourceKey → { refs:Set<requester>, handle } — one shared open
// session per source; the provider is closed only after the last release.
const sessions = new Map();
// logicalSourceKey → Promise — in-flight provider.open() calls, so concurrent
// opens for the same source coalesce onto one provider session instead of each
// calling provider.open() (which, for Web-MIDI, would overwrite the shared
// input.onmidimessage handler and orphan the earlier session/handle).
const opening = new Map();
let selectedKey = _readStorage();
let lastOutcome = null;
// ── outcome helpers (mirror note-detection.js) ──────────────────────────
function _handled(payload = {}) { lastOutcome = { outcome: 'handled' }; return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { lastOutcome = { outcome: 'degraded', reason }; return { outcome: 'degraded', reason, payload }; }
function _denied(reason, payload = {}) { lastOutcome = { outcome: 'denied', reason }; return { outcome: 'denied', reason, payload }; }
function _unavailable(reason, payload = {}) { lastOutcome = { outcome: 'unavailable', reason }; return { outcome: 'unavailable', reason, payload }; }
function _emit(name, detail) {
try { capabilities.emitEvent('midi-input', name, detail || {}); }
catch (_) { /* eventing must not break input */ }
}
function _readStorage() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _writeStorage(key) {
try { if (key) window.localStorage.setItem(STORAGE_KEY, key); else window.localStorage.removeItem(STORAGE_KEY); return true; }
catch (_) { return false; }
}
function _str(v, fallback) { const s = (v == null ? '' : String(v)).trim(); return s || fallback; }
// A stable, redaction-safe key for persistence: provider + a stable source
// id, NOT the human device label.
function _logicalKey(providerId, sourceId) { return `${providerId}::${sourceId}`; }
// ── snapshots ───────────────────────────────────────────────────────────
// listShape keeps labels (this feeds the picker UI); diagShape strips them
// (device labels are PII-adjacent).
function _sourceListShape() {
return Array.from(sources.values()).map((s) => ({
logicalSourceKey: s.logicalSourceKey,
sourceId: s.sourceId,
providerId: s.providerId,
kind: s.kind,
label: s.label,
availability: s.availability,
selected: s.logicalSourceKey === selectedKey,
open: sessions.has(s.logicalSourceKey),
}));
}
function _snapshot(extra = {}) {
return {
available: providers.size > 0,
providers: Array.from(providers.values()).map((p) => ({ id: p.id, label: p.label })),
sources: _sourceListShape(),
selected: selectedKey,
openSessions: Array.from(sessions.keys()),
lastOutcome: lastOutcome ? { ...lastOutcome } : null,
...extra,
};
}
function _contributeDiagnostics() {
const diagnostics = window.slopsmith && window.slopsmith.diagnostics;
if (!diagnostics || typeof diagnostics.contribute !== 'function') return;
try {
const snap = _snapshot();
// Redact device labels everywhere — keep ids/kind/availability for
// operational observability only. No raw MIDI messages ever.
const redacted = {
...snap,
providers: snap.providers.map(({ label: _l, ...safe }) => safe),
sources: snap.sources.map(({ label: _l, ...safe }) => safe),
};
diagnostics.contribute('midi-input-capability', {
schema: 'slopsmith.midi_input.diagnostics.v1',
...redacted,
});
} catch (_) { /* diagnostics must not break input */ }
}
// ── provider registry (live handlers via the public global) ─────────────
function _registerProvider(input = {}) {
const providerId = _str(input.providerId || input.id, '');
if (!providerId) return null;
const handlers = {
enumerate: typeof input.enumerate === 'function' ? input.enumerate : null,
open: typeof input.open === 'function' ? input.open : null,
close: typeof input.close === 'function' ? input.close : null,
};
const participantId = _str(input.participantId, providerId);
const wasAvailable = providers.size > 0;
providers.set(providerId, { id: providerId, label: _str(input.label, providerId), participantId, handlers });
// Mirror a serializable declaration into the capability graph so the
// Inspector/diagnostics can reason about the provider relationship.
try {
capabilities.registerParticipant(participantId, {
'midi-input': {
roles: ['provider'],
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
mode: 'active',
safety: 'sensitive',
runtime: true,
description: `MIDI input provider ${_str(input.label, providerId)}.`,
provider_policy: { providerId },
},
});
} catch (_) { /* declaration is best-effort */ }
_emit('provider-registered', { providerId });
if (!wasAvailable) _emit('availability-changed', { available: true });
_contributeDiagnostics();
return { providerId };
}
function _unregisterProvider(providerId) {
providerId = _str(providerId, '');
const provider = providers.get(providerId);
if (!provider) return false;
// Drop the provider's sources + any open sessions.
for (const [key, s] of Array.from(sources.entries())) {
if (s.providerId === providerId) {
_closeSessionInternal(key, 'provider-unregistered');
sources.delete(key);
}
}
providers.delete(providerId);
if (typeof capabilities.unregisterParticipant === 'function') {
try { capabilities.unregisterParticipant(provider.participantId, 'midi-input'); } catch (_) { /* best-effort */ }
}
_emit('provider-unregistered', { providerId });
if (providers.size === 0) _emit('availability-changed', { available: false });
_contributeDiagnostics();
return true;
}
// ── discovery (the Web-MIDI permission boundary) ────────────────────────
async function _discover() {
if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot());
let found = 0;
for (const provider of providers.values()) {
if (!provider.handlers.enumerate) continue;
let list;
try { list = await provider.handlers.enumerate(); }
catch (e) {
// requestMIDIAccess rejection = permission denied / unsupported.
return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot());
}
const fresh = new Set();
for (const raw of (Array.isArray(list) ? list : [])) {
const sourceId = _str(raw.sourceId || raw.id, '');
if (!sourceId) continue;
const key = _logicalKey(provider.id, sourceId);
fresh.add(key);
sources.set(key, {
sourceId,
providerId: provider.id,
logicalSourceKey: key,
kind: 'midi',
label: _str(raw.label || raw.name, 'MIDI input'),
availability: _str(raw.availability, 'available'),
});
found += 1;
}
// Reconcile: drop this provider's sources that vanished since the
// last enumeration (e.g. a device unplugged, firing statechange).
// Without this, list-sources keeps showing disconnected devices and
// selecting/opening them later fails on stale state.
for (const [key, s] of Array.from(sources.entries())) {
if (s.providerId === provider.id && !fresh.has(key)) {
// Close any live session but KEEP the selectedKey preference
// — the device may be replugged and should re-select.
_closeSessionInternal(key, 'device-removed');
sources.delete(key);
}
}
}
// Restore a previously-selected source if it reappeared.
if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ }
_emit('sources-changed', { count: found });
_contributeDiagnostics();
return _handled(_snapshot({ discovered: found }));
}
function _selectSource(ctx = {}) {
const payload = ctx.payload || {};
const key = _str(payload.logicalSourceKey, '');
if (!key) return _degraded('select-source requires a logicalSourceKey', _snapshot());
if (!sources.has(key)) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
selectedKey = key;
_writeStorage(key);
_emit('source-selected', { logicalSourceKey: key });
_contributeDiagnostics();
return _handled(_snapshot({ selected: key }));
}
async function _openSource(ctx = {}) {
const payload = ctx.payload || {};
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
const key = _str(payload.logicalSourceKey, selectedKey || '');
if (!key) return _degraded('No MIDI source selected', _snapshot());
const source = sources.get(key);
if (!source) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
const provider = providers.get(source.providerId);
if (!provider || !provider.handlers.open) return _unavailable('Provider cannot open MIDI input', _snapshot());
// Share one open session per source across requesters.
let session = sessions.get(key);
if (session) {
session.refs.add(requester);
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
}
// Coalesce concurrent opens for the same source: if a provider.open() is
// already in flight for this key, await it and adopt the resulting session
// rather than opening the device a second time.
if (opening.has(key)) {
try { await opening.get(key); } catch (_) { /* fall through to retry below */ }
session = sessions.get(key);
if (session) {
session.refs.add(requester);
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
}
}
let handle;
const openPromise = provider.handlers.open(source.sourceId, { requester });
opening.set(key, openPromise);
try { handle = await openPromise; }
catch (e) { return _denied(_str(e && e.message, 'Could not open MIDI input'), _snapshot()); }
finally { if (opening.get(key) === openPromise) opening.delete(key); }
// A concurrent open may have won the race while we awaited; adopt its
// session and release our redundant handle so we don't orphan a device.
const existing = sessions.get(key);
if (existing) {
if (provider.handlers.close) {
try { provider.handlers.close(source.sourceId, handle); } catch (_) { /* best-effort */ }
}
existing.refs.add(requester);
return _handled(_snapshot({ sessionId: existing.sessionId, shared: true }));
}
session = { sessionId: `mis-${key}`, refs: new Set([requester]), handle };
sessions.set(key, session);
_emit('source-opened', { logicalSourceKey: key, requester });
_contributeDiagnostics();
return _handled(_snapshot({ sessionId: session.sessionId }));
}
function _closeSessionInternal(key, reason) {
const session = sessions.get(key);
if (!session) return;
const source = sources.get(key);
const provider = source && providers.get(source.providerId);
if (provider && provider.handlers.close) {
try { provider.handlers.close(source.sourceId, session.handle); } catch (_) { /* best-effort */ }
}
sessions.delete(key);
_emit('source-closed', { logicalSourceKey: key, reason: reason || 'closed' });
}
function _closeSource(ctx = {}) {
const payload = ctx.payload || {};
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
const key = _str(payload.logicalSourceKey, selectedKey || '');
const session = sessions.get(key);
if (!session) return _handled(_snapshot({ closed: key, alreadyClosed: true }));
session.refs.delete(requester);
if (session.refs.size === 0) {
_closeSessionInternal(key, 'released');
_contributeDiagnostics();
}
return _handled(_snapshot({ closed: key }));
}
capabilities.registerOwner('midi-input', {
pluginId: 'core.midi-input',
kind: 'provider-coordinator',
safety: 'sensitive',
commands: [
'inspect', 'list-sources', 'discover',
'select-source', 'open-source', 'close-source',
],
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
events: [
'provider-registered', 'provider-unregistered', 'availability-changed',
'sources-changed', 'source-selected', 'source-opened', 'source-closed',
],
description: 'Core-owned MIDI device control plane: discovery, selection, and shared open/close sessions. `discover` is the Web-MIDI permission boundary.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-sources': () => _handled(_snapshot()), // prompt-free; never requests access
discover: (ctx) => _discover(ctx), // permission boundary
'select-source': (ctx) => _selectSource(ctx), // prompt-free
'open-source': (ctx) => _openSource(ctx),
'close-source': (ctx) => _closeSource(ctx),
},
});
// ── public global (live surface for in-page consumers) ──────────────────
// Providers register live handlers here; consumers (input_setup, piano,
// drums) get a live session handle for the "play a note" check.
window.slopsmith.midiInput = {
version: 1,
snapshot: _snapshot,
listSources: () => _sourceListShape(),
getSelected: () => selectedKey,
registerProvider: _registerProvider,
unregisterProvider: _unregisterProvider,
discover: () => _discover(),
select: (logicalSourceKey) => _selectSource({ payload: { logicalSourceKey } }),
// Returns { outcome, sessionId, handle } where handle is the provider's
// live MIDI input wrapper (exposes addListener/removeListener). The live
// handle is surfaced ONLY through this in-page global, never through the
// serializable `open-source` command payload. Use for calibration
// note/pad checks.
open: async (opts = {}) => {
const result = await _openSource({ source: opts.requester || 'in-page', payload: opts });
const key = _str(opts.logicalSourceKey, selectedKey || '');
const session = sessions.get(key);
return { ...result, handle: session ? session.handle : null, sessionId: session ? session.sessionId : null };
},
close: (opts = {}) => _closeSource({ source: opts.requester || 'in-page', payload: opts }),
};
// ── built-in Web-MIDI provider ──────────────────────────────────────────
// Ship a default Web-MIDI source provider so every consumer (piano, drums,
// input_setup, …) gets MIDI devices from the domain without any one plugin
// having to register the provider. Guarded by Web-MIDI support;
// requestMIDIAccess() is the permission boundary, called lazily on discover.
(function _registerBuiltinWebMidiProvider() {
if (typeof navigator === 'undefined' || typeof navigator.requestMIDIAccess !== 'function') return;
const BLOCK = /(midi through|thru|iac)/i; // loopback / passthrough ports
let access = null;
_registerProvider({
providerId: 'web-midi',
label: 'Web MIDI',
participantId: 'core.midi-input',
enumerate: async () => {
access = await navigator.requestMIDIAccess({ sysex: false });
try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ }
const out = [];
access.inputs.forEach((input) => {
if (BLOCK.test(input.name || '')) return;
out.push({ sourceId: input.id, label: input.name || 'MIDI input', availability: 'available' });
});
return out;
},
open: async (sourceId) => {
if (!access) access = await navigator.requestMIDIAccess({ sysex: false });
const input = access.inputs.get(sourceId);
if (!input) throw new Error('MIDI input not found');
const listeners = new Set();
input.onmidimessage = (e) => { listeners.forEach((fn) => { try { fn(e.data); } catch (_) { /* listener isolation */ } }); };
return {
addListener: (fn) => { if (typeof fn === 'function') listeners.add(fn); },
removeListener: (fn) => listeners.delete(fn),
_input: input,
};
},
close: (sourceId, handle) => {
if (handle && handle._input) { try { handle._input.onmidimessage = null; } catch (_) { /* best-effort */ } }
},
});
})();
_contributeDiagnostics();
})();
+1
View File
@@ -31,6 +31,7 @@
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="bg-dark-900 text-gray-200 font-display">
+1
View File
@@ -94,6 +94,7 @@
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
+141 -15
View File
@@ -153,6 +153,55 @@
// diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway).
// The profile POST always lands before the step-3 choice so onboarded=1 is
// never blocked by the calibration decision. Editing keeps the single form.
// Run the input-device setup wizard (the input_setup plugin's
// `input-calibration` domain) for the chosen instrument paths — BETWEEN path
// selection and the calibration challenge — so the diagnostic runs against a
// calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch)
// and await the `calibration-done` event. Degrades gracefully when the
// plugin/runtime is absent so onboarding can never be stranded.
// Wait (bounded) for the bundled input_setup plugin to finish registering
// its input-calibration owner. Plugins load asynchronously, so onboarding
// can reach this step before the plugin is ready — without this wait the
// mandatory wizard is skipped by a load-order race (it dispatches, gets a
// no-owner outcome, and falls through to the calibration challenge). The
// public global is set at the end of the plugin's screen.js, after the
// owner is registered, so it is a reliable readiness signal.
function waitForInputSetup(timeoutMs) {
const ready = () => !!(window.slopsmithInputSetup && typeof window.slopsmithInputSetup.launch === 'function');
return new Promise((resolve) => {
if (ready()) { resolve(true); return; }
const t0 = Date.now();
const iv = setInterval(() => {
if (ready()) { clearInterval(iv); resolve(true); }
else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); }
}, 100);
});
}
async function runInputSetup(paths) {
const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase());
if (!instruments.length) return;
// Don't let a plugin-load race skip the mandatory input-setup step.
if (!(await waitForInputSetup(8000))) return;
const caps = window.slopsmith && window.slopsmith.capabilities;
if (!caps || typeof caps.command !== 'function') {
try { await window.slopsmithInputSetup.launch(instruments); } catch (e) { /* proceed */ }
return;
}
await new Promise((resolve) => {
let settled = false;
let unsub = null;
const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
// `run` is fire-and-launch; completion arrives via the event above.
// A non-handled outcome (no owner / plugin absent / error) means
// nothing was launched, so proceed immediately.
caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
.then((r) => { if (!r || r.outcome !== 'handled') done(); })
.catch(() => done());
});
}
function show(profile, opts) {
opts = opts || {};
const editing = !!opts.editing;
@@ -160,7 +209,7 @@
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
@@ -184,13 +233,22 @@
'<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
'<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
'<span id="v3-ob-preview"></span></div></div></div>' +
// Step 2 — instrument paths (first-run only; tiles filled on entry).
// Step 2 — song directory (where the user's songs live).
'<div id="v3-ob-step2" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Song directory</label>' +
'<p class="text-sm text-fb-textDim mb-3">Choose the folder where your songs are stored. Well scan it to build your library. You can change this later in Settings.</p>' +
'<div class="flex gap-2">' +
'<input id="v3-ob-songdir" type="text" placeholder="Path to your songs folder" ' +
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
'</div></div>' +
// Step 3 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 3 — calibration offer (first-run only).
'<div id="v3-ob-step3" class="hidden">' +
// Step 4 — calibration offer (first-run only).
'<div id="v3-ob-step4" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@@ -211,9 +269,12 @@
const skipBtn = overlay.querySelector('#v3-ob-skip');
let selected = null; // { type:'default', value } | { type:'upload', value:url }
let step = 1; // first-run wizard step (editing stays on 1)
let selectedPaths = []; // step-2 picks
let songDir = ''; // step-2 song directory pick
let selectedPaths = []; // step-3 picks
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
let diagnosticFilename = null; // from /api/progression (step-3 "Play it now")
let diagnosticFilename = null; // from /api/progression (step-4 "Play it now")
const songDirEl = overlay.querySelector('#v3-ob-songdir');
const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse');
if (editing && profile) {
nameEl.value = profile.display_name || '';
@@ -229,6 +290,10 @@
const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
} else if (step === 2) {
// Song directory — require a non-empty path to proceed; "Skip
// for now" is available for users who'll set it later.
submit.disabled = !songDir.trim();
} else if (step === 3) {
// ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1;
@@ -240,7 +305,7 @@
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= 4; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@@ -250,11 +315,14 @@
const subtitle = overlay.querySelector('#v3-ob-subtitle');
if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Choose your instrument paths'
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 3 ? 'Play it now' : 'Next';
skipBtn.classList.toggle('hidden', n !== 3);
submit.textContent = n === 4 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4));
refreshSubmit();
}
@@ -347,6 +415,43 @@
if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());
// ── Song directory (step 2) ──────────────────────────────────────────
if (songDirEl) {
songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); });
}
// Native folder picker on desktop; web users type/paste the path.
const _desktop = window.slopsmithDesktop;
if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') {
songDirBrowse.classList.remove('hidden');
songDirBrowse.addEventListener('click', async () => {
try {
const picked = await _desktop.pickDirectory();
if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); }
} catch (e) { /* user cancelled / unavailable */ }
});
}
// Save the song directory to settings + kick a library scan so the
// user's songs appear. Throws (with a message) on an invalid folder.
async function saveSongDir() {
const dir = ((songDirEl && songDirEl.value) || '').trim();
if (!dir) return; // skipped — leave unconfigured (settable later)
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dlc_dir: dir }),
});
// /api/settings reports an invalid folder as a 200 with an `error`
// field (a bare dict return, not a non-2xx status), so a res.ok-only
// check would treat the failure as success and advance without saving.
// Inspect the body too.
let data = null;
try { data = await res.json(); } catch (e) { /* non-JSON body */ }
if (!res.ok || (data && data.error)) {
throw new Error((data && data.error) || 'That folder couldnt be set — check the path and try again.');
}
// Non-fatal: scan kicks off the library build in the background.
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ }
}
async function postProfile() {
const res = await fetch('/api/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -380,12 +485,23 @@
}
if (step === 1) {
setStep(2);
loadPathTiles();
setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50);
return;
}
if (step === 2) {
// Save the song directory + kick a library scan, then continue
// to instrument paths. "Skip for now" leaves it unconfigured.
submit.disabled = true;
try {
await saveSongDir();
setStep(3);
loadPathTiles();
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
return;
}
if (step === 3) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at step 3 can never lose the profile.
// so closing the overlay at the challenge can never lose the profile.
submit.disabled = true;
try {
_profile = await postProfile();
@@ -403,11 +519,14 @@
throw new Error(msg);
}
}
setStep(3);
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(4);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 3 — "Play it now": leave calibration pending (it completes
// Step 4 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish();
@@ -415,7 +534,14 @@
});
skipBtn.addEventListener('click', async () => {
// Step 3 — skip: Mastery Rank 1 immediately, calibration stays
// Step 2 — skip the song directory (the user can set it later in
// Settings). Proceed straight to instrument paths.
if (step === 2) {
setStep(3);
loadPathTiles();
return;
}
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
skipBtn.disabled = true;
try {