mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
The onboarding audio picker de-duped the device list by display LABEL. On
Windows the engine enumerates one interface once per host API (ASIO /
Windows Audio / DirectSound) with the same name, so the variants collapsed
to a single choice — silently keeping whichever sorted first, often not the
low-latency ASIO one the player wants. It could also drop the variant that
was actually `selected`.
The audio-input capability already collapses true duplicates by
logicalSourceKey (_visibleInputSources), and these variants each have a
DISTINCT key, so the wizard's extra label-collapse was redundant for real
dupes and destructive for the variants. Removed it; the picker now lists
every selectable input.
Pairs with feedBack-desktop's change to tag each source label with its
driver type ("Focusrite (ASIO)" vs "(Windows Audio)") so the now-distinct
entries are legible.
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
370 lines
22 KiB
JavaScript
370 lines
22 KiB
JavaScript
/*
|
||
* 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.feedBack = window.feedBack || {};
|
||
if (window.feedBackInputSetup && window.feedBackInputSetup.version === 1) return;
|
||
|
||
const capabilities = window.feedBack.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) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.feedBack.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 || '')));
|
||
// No label de-dupe here. The audio-input capability already
|
||
// collapses exact duplicates by logicalSourceKey
|
||
// (_visibleInputSources), so nothing it returns shares a key. A
|
||
// device that enumerates under several driver types (ASIO / Windows
|
||
// Audio / DirectSound) has a DISTINCT key per type and is now
|
||
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
|
||
// a real, separately-selectable input the user must be able to see.
|
||
// The old bare-label collapse also kept whichever variant sorted
|
||
// first, which could silently drop the one that was actually
|
||
// `selected` below.
|
||
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 isn’t 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) {
|
||
// Hide our own full-screen overlay while note_detect's
|
||
// Calibration Wizard runs on top. That wizard goes
|
||
// transparent (pointer-events:none) when it minimizes to
|
||
// expose the Tuner; if our overlay stayed up it would show
|
||
// through — covering the tuner with the still-mounted
|
||
// "select your input" card and looking like a second
|
||
// wizard at the input step. Restore it on done/cancel
|
||
// (one of which always fires when that wizard closes).
|
||
const ov = document.getElementById('input-setup-overlay');
|
||
const prevDisplay = ov ? ov.style.display : '';
|
||
if (ov) ov.style.display = 'none';
|
||
const restore = () => {
|
||
const o = document.getElementById('input-setup-overlay');
|
||
if (o) o.style.display = prevDisplay;
|
||
};
|
||
window.noteDetect.launchCalibration({
|
||
instrument: inst,
|
||
onDone: () => { restore(); advance(inst, true); },
|
||
onCancel: () => { restore(); /* 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.feedBack.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 isn’t 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 it’s 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.feedBack.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.feedBackInputSetup = {
|
||
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);
|
||
};
|
||
})();
|