mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 06:42:32 +00:00
* 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>
409 lines
21 KiB
JavaScript
409 lines
21 KiB
JavaScript
// 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();
|
|
})();
|