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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-19 16:31:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 313348a1ff
commit fb06e288e1
15 changed files with 1396 additions and 19 deletions
+404
View File
@@ -0,0 +1,404 @@
// Core MIDI-input capability domain (spec 012 control plane).
//
// The MIDI analog of `audio-input`: a core-owned provider-coordinator over MIDI
// device discovery, selection, and open/close session lifecycle. It is NOT
// owned by any feature plugin (an input plane outlives any feature, exactly as
// `audio-input` is `core.audio.session`-owned), and it is deliberately separate
// from `audio-input` (whose source/open contract is audio-frame-centric:
// channel shapes, sample buffers) — MIDI carries discrete messages, not audio.
//
// Consumers (the input_setup wizard, the piano/keys and drums plugins, and —
// later — note-detection's Web-MIDI provider) converge on ONE device-access
// boundary here: one permission prompt, one source list, one redaction
// boundary, replacing private per-plugin `navigator.requestMIDIAccess()` calls.
//
// Web-MIDI nuance vs audio: `requestMIDIAccess()` gates the whole input LIST, so
// `discover` (not `open-source`) is the permission boundary for MIDI. `inspect`
// / `list-sources` / `select-source` stay prompt-free and never request access.
//
// Live message delivery (needed by the "play a note / hit a pad" calibration
// check) is exposed to in-page consumers through the public global's session
// handle, never as raw capability events or diagnostics.
(function () {
'use strict';
window.slopsmith = window.slopsmith || {};
const capabilities = window.slopsmith.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.slopsmith.midiInput && window.slopsmith.midiInput.version === 1) return;
const STORAGE_KEY = 'slopsmith.midiInput.selectedLogicalSourceKey';
// providerId → { id, label, participantId, handlers:{ enumerate, open, close } }
// handlers are LIVE functions supplied in-page via the public global; they
// never travel through the capability `command` payload.
const providers = new Map();
// logicalSourceKey → { sourceId, providerId, logicalSourceKey, kind, label, availability }
const sources = new Map();
// logicalSourceKey → { refs:Set<requester>, handle } — one shared open
// session per source; the provider is closed only after the last release.
const sessions = new Map();
// logicalSourceKey → Promise — in-flight provider.open() calls, so concurrent
// opens for the same source coalesce onto one provider session instead of each
// calling provider.open() (which, for Web-MIDI, would overwrite the shared
// input.onmidimessage handler and orphan the earlier session/handle).
const opening = new Map();
let selectedKey = _readStorage();
let lastOutcome = null;
// ── outcome helpers (mirror note-detection.js) ──────────────────────────
function _handled(payload = {}) { lastOutcome = { outcome: 'handled' }; return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { lastOutcome = { outcome: 'degraded', reason }; return { outcome: 'degraded', reason, payload }; }
function _denied(reason, payload = {}) { lastOutcome = { outcome: 'denied', reason }; return { outcome: 'denied', reason, payload }; }
function _unavailable(reason, payload = {}) { lastOutcome = { outcome: 'unavailable', reason }; return { outcome: 'unavailable', reason, payload }; }
function _emit(name, detail) {
try { capabilities.emitEvent('midi-input', name, detail || {}); }
catch (_) { /* eventing must not break input */ }
}
function _readStorage() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _writeStorage(key) {
try { if (key) window.localStorage.setItem(STORAGE_KEY, key); else window.localStorage.removeItem(STORAGE_KEY); return true; }
catch (_) { return false; }
}
function _str(v, fallback) { const s = (v == null ? '' : String(v)).trim(); return s || fallback; }
// A stable, redaction-safe key for persistence: provider + a stable source
// id, NOT the human device label.
function _logicalKey(providerId, sourceId) { return `${providerId}::${sourceId}`; }
// ── snapshots ───────────────────────────────────────────────────────────
// listShape keeps labels (this feeds the picker UI); diagShape strips them
// (device labels are PII-adjacent).
function _sourceListShape() {
return Array.from(sources.values()).map((s) => ({
logicalSourceKey: s.logicalSourceKey,
sourceId: s.sourceId,
providerId: s.providerId,
kind: s.kind,
label: s.label,
availability: s.availability,
selected: s.logicalSourceKey === selectedKey,
open: sessions.has(s.logicalSourceKey),
}));
}
function _snapshot(extra = {}) {
return {
available: providers.size > 0,
providers: Array.from(providers.values()).map((p) => ({ id: p.id, label: p.label })),
sources: _sourceListShape(),
selected: selectedKey,
openSessions: Array.from(sessions.keys()),
lastOutcome: lastOutcome ? { ...lastOutcome } : null,
...extra,
};
}
function _contributeDiagnostics() {
const diagnostics = window.slopsmith && window.slopsmith.diagnostics;
if (!diagnostics || typeof diagnostics.contribute !== 'function') return;
try {
const snap = _snapshot();
// Redact device labels everywhere — keep ids/kind/availability for
// operational observability only. No raw MIDI messages ever.
const redacted = {
...snap,
providers: snap.providers.map(({ label: _l, ...safe }) => safe),
sources: snap.sources.map(({ label: _l, ...safe }) => safe),
};
diagnostics.contribute('midi-input-capability', {
schema: 'slopsmith.midi_input.diagnostics.v1',
...redacted,
});
} catch (_) { /* diagnostics must not break input */ }
}
// ── provider registry (live handlers via the public global) ─────────────
function _registerProvider(input = {}) {
const providerId = _str(input.providerId || input.id, '');
if (!providerId) return null;
const handlers = {
enumerate: typeof input.enumerate === 'function' ? input.enumerate : null,
open: typeof input.open === 'function' ? input.open : null,
close: typeof input.close === 'function' ? input.close : null,
};
const participantId = _str(input.participantId, providerId);
const wasAvailable = providers.size > 0;
providers.set(providerId, { id: providerId, label: _str(input.label, providerId), participantId, handlers });
// Mirror a serializable declaration into the capability graph so the
// Inspector/diagnostics can reason about the provider relationship.
try {
capabilities.registerParticipant(participantId, {
'midi-input': {
roles: ['provider'],
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
mode: 'active',
safety: 'sensitive',
runtime: true,
description: `MIDI input provider ${_str(input.label, providerId)}.`,
provider_policy: { providerId },
},
});
} catch (_) { /* declaration is best-effort */ }
_emit('provider-registered', { providerId });
if (!wasAvailable) _emit('availability-changed', { available: true });
_contributeDiagnostics();
return { providerId };
}
function _unregisterProvider(providerId) {
providerId = _str(providerId, '');
const provider = providers.get(providerId);
if (!provider) return false;
// Drop the provider's sources + any open sessions.
for (const [key, s] of Array.from(sources.entries())) {
if (s.providerId === providerId) {
_closeSessionInternal(key, 'provider-unregistered');
sources.delete(key);
}
}
providers.delete(providerId);
if (typeof capabilities.unregisterParticipant === 'function') {
try { capabilities.unregisterParticipant(provider.participantId, 'midi-input'); } catch (_) { /* best-effort */ }
}
_emit('provider-unregistered', { providerId });
if (providers.size === 0) _emit('availability-changed', { available: false });
_contributeDiagnostics();
return true;
}
// ── discovery (the Web-MIDI permission boundary) ────────────────────────
async function _discover() {
if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot());
let found = 0;
for (const provider of providers.values()) {
if (!provider.handlers.enumerate) continue;
let list;
try { list = await provider.handlers.enumerate(); }
catch (e) {
// requestMIDIAccess rejection = permission denied / unsupported.
return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot());
}
const fresh = new Set();
for (const raw of (Array.isArray(list) ? list : [])) {
const sourceId = _str(raw.sourceId || raw.id, '');
if (!sourceId) continue;
const key = _logicalKey(provider.id, sourceId);
fresh.add(key);
sources.set(key, {
sourceId,
providerId: provider.id,
logicalSourceKey: key,
kind: 'midi',
label: _str(raw.label || raw.name, 'MIDI input'),
availability: _str(raw.availability, 'available'),
});
found += 1;
}
// Reconcile: drop this provider's sources that vanished since the
// last enumeration (e.g. a device unplugged, firing statechange).
// Without this, list-sources keeps showing disconnected devices and
// selecting/opening them later fails on stale state.
for (const [key, s] of Array.from(sources.entries())) {
if (s.providerId === provider.id && !fresh.has(key)) {
// Close any live session but KEEP the selectedKey preference
// — the device may be replugged and should re-select.
_closeSessionInternal(key, 'device-removed');
sources.delete(key);
}
}
}
// Restore a previously-selected source if it reappeared.
if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ }
_emit('sources-changed', { count: found });
_contributeDiagnostics();
return _handled(_snapshot({ discovered: found }));
}
function _selectSource(ctx = {}) {
const payload = ctx.payload || {};
const key = _str(payload.logicalSourceKey, '');
if (!key) return _degraded('select-source requires a logicalSourceKey', _snapshot());
if (!sources.has(key)) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
selectedKey = key;
_writeStorage(key);
_emit('source-selected', { logicalSourceKey: key });
_contributeDiagnostics();
return _handled(_snapshot({ selected: key }));
}
async function _openSource(ctx = {}) {
const payload = ctx.payload || {};
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
const key = _str(payload.logicalSourceKey, selectedKey || '');
if (!key) return _degraded('No MIDI source selected', _snapshot());
const source = sources.get(key);
if (!source) return _degraded(`Unknown MIDI source: ${key}`, _snapshot());
const provider = providers.get(source.providerId);
if (!provider || !provider.handlers.open) return _unavailable('Provider cannot open MIDI input', _snapshot());
// Share one open session per source across requesters.
let session = sessions.get(key);
if (session) {
session.refs.add(requester);
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
}
// Coalesce concurrent opens for the same source: if a provider.open() is
// already in flight for this key, await it and adopt the resulting session
// rather than opening the device a second time.
if (opening.has(key)) {
try { await opening.get(key); } catch (_) { /* fall through to retry below */ }
session = sessions.get(key);
if (session) {
session.refs.add(requester);
return _handled(_snapshot({ sessionId: session.sessionId, shared: true }));
}
}
let handle;
const openPromise = provider.handlers.open(source.sourceId, { requester });
opening.set(key, openPromise);
try { handle = await openPromise; }
catch (e) { return _denied(_str(e && e.message, 'Could not open MIDI input'), _snapshot()); }
finally { if (opening.get(key) === openPromise) opening.delete(key); }
// A concurrent open may have won the race while we awaited; adopt its
// session and release our redundant handle so we don't orphan a device.
const existing = sessions.get(key);
if (existing) {
if (provider.handlers.close) {
try { provider.handlers.close(source.sourceId, handle); } catch (_) { /* best-effort */ }
}
existing.refs.add(requester);
return _handled(_snapshot({ sessionId: existing.sessionId, shared: true }));
}
session = { sessionId: `mis-${key}`, refs: new Set([requester]), handle };
sessions.set(key, session);
_emit('source-opened', { logicalSourceKey: key, requester });
_contributeDiagnostics();
return _handled(_snapshot({ sessionId: session.sessionId }));
}
function _closeSessionInternal(key, reason) {
const session = sessions.get(key);
if (!session) return;
const source = sources.get(key);
const provider = source && providers.get(source.providerId);
if (provider && provider.handlers.close) {
try { provider.handlers.close(source.sourceId, session.handle); } catch (_) { /* best-effort */ }
}
sessions.delete(key);
_emit('source-closed', { logicalSourceKey: key, reason: reason || 'closed' });
}
function _closeSource(ctx = {}) {
const payload = ctx.payload || {};
const requester = _str(ctx.source || ctx.requester || payload.requester, 'unknown');
const key = _str(payload.logicalSourceKey, selectedKey || '');
const session = sessions.get(key);
if (!session) return _handled(_snapshot({ closed: key, alreadyClosed: true }));
session.refs.delete(requester);
if (session.refs.size === 0) {
_closeSessionInternal(key, 'released');
_contributeDiagnostics();
}
return _handled(_snapshot({ closed: key }));
}
capabilities.registerOwner('midi-input', {
pluginId: 'core.midi-input',
kind: 'provider-coordinator',
safety: 'sensitive',
commands: [
'inspect', 'list-sources', 'discover',
'select-source', 'open-source', 'close-source',
],
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
events: [
'provider-registered', 'provider-unregistered', 'availability-changed',
'sources-changed', 'source-selected', 'source-opened', 'source-closed',
],
description: 'Core-owned MIDI device control plane: discovery, selection, and shared open/close sessions. `discover` is the Web-MIDI permission boundary.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-sources': () => _handled(_snapshot()), // prompt-free; never requests access
discover: (ctx) => _discover(ctx), // permission boundary
'select-source': (ctx) => _selectSource(ctx), // prompt-free
'open-source': (ctx) => _openSource(ctx),
'close-source': (ctx) => _closeSource(ctx),
},
});
// ── public global (live surface for in-page consumers) ──────────────────
// Providers register live handlers here; consumers (input_setup, piano,
// drums) get a live session handle for the "play a note" check.
window.slopsmith.midiInput = {
version: 1,
snapshot: _snapshot,
listSources: () => _sourceListShape(),
getSelected: () => selectedKey,
registerProvider: _registerProvider,
unregisterProvider: _unregisterProvider,
discover: () => _discover(),
select: (logicalSourceKey) => _selectSource({ payload: { logicalSourceKey } }),
// Returns { outcome, sessionId, handle } where handle is the provider's
// live MIDI input wrapper (exposes addListener/removeListener). The live
// handle is surfaced ONLY through this in-page global, never through the
// serializable `open-source` command payload. Use for calibration
// note/pad checks.
open: async (opts = {}) => {
const result = await _openSource({ source: opts.requester || 'in-page', payload: opts });
const key = _str(opts.logicalSourceKey, selectedKey || '');
const session = sessions.get(key);
return { ...result, handle: session ? session.handle : null, sessionId: session ? session.sessionId : null };
},
close: (opts = {}) => _closeSource({ source: opts.requester || 'in-page', payload: opts }),
};
// ── built-in Web-MIDI provider ──────────────────────────────────────────
// Ship a default Web-MIDI source provider so every consumer (piano, drums,
// input_setup, …) gets MIDI devices from the domain without any one plugin
// having to register the provider. Guarded by Web-MIDI support;
// requestMIDIAccess() is the permission boundary, called lazily on discover.
(function _registerBuiltinWebMidiProvider() {
if (typeof navigator === 'undefined' || typeof navigator.requestMIDIAccess !== 'function') return;
const BLOCK = /(midi through|thru|iac)/i; // loopback / passthrough ports
let access = null;
_registerProvider({
providerId: 'web-midi',
label: 'Web MIDI',
participantId: 'core.midi-input',
enumerate: async () => {
access = await navigator.requestMIDIAccess({ sysex: false });
try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ }
const out = [];
access.inputs.forEach((input) => {
if (BLOCK.test(input.name || '')) return;
out.push({ sourceId: input.id, label: input.name || 'MIDI input', availability: 'available' });
});
return out;
},
open: async (sourceId) => {
if (!access) access = await navigator.requestMIDIAccess({ sysex: false });
const input = access.inputs.get(sourceId);
if (!input) throw new Error('MIDI input not found');
const listeners = new Set();
input.onmidimessage = (e) => { listeners.forEach((fn) => { try { fn(e.data); } catch (_) { /* listener isolation */ } }); };
return {
addListener: (fn) => { if (typeof fn === 'function') listeners.add(fn); },
removeListener: (fn) => listeners.delete(fn),
_input: input,
};
},
close: (sourceId, handle) => {
if (handle && handle._input) { try { handle._input.onmidimessage = null; } catch (_) { /* best-effort */ } }
},
});
})();
_contributeDiagnostics();
})();