From db0b55a301b6bb9bb9dcd44597105f2c8c538e61 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 29 Jun 2026 00:22:52 +0200 Subject: [PATCH] fix(audio-input): return bound device from open-source + live input-meter gate in input-setup A3: openInputSource() surfaces the provider's bound device on the command return (payload.bound) for the trusted caller, kept out of the redacted event/diagnostics. B: input-setup guitar/bass step gains a Test-input live meter gating Continue on heard signal, reading note_detect getInputLevel() (same stream the scorer binds). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + plugins/input_setup/screen.js | 129 ++++++++++++++++++++++++--- static/capabilities/audio-session.js | 12 ++- tests/js/audio_session_input.test.js | 29 ++++++ 4 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed349aa..4951ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Input-setup wizard's guitar/bass step now has a live "we can hear you" check, so it can't be passed against a silent/wrong device.** The MIDI step already gated Continue on a live note; the audio step could be completed with zero proof any audio was arriving — which is exactly how a wrong-mic pick (see the desktop binding fix) sailed through onboarding. The guitar/bass panel (`plugins/input_setup/screen.js` `renderAudioPanel`) gains a **Test input** button that opens the *selected* device and shows a live input meter, with a **Continue** button that stays disabled until real signal is heard (two consecutive ticks above an idle-noise threshold → "✓ We can hear you"). **Calibrate** stays always-enabled alongside it — it's the tool that fixes a too-low input, so it must never be gated. The meter reads `window.noteDetect.getInputLevel()` — the *same* level the scorer sees off the bound capture stream (not a second, independently-opened stream), so it can't green-light a device detection isn't actually hearing. Changing the device while testing re-commits the selection and cycles the capture so the meter always reflects the shown device, never a stale one; the gate resets on device change, the panel restores the prior detection on/off state on exit, and hands the capture off cleanly to the Calibration Wizard. Degrades to the prior calibrate-only flow when the detector doesn't expose `getInputLevel`/enable/disable. Pairs with feedback-plugin-notedetect's new `getInputLevel()` getter and the desktop wrong-mic binding fix. Runtime verification needs a desktop build with a real interface (the web testbed has no enumerated audio-input sources). - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. - **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`. - **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption). @@ -40,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers). ### Fixed +- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller (the input-setup confirmation gate, note_detect), enabling an honest "Now listening to: " readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's switch to a stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot). - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. - **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring). - **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song//meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field. diff --git a/plugins/input_setup/screen.js b/plugins/input_setup/screen.js index 718801a..4dee4e4 100644 --- a/plugins/input_setup/screen.js +++ b/plugins/input_setup/screen.js @@ -126,32 +126,116 @@ return renderMidiPanel(inst); } - // Guitar/bass: show the audio source (audio-input) and launch the - // note_detect Calibration Wizard for the deep work. + // Guitar/bass: show the audio source (audio-input), a live "we can + // hear you" meter, then launch the note_detect Calibration Wizard for + // the deep work. async function renderAudioPanel(inst) { const { sources, selected } = await _audioSources(); const opts2 = sources.map((s) => '').join(''); const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function'); + // The live-meter gate needs the detector to expose its input level + // (getInputLevel) and the enable/disable capture lifecycle. When any + // is missing (older detector / none) we degrade to the prior + // calibrate-only flow rather than show a meter we can't feed. + const nd = window.noteDetect; + const canMeter = !!(hasDetector && nd + && typeof nd.getInputLevel === 'function' + && typeof nd.enable === 'function' + && typeof nd.disable === 'function' + && typeof nd.isEnabled === 'function' + && sources.length); const body = - '

Pick your audio input, then run the calibration to set levels, channel and latency.

' + + '

Pick your audio input, then ' + + (canMeter ? 'play a note so we can confirm we hear it — then calibrate or continue.' : 'run the calibration to set levels, channel and latency.') + '

' + (sources.length ? '' + '' : '

No audio input detected yet — plug in your interface, or skip and set this up later.

') + + (canMeter + ? '
' + + '
' + : '') + (hasDetector ? '' : '

The note detector isn’t loaded here — you can calibrate later from the player.

'); - const foot = - ''; + // canMeter: a gated "Continue" (enabled once we hear you) PLUS an + // always-enabled "Calibrate" — never gate Calibrate, since it's the + // tool that fixes a too-low input that produces no signal. + const foot = canMeter + ? '' + + '' + : ''; 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 (!capabilities || !key) return Promise.resolve(); + // Awaitable: the meter must reopen on the *committed* selection, + // so callers can sequence enable() after the choice persists. + return capabilities.command('audio-input', 'select-source', { requester: 'input_setup', payload: { logicalSourceKey: key } }).catch(() => {}); }; + + // ── live "we can hear you" meter ──────────────────────────────── + const meterWrap = host.querySelector('[data-is-meter-wrap]'); + const meterBar = host.querySelector('[data-is-meter]'); + const testMsg = host.querySelector('[data-is-test-msg]'); + const contBtn = host.querySelector('[data-is-cont]'); + // Idle hiss sits well under this; a plucked string clears it easily. + const SIGNAL_THRESHOLD = 0.04; + let pollTimer = null; + let testing = false; + let weEnabled = false; // did WE turn detection on (so we restore it)? + let handingOff = false; // Calibrate owns the capture from here on + + function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } + function resetGate() { + if (contBtn) contBtn.disabled = true; + _markDone(inst, false); + if (meterBar) meterBar.style.width = '0%'; + if (testMsg) testMsg.textContent = 'Play your ' + String(INSTRUMENTS[inst].label).toLowerCase() + ' — waiting for signal…'; + } + function startPoll() { + stopPoll(); + let heard = 0; + pollTimer = setInterval(() => { + let lvl = 0; + try { lvl = (nd.getInputLevel() || {}).level || 0; } catch (_) { lvl = 0; } + if (meterBar) meterBar.style.width = Math.min(100, Math.round(lvl * 140)) + '%'; + if (lvl >= SIGNAL_THRESHOLD) { + // Require two consecutive ticks so a single click/pop + // doesn't false-pass the gate. + if (++heard >= 2 && contBtn && contBtn.disabled) { + if (testMsg) testMsg.innerHTML = '✓ We can hear you — your input is working.'; + contBtn.disabled = false; + _markDone(inst, true); + } + } else { heard = 0; } + }, 100); + } + async function startTest() { + stopPoll(); + resetGate(); + if (meterWrap) meterWrap.classList.remove('hidden'); + // Persist the shown choice, THEN (re)open it: enable() is a no-op + // when detection is already on, so a device change while testing + // must cycle disable→enable or the meter would read the stale + // device — false confidence, the exact bug this gate prevents. + await commitAudio(sel.value); + try { + if (nd.isEnabled()) { await nd.disable(); await nd.enable(); weEnabled = false; } + else { await nd.enable(); weEnabled = true; } + } catch (_) { /* fail-soft: meter just won't move; Calibrate still works */ } + testing = true; + startPoll(); + } + if (sel) { - sel.addEventListener('change', () => commitAudio(sel.value)); + sel.addEventListener('change', async () => { + if (testing) { await startTest(); } + else { await commitAudio(sel.value); resetGate(); } + }); // The