mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
431 lines
22 KiB
JavaScript
431 lines
22 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.feedBack = window.feedBack || {};
|
|
const capabilities = window.feedBack.capabilities;
|
|
if (!capabilities || capabilities.version !== 1) return;
|
|
if (window.feedBack.midiInput && window.feedBack.midiInput.version === 1) return;
|
|
|
|
const STORAGE_KEY = 'feedBack.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.feedBack && window.feedBack.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: 'feedBack.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;
|
|
let enumerated = 0;
|
|
let lastError = null;
|
|
for (const provider of providers.values()) {
|
|
if (!provider.handlers.enumerate) continue;
|
|
let list;
|
|
try { list = await provider.handlers.enumerate(); }
|
|
catch (e) {
|
|
// One provider failing (e.g. browser Web-MIDI permission denied,
|
|
// or unsupported) must NOT hide other providers' devices (e.g. a
|
|
// native/desktop MIDI provider). Record it and keep going; denial
|
|
// is only reported below when NO provider enumerated successfully.
|
|
lastError = e;
|
|
// Drop this provider's now-unverifiable sources so revoked /
|
|
// unplugged devices don't linger as selectable and fail on a
|
|
// later open (it just failed to enumerate, so we can't trust its
|
|
// previous list).
|
|
for (const [key, s] of Array.from(sources.entries())) {
|
|
if (s.providerId === provider.id) {
|
|
_closeSessionInternal(key, 'enumerate-failed');
|
|
sources.delete(key);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
enumerated += 1;
|
|
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 */ }
|
|
// Only surface a denial when NO provider could enumerate — otherwise a
|
|
// single denied/unsupported provider would mask the working ones.
|
|
if (enumerated === 0 && lastError) {
|
|
return _denied(_str(lastError && lastError.message, 'MIDI access denied'), _snapshot());
|
|
}
|
|
_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.feedBack.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();
|
|
})();
|