Compare commits

..
Author SHA1 Message Date
Jafz2001andClaude Opus 4.8 1c8e402a21 nav: pin rig_builder into the LIBRARY group as "Studio"
Adds a "Studio" sidebar entry (rig_builder plugin) in the LIBRARY group, between
Songs and Lessons, with a faders icon. Excludes rig_builder from the auto-built
PLUGINS section so it isn't listed twice. Routing/title reuse the existing
plugin-<id> screen path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:14:47 -04:00
c7fb074111 feat(onboarding): first-run home tour (spotlight coach marks) (#528)
* style(tour): align tour engine + Shepherd bubbles to the v3 fb-* palette

The tour/help engine shipped its own indigo/blue dark palette (#181830 / #4080e0)
that predates the v3 fee[dB]ack tokens, and the spotlight bubbles themselves used
the vendored Shepherd LIGHT default (white card, black text) — both clashed with
the navy/sky v3 UI behind them.

- Recolor the "?" menu button, popover and first-visit toast to the fb-* tokens
  (card #1e293b, primary #0ea5e9, border #334155, text #f8fafc/#94a3b8, gold
  #e8c040 unchanged).
- Add a dark .shepherd-* override block (loads after the vendored shepherd.css,
  which is left pristine for upgrades): dark bubble + arrow, fb-primary Next/Done
  button, slate secondary button, fb text scale, and bump the modal dim to 0.6 to
  match the onboarding overlay.

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

* feat(tour-engine): let client/core tours register into the consolidated menu

The tour engine only listed server-discovered plugins (those with a tour.json,
populated from /api/plugins) in the "?" menu, and always prompted unseen relevant
tours via the toast + button pulse. Generalize register() so a core/client-owned
tour can participate:
- `name` registers the tour into the menu catalog (_tourPlugins) so it shows in
  the "?" menu even without a server plugin; never clobbers a real plugin entry.
- `autoPrompt:false` opts the tour OUT of the unseen toast + pulse (for tours
  driven programmatically by their owner), while still listing + running on
  demand. _unseenRelevant honours it.
Both options are additive and default to the prior behaviour.

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

* chore(v3): add stable tour anchors to home cards + instrument badge

Give the first-run home tour stable spotlight targets: #v3-hero on the hero
panel and data-tour="continue" on the three continue/pick/browse card variants
(dashboard.js), and #v3-instrument-wrap on the topbar instrument selector
(badges.js, mirroring the existing #v3-tuner-wrap). The other targets (audio
routing, tuner, profile, sidebar nav) already had stable ids.

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

* feat(onboarding): first-run home tour (spotlight coach marks)

After a genuine onboarding completion, dim the home page and spotlight one card
at a time with an explanatory bubble + Next, reusing the shared tour engine
(Shepherd). 7 stops: Hero/Start Playing → Continue/Pick → Instrument selector →
Tuner → Audio Routing → Profile → Sidebar nav. Auto-runs once; replayable
forever from the "?" tour menu as "Welcome tour".

- New static/v3/onboarding-tour.js: registers the spotlight tour (screens:
  ['v3-home'], name "Welcome tour", autoPrompt:false) and exposes startFirstRun(),
  gated on the engine's seen/dismissed state so it never repeats; loaded after
  tour-engine.js + dashboard.js.
- profile.js finish(): trigger startFirstRun() only on a real onboarding
  completion (!editing) — a later profile edit must not relaunch it.

Verified headlessly (native core + Playwright): all 7 anchors resolve, the
spotlight advances one bubble at a time in the v3 dark theme, completion marks
seen, startFirstRun is once-only, and the "?" menu lists "Welcome tour".

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

* fix(capabilities): clear the handler timeout timer once the race settles

Codex round-6: _withTimeout raced the handler promise against a bare setTimeout
but never cleared it, so a handler that resolves first leaves the timer alive
until it fires. Harmless at 250ms, but the new 15s MIDI permission-command
overrides (discover/open-source) kept the event loop alive ~15s after every
successful call (and the test process hung that long) and could accumulate
delayed callbacks across repeated scans. Capture the timer and clearTimeout it in
a .finally on the race. (Domain/capabilities tests now finish in ~0.1s, not 15s.)

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

* fix(midi-input): give the built-in Web-MIDI provider a distinct participant id

Codex round-7: the built-in Web-MIDI provider registered with participantId
'core.midi-input' — the same id as the domain owner. unregisterProvider()
unregisters the provider's participant, so a provider swap/hot-reload would tear
down the domain OWNER too, leaving midi-input with no owner for later commands.
Register the provider as 'core.midi-input.web-midi'.

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

* fix(onboarding): don't start the home tour when launching the diagnostic

Codex round-7: on the final onboarding step, "Play it now" calls finish() (which
started the home tour) and THEN playSong(target). startFirstRun() navigated to
v3-home and scheduled the tour, then playSong switched to the player — so the
tour spotlighted hidden home elements / stole focus from the diagnostic. Gate the
tour on a launchingSong flag (passed by the "Play it now" path); the Skip path
stays on home, so the tour still runs there.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:32:33 +02:00
fb06e288e1 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>
2026-06-19 16:31:16 +02:00
K. O. A.andGitHub 313348a1ff Merge pull request #523 from got-feedback/fix/calibration-rebrand-feedback
Rebrand calibration challenge copy Slopsmith -> fee[dB]ack
2026-06-19 09:12:54 -04:00
topkoaandClaude Opus 4.8 21b8a6cd49 Rebrand calibration challenge copy Slopsmith -> fee[dB]ack
The first-run calibration prompt (profile onboarding step 3) and the
Progress-screen calibration card both told the user to play the
"Slopsmith Diagnostic". Update the visible copy to "fee[dB]ack
Diagnostic". Text-only; the diagnostic is matched functionally by the
is_diagnostic flag + filename, not by this label, so no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-06-19 09:02:38 -04:00
23 changed files with 1697 additions and 121 deletions
+2
View File
@@ -35,6 +35,8 @@ plugins/minigames/__pycache__/
!plugins/tuner/
!plugins/tuner/**
plugins/tuner/__pycache__/
!plugins/input_setup/
!plugins/input_setup/**
node_modules/
test-results/
playwright-report/
+12
View File
@@ -153,6 +153,18 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `slopsmith.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
## MIDI-Input Domain
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
Native providers register source summaries with `providerId`, a stable `sourceId`, a derived redaction-safe `logicalSourceKey` (`providerId::sourceId`), `kind: "midi"`, a label, and `availability`. The public command surface is `inspect`, `list-sources`, `discover`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never request MIDI access. Unlike audio (where `getUserMedia` gates labels and `open-source` is the prompt), Web-MIDI's `requestMIDIAccess()` gates the whole input list, so **`discover` is the permission boundary** and records `denied`/`unavailable` outcomes; `open-source` then attaches a shared listener session and never re-prompts.
Selected input is persisted by `logicalSourceKey` (`slopsmith.midiInput.selectedLogicalSourceKey`) when browser storage is available. Compatible requesters share one open session per source; each later calls `close-source`, and the provider receives `source.close` only after the last requester releases. Live MIDI message delivery (for the "play a note / hit a pad" calibration check) is exposed to in-page consumers through the public `window.slopsmith.midiInput` session handle only — never as raw capability events or diagnostics.
The reserved `midi-control` domain is the planned **sibling** for control mappings (CC/pitchbend/note → action routing) and will consume `midi-input` for device access (spec 013 / #882); this slice carves the device control plane out so `midi-control` can stay mappings-only. `midi-control` stays RESERVED (documentation-only) until a concrete mapping consumer + tests exist, per the future-domain governance.
Diagnostics live under `slopsmith.midi_input.diagnostics.v1` and contain provider ids, source ids/keys/kinds/availability, the selected key, and open-session keys — **device labels are redacted** and no raw MIDI messages are ever included.
## Capability Roles
Use capability declarations for provider/requester/observer relationships:
+1 -1
View File
@@ -108,7 +108,7 @@ These domains are planned but should stay out of the runtime graph until a host
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. |
| `midi-control` | multi-provider | sensitive | MIDI control mappings only (CC/pitchbend/note → action routing), consuming `midi-input` for device access. Device discovery/selection/open is split out to the delivered `midi-input` domain (spec 012). | A concrete mapping/routing workflow on top of the `midi-input` device plane (#882). |
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
+3 -1
View File
@@ -21,6 +21,8 @@ Core domains also have a review scope. **Active contract** domains are wired to
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
## Expected Future Domains
@@ -38,7 +40,7 @@ These domains are expected future capability contracts, not current runtime grap
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. |
| midi-control | multi-provider | sensitive | list-mappings, get-mapping, set-mapping, delete-mapping, activate-mapping, inspect | Mappings ONLY — CC/pitchbend/note → semantic action routing (spec 013). Device discovery/selection/open is NOT this domain's job: it consumes the delivered `midi-input` domain for device access. Needs a concrete mapping consumer (the MIDI control plugin / drums learn-mode) + redacted diagnostics (no raw MIDI streams) before promotion. |
| tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. |
Planned domains should also stay out of the runtime graph until Slopsmith ships the corresponding user-facing workflows.
+2 -50
View File
@@ -934,16 +934,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
if not req_file.exists():
return True
# Packaged/distributed hosts (e.g. slopsmith-desktop) set
# SLOPSMITH_SKIP_PLUGIN_INSTALL to skip the blocking startup pip install:
# heavy optional deps (torch/whisperx/demucs) would otherwise download for
# minutes and hang the backend past the host app's readiness window. The
# plugin still loads and degrades gracefully when its optional deps are
# absent.
if os.environ.get("SLOPSMITH_SKIP_PLUGIN_INSTALL", "").strip().lower() not in ("", "0", "false", "no"):
log.info("Skipping requirement install for plugin %r (SLOPSMITH_SKIP_PLUGIN_INSTALL set)", plugin_id)
return True
_PIP_TARGET.mkdir(parents=True, exist_ok=True)
pip_target = str(_PIP_TARGET)
@@ -956,32 +946,9 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
# (PYTHONHASHSEED), so the marker would never match on restart and
# pip would re-resolve every plugin's requirements on every boot.
marker = _PIP_TARGET / f".installed_{plugin_id}"
fail_marker = _PIP_TARGET / f".failed_{plugin_id}"
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest()
def _marker_matches(m):
# Tolerate an unreadable/transiently-broken marker (permissions, I/O):
# treat it as "no match" and fall through to a normal install attempt
# rather than letting read_text() raise out of this function.
try:
return m.exists() and m.read_text().strip() == req_hash
except OSError:
return False
if _marker_matches(marker):
if marker.exists() and marker.read_text().strip() == req_hash:
return True # Already installed, same requirements
# A previous install of these exact requirements already failed. Don't
# re-attempt on every boot: that re-blocks startup for the full pip timeout
# each launch. Retry only when requirements.txt changes (new hash) or the
# .failed_ marker is cleared.
if _marker_matches(fail_marker):
return False
def _record_failure():
try:
fail_marker.write_text(req_hash)
except OSError:
pass # read-only target: nothing to persist
log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id)
try:
@@ -993,20 +960,7 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
capture_output=True, text=True, timeout=1800,
)
if result.returncode == 0:
# Persisting markers is best-effort and must NOT fall through to the
# outer `except` (which would call _record_failure() and make a
# SUCCESSFUL install look like a sticky failure). The two writes are
# independent: clearing a stale .failed_ marker must still happen
# even if writing the success marker fails — otherwise a real
# success would stay recorded as a failure on the next boot.
try:
marker.write_text(req_hash)
except OSError:
pass
try:
fail_marker.unlink() # clear any stale failure record
except OSError:
pass
marker.write_text(req_hash)
log.info("Requirements installed for plugin %r", plugin_id)
return True
else:
@@ -1020,7 +974,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
)
else:
log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300])
_record_failure()
return False
except Exception as e:
err_lower = str(e).lower()
@@ -1033,7 +986,6 @@ def _install_requirements(plugin_dir: Path, plugin_id: str):
)
else:
log.warning("Plugin %r: error installing requirements: %s", plugin_id, e)
_record_failure()
return False
+46
View File
@@ -0,0 +1,46 @@
{
"id": "input_setup",
"name": "Input Setup",
"version": "0.1.0",
"bundled": true,
"private": false,
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"script": "screen.js",
"settings": { "html": "settings.html" },
"description": "Per-instrument input-device selection and calibration, used during onboarding and re-launchable from Settings.",
"category": "practice",
"capabilities": {
"input-calibration": {
"roles": ["owner"],
"commands": ["run", "status", "inspect"],
"events": ["calibration-started", "calibration-done", "calibration-skipped"],
"kind": "command",
"mode": "active",
"compatibility": "none",
"ownership": "exclusive-owner",
"safety": "safe",
"description": "Owns the per-instrument input-setup wizard workflow; onboarding and Settings dispatch through the runtime.",
"version": 1
},
"audio-input": {
"roles": ["requester"],
"requests": ["list-sources", "select-source", "open-source"],
"mode": "active",
"compatibility": "degrade-noop",
"ownership": "requester-only",
"safety": "sensitive",
"description": "Picks the guitar/bass audio input device through the core audio-input domain.",
"version": 1
},
"midi-input": {
"roles": ["requester"],
"requests": ["discover", "list-sources", "select-source", "open-source", "close-source"],
"mode": "active",
"compatibility": "degrade-noop",
"ownership": "requester-only",
"safety": "sensitive",
"description": "Picks the keys/drums MIDI device through the core midi-input domain (Web-MIDI provider ships built-in with the domain).",
"version": 1
}
}
}
+353
View File
@@ -0,0 +1,353 @@
/*
* input_setup — per-instrument input-device selection & calibration.
*
* Bundled core plugin (constitution P-II vanilla JS). It:
* 1. supplies a Web-MIDI source provider to the core `midi-input` domain;
* 2. owns the `input-calibration` capability domain (run / status / inspect);
* 3. renders the onboarding input-setup wizard (one pass per instrument):
* - guitar/bass → pick via `audio-input`, then launch note_detect's
* Calibration Wizard (note-detection is a deferred surface — JS API);
* - keys/drums → pick via `midi-input`, then a live "play a note /
* hit a pad" confirmation.
*
* Idempotent (plugin-runtime-idempotent.v1): re-hydration is a no-op.
*/
(function () {
'use strict';
window.slopsmith = window.slopsmith || {};
if (window.slopsmithInputSetup && window.slopsmithInputSetup.version === 1) return;
const capabilities = window.slopsmith.capabilities;
const DONE_KEY = (inst) => `input_setup.done.${inst}`;
const INSTRUMENTS = {
guitar: { label: 'Guitar', mode: 'audio' },
bass: { label: 'Bass', mode: 'audio' },
keys: { label: 'Keys / Piano', mode: 'midi' },
piano: { label: 'Keys / Piano', mode: 'midi' },
drums: { label: 'Drums', mode: 'midi' },
};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function _isDone(inst) { try { return window.localStorage.getItem(DONE_KEY(inst)) === '1'; } catch (_) { return false; } }
function _markDone(inst, v) { try { if (v) window.localStorage.setItem(DONE_KEY(inst), '1'); else window.localStorage.removeItem(DONE_KEY(inst)); } catch (_) { /* private mode */ } }
// The Web-MIDI source provider now ships built-in with the core midi-input
// domain (static/capabilities/midi-input.js), so input_setup is a pure
// consumer — it just discovers/selects/opens through `window.slopsmith.midiInput`.
// ── audio-input helper (guitar/bass device context) ─────────────────────
async function _audioSources() {
if (!capabilities || typeof capabilities.command !== 'function') return { sources: [], selected: null };
try {
const r = await capabilities.command('audio-input', 'list-sources', { requester: 'input_setup' });
const p = (r && r.payload) || {};
let sources = Array.isArray(p.sources) ? p.sources : [];
// Exclude MIDI devices some plugins export into audio-input
// (e.g. keys-highway-3d's pseudonymized 'midi-input-N'): they aren't
// audio inputs and the cryptic labels confuse this guitar/bass picker.
sources = sources.filter((s) => s
&& !/midi/i.test(String(s.providerId || ''))
&& !/^midi-input/i.test(String(s.label || '')));
// De-dupe by display label — the desktop engine enumerates the same
// device under several driver types, so the same name can repeat.
const seen = new Set();
sources = sources.filter((s) => {
const key = String(s.label || '').toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const selected = sources.find((s) => s && s.selected) || null;
return { sources, selected };
} catch (_) { return { sources: [], selected: null }; }
}
// ── Wizard UI ────────────────────────────────────────────────────────────
// Renders sequential per-instrument panels into `host`. Resolves the
// returned promise to { completed:[...], skipped:[...] } when finished.
function _runWizard(opts) {
opts = opts || {};
const instruments = (Array.isArray(opts.instruments) ? opts.instruments : [])
.map((i) => String(i).toLowerCase()).filter((i) => INSTRUMENTS[i]);
// De-dupe keys/piano (same MIDI flow under one label).
const seen = new Set();
const queue = instruments.filter((i) => { const k = INSTRUMENTS[i].label; if (seen.has(k)) return false; seen.add(k); return true; });
const completed = [];
const skipped = [];
let idx = 0;
return new Promise((resolve) => {
const host = opts.host;
if (!host) { resolve({ completed, skipped }); return; }
function finish() {
_emitOwner('calibration-done', { completed: completed.slice(), skipped: skipped.slice() });
if (typeof opts.onComplete === 'function') { try { opts.onComplete({ completed, skipped }); } catch (_) {} }
resolve({ completed, skipped });
}
// Per-panel teardown run on EVERY exit (Continue or the generic "Skip
// for now"), so an opened MIDI session/listener never leaks past the
// panel that opened it.
let _activeCleanup = null;
function next() {
if (idx >= queue.length) { finish(); return; }
renderPanel(queue[idx]);
}
function advance(inst, didComplete) {
if (_activeCleanup) { try { _activeCleanup(); } catch (_) {} _activeCleanup = null; }
if (didComplete) { _markDone(inst, true); if (!completed.includes(inst)) completed.push(inst); }
else { if (!skipped.includes(inst)) skipped.push(inst); }
idx += 1;
next();
}
function shell(inst, bodyHtml, footHtml) {
const meta = INSTRUMENTS[inst];
host.innerHTML =
'<div class="space-y-4">' +
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim">Input setup — step ' + (idx + 1) + ' of ' + queue.length + '</div>' +
'<h3 class="text-lg font-bold text-fb-text mt-0.5">Set up your ' + esc(meta.label) + '</h3></div>' +
'<div data-is-body>' + bodyHtml + '</div>' +
'<div class="flex justify-between items-center pt-1">' +
'<button type="button" data-is-skip class="text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
'<div data-is-foot>' + (footHtml || '') + '</div></div></div>';
host.querySelector('[data-is-skip]').addEventListener('click', () => advance(inst, false));
}
// ── per-instrument panels ───────────────────────────────────────
async function renderPanel(inst) {
const meta = INSTRUMENTS[inst];
if (meta.mode === 'audio') return renderAudioPanel(inst);
return renderMidiPanel(inst);
}
// Guitar/bass: show the audio source (audio-input) and launch the
// note_detect Calibration Wizard for the deep work.
async function renderAudioPanel(inst) {
const { sources, selected } = await _audioSources();
const opts2 = sources.map((s) =>
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
const body =
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
(sources.length
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
const foot =
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
shell(inst, body, foot);
const sel = host.querySelector('[data-is-audio]');
const commitAudio = (key) => {
if (!capabilities || !key) return;
capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {});
};
if (sel) {
sel.addEventListener('change', () => commitAudio(sel.value));
// The <select> shows its first option by default, but no `change`
// fires for that implicit pick — so on a first run with nothing yet
// selected, audio-input would calibrate against the wrong/no source.
// Commit the shown option up-front so the displayed device is the
// one calibrated (idempotent if it was already selected).
if (!selected) commitAudio(sel.value);
}
// Tell the tuner tables / note_detect which instrument this is.
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
window.noteDetect.launchCalibration({
instrument: inst,
onDone: () => advance(inst, true),
onCancel: () => { /* stay on this panel; user can skip or retry */ },
});
} else {
advance(inst, true);
}
});
}
// Keys/drums: pick a MIDI device via midi-input and confirm a live hit.
async function renderMidiPanel(inst) {
const mi = window.slopsmith.midiInput;
// Availability is the midi-input DOMAIN being present, not the
// Web-MIDI browser API — the domain coordinates providers (the
// built-in Web-MIDI one, plus any native/desktop adapter), so
// gating on navigator.requestMIDIAccess would hide a usable
// non-Web-MIDI provider before discover() is ever called.
const midiAvailable = !!(mi && mi.version === 1);
if (!midiAvailable) {
shell(inst,
'<p class="text-sm text-fb-accent">MIDI input isnt available here. Connect a MIDI keyboard/e-kit in a supported environment, or skip for now.</p>',
'<button type="button" data-is-skip2 class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">Continue</button>');
host.querySelector('[data-is-skip2]').addEventListener('click', () => advance(inst, false));
return;
}
const verb = inst === 'drums' ? 'hit a pad' : 'play a note';
shell(inst,
'<p class="text-sm text-fb-textDim">Connect your MIDI device, pick it below, then ' + verb + ' to confirm its working.</p>' +
'<div class="mt-3 flex items-center gap-2">' +
'<button type="button" data-is-scan class="text-sm text-fb-primary hover:text-fb-primaryHi">Scan for MIDI devices</button></div>' +
'<div data-is-midi-wrap class="hidden mt-2">' +
'<select data-is-midi class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none"></select>' +
'<p data-is-test class="text-sm text-fb-textDim mt-2">Waiting for input…</p></div>',
'<button type="button" data-is-next disabled class="bg-fb-primary disabled:opacity-40 text-white px-5 py-2 rounded-md font-medium">Continue</button>');
const wrap = host.querySelector('[data-is-midi-wrap]');
const select = host.querySelector('[data-is-midi]');
const testEl = host.querySelector('[data-is-test]');
const nextBtn = host.querySelector('[data-is-next]');
let activeKey = null;
let listener = null;
let activeHandle = null;
let openSeq = 0;
async function openSelected() {
// Tear down the previous device + RESET the confirmation
// state, so a hit on a prior device can't leave Continue
// enabled for a newly-selected device that hasn't been heard.
const myGen = ++openSeq;
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
activeHandle = null;
listener = null;
nextBtn.disabled = true;
_markDone(inst, false);
// Capture the requested key in a local: a newer openSelected()
// overwrites the shared `activeKey`, so comparing it after the
// awaits would let a stale open bind the wrong device.
const requestedKey = select.value;
activeKey = requestedKey;
if (!requestedKey) { testEl.textContent = ''; return; }
testEl.textContent = 'Waiting for input…';
await mi.select(requestedKey);
const res = await mi.open({ requester: 'input_setup', logicalSourceKey: requestedKey });
// Discard a stale open if a newer openSelected() superseded us.
if (myGen !== openSeq) { try { if (res) mi.close({ requester: 'input_setup', logicalSourceKey: requestedKey }); } catch (_) {} return; }
if (!res || !res.handle) { testEl.textContent = 'Could not open this device.'; activeKey = null; return; }
activeHandle = res.handle;
listener = (data) => {
// 0x90 = note-on (any channel); velocity > 0.
if (data && (data[0] & 0xf0) === 0x90 && data[2] > 0) {
testEl.innerHTML = '<span class="text-fb-primary font-semibold">✓ Got it</span> — device is working.';
nextBtn.disabled = false;
_markDone(inst, true);
}
};
activeHandle.addListener(listener);
}
host.querySelector('[data-is-scan]').addEventListener('click', async () => {
await mi.discover();
// Show every source the midi-input domain surfaces — not just
// the built-in Web-MIDI provider — so a native/desktop MIDI
// adapter registered with the domain is selectable too.
const sources = window.slopsmith.midiInput.listSources() || [];
if (!sources.length) { testEl && (testEl.textContent = ''); wrap.classList.remove('hidden'); select.innerHTML = '<option>No MIDI devices found</option>'; select.disabled = true; return; }
wrap.classList.remove('hidden');
select.disabled = false;
select.innerHTML = sources.map((s) => '<option value="' + esc(s.logicalSourceKey) + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label) + '</option>').join('');
openSelected();
});
select.addEventListener('change', openSelected);
// Close the open session/listener on ANY exit (Continue or the
// generic Skip), so a scanned+selected device doesn't keep its
// Web-MIDI input live after the panel advances.
_activeCleanup = () => {
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
activeHandle = null; listener = null; activeKey = null;
};
nextBtn.addEventListener('click', () => advance(inst, true));
}
_emitOwner('calibration-started', { instruments: queue.slice() });
next();
});
}
function _emitOwner(event, detail) {
try { capabilities && capabilities.emitEvent && capabilities.emitEvent('input-calibration', event, detail || {}); } catch (_) {}
}
// ── input-calibration owner domain ───────────────────────────────────────
function _statusPayload(instruments) {
const list = (Array.isArray(instruments) && instruments.length ? instruments : Object.keys(INSTRUMENTS))
.map((i) => String(i).toLowerCase());
const status = {};
list.forEach((i) => { if (INSTRUMENTS[i]) status[i] = _isDone(i) ? 'done' : 'needs-setup'; });
return status;
}
if (capabilities && typeof capabilities.registerOwner === 'function') {
capabilities.registerOwner('input-calibration', {
pluginId: 'input_setup',
kind: 'command',
safety: 'safe',
commands: ['run', 'status', 'inspect'],
events: ['calibration-started', 'calibration-done', 'calibration-skipped'],
description: 'Per-instrument input-setup wizard workflow (audio via audio-input + note_detect; MIDI via midi-input).',
handlers: {
inspect: () => ({ outcome: 'handled', payload: { available: true, status: _statusPayload() } }),
status: (ctx) => ({ outcome: 'handled', payload: { status: _statusPayload((ctx.payload || {}).instruments) } }),
// `run` is fire-and-launch: an interactive wizard far exceeds the
// ~250ms handler timeout, so it starts the overlay and returns
// immediately. Completion is signaled by the `calibration-done`
// event (mirrors audio-monitoring `start`). A second `run` while
// one is open is a no-op (single overlay).
run: (ctx) => {
const instruments = ((ctx.payload || {}).instruments) || [];
if (!document.getElementById('input-setup-overlay')) launch(instruments);
return { outcome: 'handled', payload: { started: true, instruments } };
},
},
});
}
// ── public surface (onboarding + Settings re-entry) ──────────────────────
function mount(container, options) {
options = options || {};
return _runWizard({ host: container, instruments: options.instruments || [], onComplete: options.onComplete, onSkip: options.onSkip });
}
function launch(instruments) {
const overlay = document.createElement('div');
overlay.id = 'input-setup-overlay';
overlay.className = 'fixed inset-0 z-[210] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML = '<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6" data-is-host></div>';
document.body.appendChild(overlay);
const host = overlay.querySelector('[data-is-host]');
return _runWizard({ host, instruments: instruments || [] }).then((r) => { overlay.remove(); return r; });
}
window.slopsmithInputSetup = {
version: 1,
mount,
launch,
status: (instruments) => _statusPayload(instruments),
};
// Settings-panel re-entry (settings.html "Set up input devices" button).
// Re-runs the wizard for the player's selected instrument paths, falling
// back to all instruments when progression isn't available.
window._inputSetupRelaunch = async function () {
let instruments = [];
try {
const r = await fetch('/api/progression');
if (r.ok) {
const d = await r.json();
const paths = Array.isArray(d.paths) ? d.paths : [];
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
}
} catch (_) { /* offline — fall back below */ }
if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
launch(instruments);
};
})();
+16
View File
@@ -0,0 +1,16 @@
<div class="space-y-4 py-2">
<div class="bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<h3 class="text-sm font-medium text-gray-200">Input devices &amp; calibration</h3>
<p class="text-[11px] text-gray-500 mt-1">
Re-run the input setup wizard for your instrument paths — pick your audio
input or MIDI device and confirm it's working. Guitar/bass also opens the
calibration wizard.
</p>
<button type="button"
onclick="window._inputSetupRelaunch &amp;&amp; window._inputSetupRelaunch()"
class="mt-3 px-4 py-2 bg-accent hover:bg-accent-light text-white text-sm font-medium rounded-lg">
Set up input devices
</button>
<p id="input-setup-settings-status" class="text-[11px] text-gray-500 mt-2"></p>
</div>
</div>
+110
View File
@@ -0,0 +1,110 @@
# Spec 012 — MIDI-Input Control-Plane Capability Domain
**Status:** active (control-plane slice) · **Issues:** #873 (impl), #880 (this spec) · **Base:** `release/v0.3.0`
## Summary
`midi-input` is a **core-owned provider-coordinator** capability domain for MIDI
device discovery, selection, and open/close session lifecycle — the MIDI analog
of `audio-input` (spec 006). It gives every MIDI consumer in Slopsmith (the
`input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and — as
a follow-up — note-detection's Web-MIDI provider) **one device-access boundary**:
one permission prompt, one source list, one redaction boundary.
## Motivation
Today each MIDI consumer calls `navigator.requestMIDIAccess()` privately
(piano, drums, plugin-midi, note-detection's `midi` provider kind), so there is
no shared source list, no single permission prompt, and no common redaction of
device labels. The onboarding input-setup step (#874/#876/#877) needs a single
governed surface to pick and verify a MIDI device per instrument.
## Why not reuse `audio-input`
`audio-input`'s source/`source.open` contract is audio-frame-centric:
`channelSummary`/`channelCount`/`channelShape`, `requiredChannelShape`, and
redaction keyed to audio handles/buffers/samples. MIDI carries discrete messages
and has no channel shape. Folding MIDI in would overload the audio contract and
its redaction boundary. A sibling domain keeps both contracts clean and lets
each evolve independently — the same reasoning that made `audio-input` and
`audio-monitoring` siblings rather than one domain.
## Why core-owned (not plugin-owned)
An input control plane outlives any one feature; `audio-input` is
`core.audio.session`-owned, not owned by a feature plugin. If `input_setup`
owned `midi-input`, the domain's lifetime would be coupled to the wizard, and
migrating ownership later (every consumer, persistence key, diagnostics schema
references the owner) is costly. The domain is `core.midi-input`.
## Contract
- **Owner:** `core.midi-input`, kind `provider-coordinator`, safety `sensitive`.
- **Public commands:** `inspect`, `list-sources`, `discover`, `select-source`,
`open-source`, `close-source`.
- **Provider operations:** `source.enumerate`, `source.describe`, `source.open`,
`source.close`.
- **Events:** `provider-registered`, `provider-unregistered`,
`availability-changed`, `sources-changed`, `source-selected`, `source-opened`,
`source-closed`.
### Sources & identity
Providers register source summaries with `providerId`, a stable `sourceId`, a
derived **redaction-safe** `logicalSourceKey` (`providerId::sourceId`),
`kind: "midi"`, a label, and `availability`. Persistence and diagnostics use the
`logicalSourceKey`, never the human device label.
### Permission model (Web-MIDI nuance)
`requestMIDIAccess()` gates the **whole input list**, so **`discover` is the
permission boundary** (not `open-source`, as it is for audio). `inspect` /
`list-sources` / `select-source` are **prompt-free** and never request access.
`discover` records `denied` / `unavailable` outcomes; `open-source` attaches a
shared listener session to an already-discovered source and never re-prompts.
### Sessions
One shared open session per source across requesters (refcounted); the provider
receives `source.close` only after the last requester releases. Live MIDI
message delivery (for the "play a note / hit a pad" calibration check) is exposed
to in-page consumers via the public `window.slopsmith.midiInput` session handle
**only** — never as raw capability events or in diagnostics.
### Persistence & redaction
Selected source persists under `slopsmith.midiInput.selectedLogicalSourceKey`.
Diagnostics (`slopsmith.midi_input.diagnostics.v1`) carry provider ids, source
ids/keys/kinds/availability, the selected key, and open-session keys; device
**labels are redacted** and **no raw MIDI messages** are ever included.
## Split from `midi-control`
The reserved `midi-control` domain is narrowed to **control mappings only**
(CC/pitchbend/note → action routing) and will consume `midi-input` for device
access. This spec carves out the device control plane so `midi-control` can stay
mappings-only (#882).
## Consumers (separate issues)
- `input_setup` onboarding wizard — keys/drums device pick + verify (#876/#877).
- `piano` / `drums` plugins — consume `midi-input` instead of private
`requestMIDIAccess()` (via the sub-flow issues; legacy retired through bridges).
- note-detection's Web-MIDI provider migrates onto `midi-input` (#881).
## Acceptance
- Owner registers; appears in the Capability Inspector with the commands above.
- `discover` is the only command that triggers `requestMIDIAccess()`;
`inspect`/`list-sources`/`select-source` never prompt.
- Selection persists across reload by `logicalSourceKey`.
- Diagnostics contain no device labels or raw MIDI messages.
- A consumer can `discover``select-source``open-source` → receive live
note-on for the calibration check → `close-source` (session refcount releases).
## Out of scope (follow-ups)
- `midi-control` mapping/routing domain (#882).
- note-detection provider migration onto `midi-input` (#881).
- Retiring per-plugin `requestMIDIAccess()` in piano/drums via compatibility
bridges (tracked with the sub-flow issues).
+75
View File
@@ -0,0 +1,75 @@
# Spec 013 — `midi-control` Mappings Domain (the midi-input/midi-control split)
**Status:** documented future contract (RESERVED — not in the runtime graph) ·
**Issue:** #882 · **Depends on:** spec 012 (`midi-input`, delivered) · **Base:** `feedback/main`
## Summary
`midi-control` is the planned sibling of `midi-input`: it owns **MIDI control
mappings** — routing CC / pitchbend / note messages to *semantic actions* (drum
lane, transport command, effect parameter, etc.) — and **consumes `midi-input`**
for device access. It does **not** discover, select, or open devices; that is
`midi-input`'s job (spec 012, delivered).
This spec records the **split** so the boundary is unambiguous and the contract
is ready for whoever builds the runtime slice. Per project governance
(`docs/capability-safety-matrix.md`, `docs/capability-roadmap.md`), a future
domain stays **documentation-only until a PR ships its host workflow, a concrete
consumer, and tests** — so `midi-control` remains `RESERVED` in
`static/capabilities.js` `RESERVED_FUTURE_DOMAINS` until then. This spec does not
register a runtime domain.
## Why split it out
Before `midi-input` existed, "MIDI" meant two conflated concerns: getting bytes
from a device, and mapping those bytes to actions. The reserved `midi-control`
entry originally covered both. With `midi-input` delivered as the device control
plane, `midi-control` is narrowed to **mappings only** — mirroring how
`audio-input` (devices) is separate from `audio-effects`/`audio-mix` (what you do
with the signal). Keeping them separate prevents a future god-domain and lets the
device plane stabilize independently of mapping semantics.
## Boundary (normative)
- **`midi-input` owns:** device discovery (`discover`), source list, selection,
open/close sessions, the Web-MIDI permission boundary, redacted device
diagnostics. The raw MIDI message stream is delivered to in-page consumers via
its session handle.
- **`midi-control` will own:** named mappings from MIDI events (note / CC /
pitchbend, optionally channel-scoped) to semantic actions, mapping persistence,
active-mapping selection, and "learn" capture. It **consumes** a `midi-input`
session for the live stream; it never calls `requestMIDIAccess` or enumerates
devices.
## Proposed contract (for the future implementation slice)
- **Owner:** `core.midi-control` (or a first-party MIDI-control plugin),
`multi-provider`, safety `sensitive`.
- **Commands:** `list-mappings`, `get-mapping`, `set-mapping`, `delete-mapping`,
`activate-mapping`, `inspect`.
- **Mapping shape (sketch):** `{ id, label, trigger: { type: 'note'|'cc'|'pitchbend',
number?, channel? }, action: { domain?, command?|actionId, params? } }`.
- **Learn mode:** open a `midi-input` session, capture the next matching event,
and bind it to the pending action (the per-plugin "learn" UIs in drums today
are the reference behaviour to generalise).
- **Diagnostics:** `slopsmith.midi_control.diagnostics.v1` — mapping summaries +
bounded recent activations; **no raw MIDI streams, no device labels**.
## Intended consumers (promotion trigger)
The domain should be promoted out of RESERVED when a concrete consumer needs
shared mappings, e.g.:
- the generic **MIDI control plugin** (`feedback-plugin-midi`) — today an ad-hoc
event→action mapper; the canonical first adopter.
- **drums** note→lane mapping + "learn mode" (`feedback-plugin-drums`,
`feedback-plugin-drum-highway-3d`) — currently per-plugin; could adopt
`midi-control` to share mapping logic once the contract is proven.
Until such a consumer-driven slice exists (with host workflow + tests), this
remains a documented contract only.
## Out of scope
- Any runtime registration / handlers (governance: no premature domain).
- Migrating the drums/keys per-plugin mapping now — deferred to the consumer slice.
- The device plane — owned by `midi-input` (spec 012, done).
+27 -11
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',
@@ -803,15 +816,18 @@
}
function _withTimeout(promise, timeoutMs, participant) {
return Promise.race([
promise,
new Promise(resolve => {
setTimeout(() => resolve({
outcome: 'failed',
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
}), timeoutMs);
}),
]);
// Capture + clear the timer once the race settles — otherwise a handler
// that resolves first leaves a live setTimeout (up to timeoutMs) that
// keeps the event loop alive and, for long overrides (MIDI permission
// commands at 15s), accumulates delayed callbacks across repeated calls.
let timer;
const timeout = new Promise(resolve => {
timer = setTimeout(() => resolve({
outcome: 'failed',
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
}), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function _normalizeDecision(participant, result) {
@@ -960,7 +976,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 +1392,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' });
+408
View File
@@ -0,0 +1,408 @@
// 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',
// Distinct from the domain owner's participant id ('core.midi-input').
// unregisterProvider() unregisters the provider's participant, so
// sharing the owner's id would tear the whole domain's owner down on
// a provider swap/hot-reload, leaving midi-input with no owner.
participantId: 'core.midi-input.web-midi',
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">
+115 -32
View File
@@ -1,7 +1,9 @@
/* ── Consolidated tour menu — one floating button + popover for all tours ── */
/* Palette aligned with the app's dark theme: #4080e0 accent, #e8c040 gold,
#181830 dark background, #cbd5e1 / #94a3b8 / #64748b text scale. Per
CLAUDE.md Frontend Conventions. */
/* Palette aligned with the v3 fee[dB]ack design tokens (tailwind.config.js):
fb-card #1e293b surfaces, fb-cardMuted #0b1220 wells, fb-primary #0ea5e9
accent, fb-border #334155 hairlines, fb-text #f8fafc / fb-textDim #94a3b8
text, fb-gold #e8c040 badge. Plain CSS (no Tailwind classes) so it needs no
stylesheet rebuild. Per CLAUDE.md Frontend Conventions. */
.slopsmith-tour-menu-btn {
position: fixed;
@@ -14,20 +16,20 @@
width: 32px;
height: 32px;
border-radius: 50%;
background: #181830;
border: 1.5px solid #4080e0;
color: #cbd5e1;
background: #1e293b;
border: 1.5px solid #0ea5e9;
color: #f8fafc;
font-size: 15px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 0 6px #4080e066;
box-shadow: 0 0 6px #0ea5e966;
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.2s, transform 0.15s;
}
.slopsmith-tour-menu-btn:hover {
box-shadow: 0 0 12px #4080e0aa;
box-shadow: 0 0 12px #0ea5e9aa;
transform: translateY(-1px);
}
.slopsmith-tour-menu-btn.has-unseen {
@@ -41,12 +43,12 @@
width: 10px;
height: 10px;
background: #e8c040;
border: 2px solid #181830;
border: 2px solid #1e293b;
border-radius: 50%;
}
@keyframes tour-pulse {
0%, 100% { box-shadow: 0 0 6px #4080e066; }
50% { box-shadow: 0 0 16px #4080e0cc; }
0%, 100% { box-shadow: 0 0 6px #0ea5e966; }
50% { box-shadow: 0 0 16px #0ea5e9cc; }
}
.slopsmith-tour-menu-popover {
@@ -62,12 +64,12 @@
room for the trigger button + its bottom inset. */
max-height: calc(100vh - 80px);
overflow-y: auto;
background: #181830;
border: 1px solid #4080e044;
background: #1e293b;
border: 1px solid #0ea5e944;
border-radius: 8px;
padding: 6px;
font-size: 13px;
color: #cbd5e1;
color: #f8fafc;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.slopsmith-tour-menu-popover .tour-menu-header {
@@ -75,13 +77,13 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
border-bottom: 1px solid #1e1e3a;
color: #94a3b8;
border-bottom: 1px solid #334155;
margin-bottom: 4px;
}
.slopsmith-tour-menu-popover .tour-menu-empty {
padding: 10px;
color: #64748b;
color: #94a3b8;
font-style: italic;
text-align: center;
}
@@ -95,23 +97,23 @@
background: transparent;
border: none;
border-radius: 4px;
color: #cbd5e1;
color: #f8fafc;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.slopsmith-tour-menu-popover .tour-menu-item:hover {
background: #1a1a30;
color: #fff;
background: #334155;
color: #f8fafc;
}
/* Keyboard focus gets an explicit ring instead of relying on the hover
background matches the :focus-visible treatment elsewhere in the
app (style.css). Pointer focus is left alone. */
.slopsmith-tour-menu-popover .tour-menu-item:focus-visible {
background: #1a1a30;
color: #fff;
outline: 2px solid #4080e0;
background: #334155;
color: #f8fafc;
outline: 2px solid #0ea5e9;
outline-offset: -2px;
}
.slopsmith-tour-menu-popover .tour-menu-item-label {
@@ -127,11 +129,11 @@
}
.slopsmith-tour-menu-popover .tour-menu-item-status.is-new {
background: #e8c040;
color: #181830;
color: #0f172a;
}
.slopsmith-tour-menu-popover .tour-menu-item-status.is-seen {
background: #1e1e3a;
color: #64748b;
background: #0b1220;
color: #94a3b8;
}
/* ── First-visit toast — anchored above the menu button ── */
@@ -141,12 +143,12 @@
bottom: 56px;
right: 12px;
z-index: 202;
background: #181830;
border: 1px solid #4080e044;
background: #1e293b;
border: 1px solid #0ea5e944;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: #cbd5e1;
color: #f8fafc;
max-width: 240px;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
@@ -159,7 +161,7 @@
.slopsmith-tour-prompt .tour-prompt-more {
margin-top: 4px;
font-size: 11px;
color: #64748b;
color: #94a3b8;
}
.slopsmith-tour-prompt .tour-prompt-buttons {
display: flex;
@@ -178,11 +180,92 @@
opacity: 0.85;
}
.slopsmith-tour-prompt button[data-action="start"] {
background: #4080e0;
background: #0ea5e9;
color: #fff;
font-weight: 600;
}
.slopsmith-tour-prompt button[data-action="dismiss"] {
background: #1e1e3a;
background: #334155;
color: #94a3b8;
}
/* ── Shepherd bubble theme — override the vendored light default ──────────── */
/* The vendored static/vendor/shepherd.css ships Shepherd's stock LIGHT theme
(white bubble, black text, blue buttons), which clashes with the dark v3 UI.
These overrides load after it (index.html order) and recolor the spotlight
bubbles to the fb-* tokens. The vendored file is left untouched so it stays
upgradable. */
.shepherd-element {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
max-width: 360px;
}
.shepherd-content {
background: #1e293b;
border-radius: 10px;
}
.shepherd-text {
color: #cbd5e1;
font-size: 0.9rem;
line-height: 1.45;
padding: 0.85em 0.9em;
}
.shepherd-title {
color: #f8fafc;
font-size: 0.95rem;
font-weight: 700;
}
/* Title row: drop the light-grey header fill the stock theme paints behind a
titled step, so the header blends into the card. */
.shepherd-has-title .shepherd-content .shepherd-header {
background: transparent;
padding: 0.85em 0.9em 0;
}
/* Arrow must match the bubble surface (stock paints it white, and grey behind
a bottom-placed titled step). */
.shepherd-arrow:before {
background: #1e293b;
}
.shepherd-element.shepherd-has-title[data-popper-placement^="bottom"] > .shepherd-arrow:before {
background-color: #1e293b;
}
.shepherd-footer {
padding: 0 0.75rem 0.75rem;
}
/* Primary button (Next / Done) → fb-primary. */
.shepherd-button {
background: #0ea5e9;
color: #fff;
border-radius: 6px;
font-weight: 600;
padding: 0.4rem 1.1rem;
}
.shepherd-button:not(:disabled):hover {
background: #38bdf8;
color: #fff;
}
/* Secondary button (Back / Skip) → muted slate. */
.shepherd-button.shepherd-button-secondary {
background: #334155;
color: #f8fafc;
}
.shepherd-button.shepherd-button-secondary:not(:disabled):hover {
background: #475569;
color: #f8fafc;
}
.shepherd-cancel-icon {
color: #94a3b8;
}
.shepherd-cancel-icon:hover,
.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover {
color: #f8fafc;
}
.shepherd-has-title .shepherd-content .shepherd-cancel-icon {
color: #94a3b8;
}
/* Dim the page a touch more, matching the onboarding overlay (bg-black/60). */
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
opacity: 0.6;
}
+24 -1
View File
@@ -127,7 +127,13 @@
}
function _unseenRelevant(screenId) {
return _relevantPlugins(screenId).filter(p => !hasSeen(p.id) && !hasDismissed(p.id));
// Drives the toast prompt + button "has-unseen" pulse. A tour registered
// with autoPrompt:false is excluded — it's started programmatically by
// its owner (e.g. the first-run home tour), so nagging via toast/pulse
// would double up. It still lists in the menu and runs on demand.
return _relevantPlugins(screenId).filter(p =>
!hasSeen(p.id) && !hasDismissed(p.id) &&
(_registry[p.id] ? _registry[p.id].autoPrompt !== false : true));
}
// ── Menu UI ────────────────────────────────────────────────────────────
@@ -615,7 +621,24 @@
onStart: opts.onStart || null,
onComplete: opts.onComplete || null,
screens: Array.isArray(opts.screens) ? opts.screens.slice() : null,
// autoPrompt:false opts the tour OUT of the unseen toast + button
// pulse (it's driven programmatically by its owner, e.g. the
// first-run home tour started from onboarding). It still lists in the
// menu and runs via start(). Defaults to the legacy always-prompt.
autoPrompt: opts.autoPrompt !== false,
};
// A `name` registers a CLIENT/CORE-owned tour — one that isn't a
// server-discovered plugin with a tour.json — into the consolidated menu
// catalog so it appears in the "?" menu. Never clobber a richer entry the
// /api/plugins pass already supplied for a real plugin of the same id.
if (opts.name && !_tourPlugins[pluginId]) {
_tourPlugins[pluginId] = {
id: pluginId,
name: opts.name,
has_screen: true,
is_viz: false,
};
}
// If the override changes the relevance for the current screen, refresh.
_updateMenuVisibility();
}
+1 -1
View File
@@ -291,7 +291,7 @@
const host = document.getElementById('v3-badge-instrument');
if (!host) return;
host.innerHTML =
'<div class="relative">' +
'<div id="v3-instrument-wrap" class="relative">' +
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' +
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' +
guitarIcon +
+4 -4
View File
@@ -134,7 +134,7 @@
const bars = Array.from({ length: segs }, (_, i) =>
'<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join('');
continueCard =
'<button id="v3-continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
'<button id="v3-continue" data-tour="continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(cont.tuning_name, 'absolute top-3 right-3') +
@@ -146,7 +146,7 @@
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else if (pick) {
continueCard =
'<button id="v3-pick" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
'<button id="v3-pick" data-tour="continue" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(pick.tuning_name, 'absolute top-3 right-3') +
@@ -157,7 +157,7 @@
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else {
continueCard =
'<div class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
'<div data-tour="continue" class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
'<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' +
'<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>';
}
@@ -188,7 +188,7 @@
'<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') +
// Featured grid: hero + continue
'<div class="grid lg:grid-cols-3 gap-6 mt-6">' +
'<div class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
'<div id="v3-hero" class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
// Hero artwork (neon note-highway), right-anchored. Placeholder
// cropped from the design mock — swap static/v3/brand/hero.png for
// the designer's high-res original (same path) when available.
+5
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">
@@ -874,6 +875,10 @@
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
<script src="/static/v3/onboarding-tour.js"></script>
<script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script>
<script>
+115
View File
@@ -0,0 +1,115 @@
/*
* fee[dB]ack v0.3.0 first-run home tour.
*
* Registers a spotlight tour over the home-page cards with the shared tour
* engine (window.slopsmithTour / Shepherd) and auto-runs it once, the first
* time the user lands on the home page after completing onboarding. It stays
* replayable forever from the per-screen "?" tour menu (registered with
* screens: ['v3-home']).
*
* Anchors (all stable, on-screen while #v3-home is active):
* #v3-hero hero / Start Playing (dashboard.js)
* [data-tour="continue"] continue / pick a song (dashboard.js, 3 variants)
* #v3-instrument-wrap instrument selector badge (badges.js, topbar)
* #v3-tuner-wrap tuner badge (badges.js, topbar)
* #v3-audio-routing audio routing card (dashboard.js)
* [data-v3-open-profile] profile badge (profile.js, topbar)
* #v3-nav left sidebar navigation (index.html)
*/
(function () {
'use strict';
var TOUR_ID = 'home-onboarding';
// Each step dims the page and spotlights one element (shape: 'spotlight').
// waitFor blocks the step until its target exists, so the async dashboard
// re-render kicked off by 'v3:profile-updated' can't race the first step.
function buildSteps() {
return [
{
id: 'hero', shape: 'spotlight', position: 'bottom',
selector: '#v3-hero', waitFor: '#v3-hero',
title: 'Welcome to fee[dB]ack',
content: 'This is your home base. Hit Start Playing to drop straight into a song from your library.',
},
{
id: 'continue', shape: 'spotlight', position: 'left',
selector: '[data-tour="continue"]', waitFor: '[data-tour="continue"]',
title: 'Pick up where you left off',
content: 'Your last song resumes right here in one click. Before youve played anything, its a quick random pick to get you going.',
},
{
id: 'instrument', shape: 'spotlight', position: 'bottom',
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
title: 'Choose your instrument',
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
},
{
id: 'tuner', shape: 'spotlight', position: 'bottom',
selector: '#v3-tuner-wrap', waitFor: '#v3-tuner-wrap',
title: 'Tune up first',
content: 'Open the tuner and match each string until the meter centers — accurate tuning means accurate scoring.',
},
{
id: 'audio', shape: 'spotlight', position: 'top',
selector: '#v3-audio-routing', waitFor: '#v3-audio-routing',
title: 'Your signal path',
content: 'Input → amp / NAM / IR → output, at a glance. Set up and monitor your gear from this card.',
},
{
id: 'profile', shape: 'spotlight', position: 'bottom',
selector: '[data-v3-open-profile]', waitFor: '[data-v3-open-profile]',
title: 'Track your progress',
content: 'Your profile, avatar and rank live here. Watch your accuracy climb and level up as you play.',
},
{
id: 'nav', shape: 'spotlight', position: 'right',
selector: '#v3-nav', waitFor: '#v3-nav',
title: 'Find everything here',
content: 'Browse your full library, lessons and plugins anytime. You can replay this tour from the ? button in the corner.',
},
];
}
function register() {
var t = window.slopsmithTour;
if (!t || typeof t.register !== 'function') return false;
t.register(TOUR_ID, {
name: 'Welcome tour', // label in the "?" tour menu
screens: ['v3-home'], // relevant only on the home screen
autoPrompt: false, // we auto-run it from onboarding; no toast nag
buildSteps: buildSteps,
});
return true;
}
// Auto-run once after a genuine onboarding completion. profile.js gates the
// call on !editing (a profile edit must not relaunch it); we additionally
// honour the engine's own seen/dismissed state so it never repeats and a
// dismissal isn't nagged. Stays replayable from the "?" menu either way.
function startFirstRun() {
var t = window.slopsmithTour;
if (!t || typeof t.start !== 'function') return;
try {
if (t.hasSeen(TOUR_ID) || t.hasDismissed(TOUR_ID)) return;
} catch (e) { /* private mode — fall through and attempt once */ }
// Make sure the home screen is in view so the spotlight targets exist;
// the per-step waitFor handles the async dashboard render.
if (typeof window.showScreen === 'function') {
try { window.showScreen('v3-home'); } catch (e) { /* best-effort */ }
}
// Defer a frame so the 'v3:profile-updated' dashboard re-render has a
// chance to begin before Shepherd starts polling for the first target.
var raf = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
raf(function () { try { t.start(TOUR_ID); } catch (e) { /* degrade */ } });
}
window.v3OnboardingTour = { startFirstRun: startFirstRun };
// tour-engine.js assigns window.slopsmithTour at script-eval time, so if it
// is loaded before us register() succeeds immediately; otherwise retry once
// the DOM (and the engine) are ready.
if (!register()) {
document.addEventListener('DOMContentLoaded', register, { once: true });
}
})();
+155 -18
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,15 +233,24 @@
'<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">Slopsmith 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">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>' +
'<p id="v3-ob-error" class="text-sm text-fb-accent hidden"></p>' +
'<div class="flex justify-end gap-3">' +
@@ -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' },
@@ -357,7 +462,8 @@
return body;
}
async function finish() {
async function finish(finishOpts) {
finishOpts = finishOpts || {};
overlay.remove();
await fetchProgress();
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
@@ -366,6 +472,16 @@
renderBadge();
renderProfileScreen();
if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('v3:profile-updated', _profile);
// First-run only: after a genuine onboarding completion (not a
// profile edit), kick off the one-time home tour — but NOT when we're
// about to launch the diagnostic ("Play it now"), which navigates to
// the player; the tour would otherwise spotlight hidden home elements
// and steal focus. The Skip path stays on home, so it runs there.
// The engine's seen/dismissed state keeps it once; replayable from "?".
if (!editing && !finishOpts.launchingSong &&
window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') {
try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ }
}
}
submit.addEventListener('click', async () => {
@@ -380,12 +496,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,19 +530,29 @@
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();
await finish({ launchingSong: !!target });
if (target && typeof window.playSong === 'function') window.playSong(target);
});
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 {
+1 -1
View File
@@ -103,7 +103,7 @@
'<div class="flex items-center justify-between gap-3 flex-wrap">' +
'<div class="min-w-0">' +
'<h3 class="text-lg font-bold text-fb-text">Calibration challenge</h3>' +
'<p class="text-sm text-fb-textDim mt-1">Play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at ' +
'<p class="text-sm text-fb-textDim mt-1">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>' +
(pending ? ' to reach Mastery Rank 1.' : ' to prove your setup (you skipped this — rank already granted).') + '</p></div>' +
'<div class="flex items-center gap-2 shrink-0">' +
+5 -1
View File
@@ -30,6 +30,7 @@
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
{ key: 'studio', screen: 'plugin-rig_builder', label: 'Studio', group: 'LIBRARY', icon: 'studio' },
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
@@ -51,6 +52,8 @@
user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0',
arcade: 'M7 8h10a4 4 0 014 4 4 4 0 01-4 4H7a4 4 0 01-4-4 4 4 0 014-4zm0 4h3m-1.5-1.5v3M15 11h.01M17.5 13h.01',
lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5',
studio: 'M5 3v6M5 13v8M12 3v10M12 17v4M19 3v4M19 11v10M2 11h6M9 15h6M16 7h6',
trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4',
tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01',
};
@@ -232,7 +235,8 @@
const res = await fetch('/api/plugins');
if (res.ok) plugins = await res.json();
} catch (e) { return; } // degrade: no plugin group
const withNav = (Array.isArray(plugins) ? plugins : []).filter((p) => p && p.nav && (p.nav.label || p.name));
const withNav = (Array.isArray(plugins) ? plugins : []).filter((p) => p && p.nav && (p.nav.label || p.name)
&& p.id !== 'rig_builder'); // rig_builder is pinned into the LIBRARY group as "Studio"
if (!withNav.length) return;
let html = '<div class="px-3 mt-2 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">PLUGINS</div><div class="space-y-0.5">';
for (const p of withNav) {
+216
View File
@@ -0,0 +1,216 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const MIDI_INPUT_JS = path.join(ROOT, 'static', 'capabilities', 'midi-input.js');
function loadMidiInput(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
return window;
}
// A fake provider whose enumerate/open/close are observable by the test.
function fakeProvider(window, overrides = {}) {
const calls = { enumerate: 0, open: [], close: [] };
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi',
label: 'Web MIDI',
participantId: 'input_setup',
enumerate: async () => { calls.enumerate += 1; return overrides.sources || [{ sourceId: 'dev1', label: 'My Keyboard' }]; },
open: async (sourceId) => { calls.open.push(sourceId); return { addListener() {}, removeListener() {}, _id: sourceId }; },
close: (sourceId, handle) => { calls.close.push(sourceId); },
...overrides.handlers,
});
return calls;
}
test('midi-input registers an active sensitive provider-coordinator', () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const pipeline = api.inspect('midi-input');
assert.ok(pipeline, 'midi-input pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.midi-input');
assert.ok(owner, 'core.midi-input owner registered');
assert.equal(owner.safety, 'sensitive');
assert.equal(owner.kind, 'provider-coordinator');
for (const cmd of ['inspect', 'list-sources', 'discover', 'select-source', 'open-source', 'close-source']) {
assert.ok(owner.commands.includes(cmd), `owner exposes ${cmd}`);
}
assert.equal(window.slopsmith.midiInput.version, 1);
});
test('list-sources and select-source are prompt-free (never enumerate)', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
const listed = await api.dispatch({ capability: 'midi-input', command: 'list-sources', source: 'tester' });
assert.equal(listed.outcome, 'handled');
assert.equal(calls.enumerate, 0, 'list-sources must not request MIDI access');
});
test('discover is the permission boundary and surfaces sources', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'handled');
assert.equal(calls.enumerate, 1, 'discover requests MIDI access exactly once');
const sources = window.slopsmith.midiInput.listSources();
assert.equal(sources.length, 1);
assert.equal(sources[0].logicalSourceKey, 'web-midi::dev1');
assert.equal(sources[0].kind, 'midi');
});
test('re-discovery drops sources for devices that vanished', async () => {
const window = loadMidiInput();
let devices = [{ sourceId: 'dev1', label: 'A' }, { sourceId: 'dev2', label: 'B' }];
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi', label: 'Web MIDI',
enumerate: async () => devices,
open: async () => ({ addListener() {}, removeListener() {} }),
close: () => {},
});
await window.slopsmith.midiInput.discover();
assert.equal(window.slopsmith.midiInput.listSources().length, 2);
devices = [{ sourceId: 'dev1', label: 'A' }]; // dev2 unplugged
await window.slopsmith.midiInput.discover();
const keys = window.slopsmith.midiInput.listSources().map((s) => s.logicalSourceKey);
assert.equal(keys.length, 1, 'vanished device is dropped from the source list');
assert.equal(keys[0], 'web-midi::dev1');
});
test('discover with no provider reports unavailable', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'unavailable');
});
test('discover surfaces denied when MIDI access is rejected', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
fakeProvider(window, { handlers: { enumerate: async () => { throw new Error('SecurityError: permission denied'); } } });
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'denied');
assert.match(r.reason, /denied/i);
});
test('select-source persists by logicalSourceKey', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
fakeProvider(window);
await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
const sel = await api.dispatch({ capability: 'midi-input', command: 'select-source', source: 'tester', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(sel.outcome, 'handled');
assert.equal(window.__storage.get('slopsmith.midiInput.selectedLogicalSourceKey'), 'web-midi::dev1');
assert.ok(window.slopsmith.midiInput.listSources()[0].selected);
});
test('open/close share one session and release on the last requester', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
const calls = fakeProvider(window);
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const a = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
const b = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(a.outcome, 'handled');
assert.equal(b.outcome, 'handled');
assert.equal(calls.open.length, 1, 'provider.open called once for a shared session');
// First release keeps the session open; second closes it.
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close.length, 0, 'session stays open while a requester holds it');
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close.length, 1, 'provider.close after the last release');
});
test('concurrent opens for one source coalesce onto a single provider.open', async () => {
const window = loadMidiInput();
const api = window.slopsmith.capabilities;
// A provider whose open() stays pending until we release it, so both
// dispatches are genuinely in flight at the same time.
let release;
const gate = new Promise((r) => { release = r; });
const calls = { open: 0, close: 0 };
window.slopsmith.midiInput.registerProvider({
providerId: 'web-midi', label: 'Web MIDI',
enumerate: async () => [{ sourceId: 'dev1', label: 'My Keyboard' }],
open: async () => { calls.open += 1; await gate; return { addListener() {}, removeListener() {} }; },
close: () => { calls.close += 1; },
});
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const p1 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
const p2 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
release();
const [a, b] = await Promise.all([p1, p2]);
assert.equal(a.outcome, 'handled');
assert.equal(b.outcome, 'handled');
assert.equal(calls.open, 1, 'provider.open called exactly once despite concurrent opens');
// Both requesters joined the single shared session: it survives the first
// release and only closes on the last, with exactly one provider.close.
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close, 0, 'shared session stays open while reqB holds it');
await api.dispatch({ capability: 'midi-input', command: 'close-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
assert.equal(calls.close, 1, 'provider.close once after the last requester releases');
});
test('public open() surfaces the live handle (in-page only)', async () => {
const window = loadMidiInput();
fakeProvider(window);
await window.slopsmith.midiInput.discover();
await window.slopsmith.midiInput.select('web-midi::dev1');
const res = await window.slopsmith.midiInput.open({ requester: 'input_setup', logicalSourceKey: 'web-midi::dev1' });
assert.equal(res.outcome, 'handled');
assert.ok(res.handle && typeof res.handle.addListener === 'function', 'live handle exposed via public global');
});
// Load the domain with a Web-MIDI-capable navigator so the built-in provider
// self-registers (the shared harness has no navigator, so it normally skips).
function loadWithWebMidi(inputs) {
const window = createWindow();
window.navigator = {
requestMIDIAccess: async () => ({
onstatechange: null,
inputs: new Map(inputs.map((i) => [i.id, { id: i.id, name: i.name, onmidimessage: null }])),
}),
};
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(MIDI_INPUT_JS, 'utf8'), context, { filename: MIDI_INPUT_JS });
return window;
}
test('built-in Web-MIDI provider self-registers + discovers, filtering loopback ports', async () => {
const window = loadWithWebMidi([
{ id: 'kb1', name: 'My Keyboard' },
{ id: 'thru', name: 'Midi Through Port-0' }, // loopback → filtered out
]);
const api = window.slopsmith.capabilities;
assert.ok(api.inspect('midi-input').participants.some(p => p.pluginId === 'core.midi-input'),
'built-in provider registered without any plugin');
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
assert.equal(r.outcome, 'handled');
const sources = window.slopsmith.midiInput.listSources();
assert.equal(sources.length, 1, 'loopback/passthrough ports are filtered');
assert.equal(sources[0].logicalSourceKey, 'web-midi::kb1');
});
test('diagnostics are redaction-safe (no device labels, no raw messages)', async () => {
const window = loadMidiInput();
fakeProvider(window);
await window.slopsmith.midiInput.discover();
const contrib = window.slopsmith.diagnostics.snapshotContributions()['midi-input-capability'];
assert.ok(contrib, 'midi-input contributes diagnostics');
assert.equal(contrib.schema, 'slopsmith.midi_input.diagnostics.v1');
const serialized = JSON.stringify(contrib);
assert.ok(!serialized.includes('My Keyboard'), 'device labels are redacted from diagnostics');
for (const s of contrib.sources) assert.ok(!('label' in s), 'source entries carry no label');
});