feedBack/plugins/input_setup/screen.js
ChrisBeWithYou 271fedda55
fix(input_setup): stop collapsing audio driver-type variants in the wizard (#627)
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>
2026-06-28 20:25:49 +02:00

370 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function _isDone(inst) { try { return window.localStorage.getItem(DONE_KEY(inst)) === '1'; } catch (_) { return false; } }
function _markDone(inst, v) { try { if (v) window.localStorage.setItem(DONE_KEY(inst), '1'); else window.localStorage.removeItem(DONE_KEY(inst)); } catch (_) { /* private mode */ } }
// The Web-MIDI source provider now ships built-in with the core midi-input
// domain (static/capabilities/midi-input.js), so input_setup is a pure
// consumer — it just discovers/selects/opens through `window.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 isnt loaded here — you can calibrate later from the player.</p>');
const foot =
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
shell(inst, body, foot);
const sel = host.querySelector('[data-is-audio]');
const commitAudio = (key) => {
if (!capabilities || !key) return;
capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {});
};
if (sel) {
sel.addEventListener('change', () => commitAudio(sel.value));
// The <select> shows its first option by default, but no `change`
// fires for that implicit pick — so on a first run with nothing yet
// selected, audio-input would calibrate against the wrong/no source.
// Commit the shown option up-front so the displayed device is the
// one calibrated (idempotent if it was already selected).
if (!selected) commitAudio(sel.value);
}
// Tell the tuner tables / note_detect which instrument this is.
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
// 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 isnt available here. Connect a MIDI keyboard/e-kit in a supported environment, or skip for now.</p>',
'<button type="button" data-is-skip2 class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">Continue</button>');
host.querySelector('[data-is-skip2]').addEventListener('click', () => advance(inst, false));
return;
}
const verb = inst === 'drums' ? 'hit a pad' : 'play a note';
shell(inst,
'<p class="text-sm text-fb-textDim">Connect your MIDI device, pick it below, then ' + verb + ' to confirm its working.</p>' +
'<div class="mt-3 flex items-center gap-2">' +
'<button type="button" data-is-scan class="text-sm text-fb-primary hover:text-fb-primaryHi">Scan for MIDI devices</button></div>' +
'<div data-is-midi-wrap class="hidden mt-2">' +
'<select data-is-midi class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none"></select>' +
'<p data-is-test class="text-sm text-fb-textDim mt-2">Waiting for input…</p></div>',
'<button type="button" data-is-next disabled class="bg-fb-primary disabled:opacity-40 text-white px-5 py-2 rounded-md font-medium">Continue</button>');
const wrap = host.querySelector('[data-is-midi-wrap]');
const select = host.querySelector('[data-is-midi]');
const testEl = host.querySelector('[data-is-test]');
const nextBtn = host.querySelector('[data-is-next]');
let activeKey = null;
let listener = null;
let activeHandle = null;
let openSeq = 0;
async function openSelected() {
// Tear down the previous device + RESET the confirmation
// state, so a hit on a prior device can't leave Continue
// enabled for a newly-selected device that hasn't been heard.
const myGen = ++openSeq;
if (activeHandle && listener) { try { activeHandle.removeListener(listener); } catch (_) {} }
if (activeKey) { try { mi.close({ requester: 'input_setup', logicalSourceKey: activeKey }); } catch (_) {} }
activeHandle = null;
listener = null;
nextBtn.disabled = true;
_markDone(inst, false);
// Capture the requested key in a local: a newer openSelected()
// overwrites the shared `activeKey`, so comparing it after the
// awaits would let a stale open bind the wrong device.
const requestedKey = select.value;
activeKey = requestedKey;
if (!requestedKey) { testEl.textContent = ''; return; }
testEl.textContent = 'Waiting for input…';
await mi.select(requestedKey);
const res = await mi.open({ requester: 'input_setup', logicalSourceKey: requestedKey });
// Discard a stale open if a newer openSelected() superseded us.
if (myGen !== openSeq) { try { if (res) mi.close({ requester: 'input_setup', logicalSourceKey: requestedKey }); } catch (_) {} return; }
if (!res || !res.handle) { testEl.textContent = 'Could not open this device.'; activeKey = null; return; }
activeHandle = res.handle;
listener = (data) => {
// 0x90 = note-on (any channel); velocity > 0.
if (data && (data[0] & 0xf0) === 0x90 && data[2] > 0) {
testEl.innerHTML = '<span class="text-fb-primary font-semibold">✓ Got it</span> — device is working.';
nextBtn.disabled = false;
_markDone(inst, true);
}
};
activeHandle.addListener(listener);
}
host.querySelector('[data-is-scan]').addEventListener('click', async () => {
await mi.discover();
// Show every source the midi-input domain surfaces — not just
// the built-in Web-MIDI provider — so a native/desktop MIDI
// adapter registered with the domain is selectable too.
const sources = window.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);
};
})();