diff --git a/plugins/tuner/screen.js b/plugins/tuner/screen.js index ea30801..0845550 100644 --- a/plugins/tuner/screen.js +++ b/plugins/tuner/screen.js @@ -189,7 +189,32 @@ return { isBass, sc, key: (isBass ? 'bass' : 'guitar') + '-' + sc }; } - async function _playerTuning() { + // Memoize the player's tuning: it depends only on /api/settings + the working + // tuning, which change on instrument:changed / working-tuning-changed (NOT per song). + // Without this, a consumer that evaluates coverage for many items at once — the + // library's per-song tuning-match chips — would fire one /api/settings fetch PER item + // per paint. Cache the promise so they all share one read; invalidate on the events + // that change the answer, AND expire after a short TTL so a settings write that + // doesn't emit an event (e.g. the input-setup flow) still heals within seconds. + let _playerTuningPromise = null; + let _playerTuningAt = 0; + const _PLAYER_TUNING_TTL_MS = 3000; + function _playerTuning() { + const now = (typeof Date !== 'undefined' && Date.now) ? Date.now() : 0; + if (!_playerTuningPromise || (now - _playerTuningAt) > _PLAYER_TUNING_TTL_MS) { + _playerTuningAt = now; + const p = _computePlayerTuning(); + _playerTuningPromise = p; + // Never pin a transient failure: if the read yields null (or rejects), drop + // the cache so the next call retries. A real result stays until invalidation/TTL. + p.then((r) => { if (r == null && _playerTuningPromise === p) _playerTuningPromise = null; }, + () => { if (_playerTuningPromise === p) _playerTuningPromise = null; }); + } + return _playerTuningPromise; + } + function _invalidatePlayerTuning() { _playerTuningPromise = null; } + + async function _computePlayerTuning() { const u = window._tunerUtils; if (!u) return null; // Instrument IDENTITY (which instrument is selected) + the static fallback @@ -408,10 +433,12 @@ // switches instrument. Drop the cached selection so a publish-on-clear can't write // to the previously-selected instrument's slot; the next coverage read re-resolves // it. Until then _publishWorkingTuning skips (safe — no mis-slotted write). - window.feedBack.on('instrument:changed', () => { _state._playerSelected = null; _invalidateCoverageCache(); }); + window.feedBack.on('instrument:changed', () => { + _state._playerSelected = null; _invalidatePlayerTuning(); _invalidateCoverageCache(); + }); // A retune (working tuning published on a tuner clear) changes coverage for the - // current song — drop the cached report so a re-evaluation recomputes it. - window.feedBack.on('working-tuning-changed', _invalidateCoverageCache); + // current song — drop the cached player tuning + report so a re-evaluation recomputes. + window.feedBack.on('working-tuning-changed', () => { _invalidatePlayerTuning(); _invalidateCoverageCache(); }); } // ── Player sync helpers ─────────────────────────────────────────── diff --git a/static/v3/songs.js b/static/v3/songs.js index 5b64135..8d4ef58 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -604,6 +604,41 @@ '').join(''); } + // ── Tuning-match flags (working-tuning PR 6) ─────────────────────────────── + // Colour each song's tuning chip by whether your CURRENT working tuning covers + // it: green = play it now, amber = needs a retune. Uses the tuner plugin's + // coverage check (async) + the host workingTuning state — BOTH feature-detected, + // so without them the chips render exactly as before. Decoration runs AFTER the + // (sync) window paint so scrolling stays snappy; a token cancels a superseded pass. + let _tuningDecorToken = 0; + function _applyChipMatch(chip, stateName) { + chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400'); + chip.classList.add(stateName === 'match' ? 'bg-emerald-500' + : stateName === 'retune' ? 'bg-amber-400' : 'bg-fb-mid'); + if (!chip.dataset.baseTitle) chip.dataset.baseTitle = chip.getAttribute('title') || ''; + chip.setAttribute('title', chip.dataset.baseTitle + (stateName === 'match' + ? ' — matches your tuning' : stateName === 'retune' ? ' — needs a retune' : '')); + } + async function decorateTuningChips(grid) { + if (!grid) return; + const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport; + const hasWT = window.feedBack && window.feedBack.workingTuning + && typeof window.feedBack.workingTuning.get === 'function'; + if (typeof cov !== 'function' || !hasWT) return; // feature-detect → no flags + const token = ++_tuningDecorToken; + const chips = grid.querySelectorAll('[data-tuning-chip][data-tuning-offsets]'); + for (const chip of chips) { + const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number); + if (!offs.length || offs.some((n) => !isFinite(n))) continue; + // Pass the instrument so coverage uses the right base pitches (bass vs guitar). + const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead'; + let rep = null; + try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; } + if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change + if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune'); + } + } + function songCard(song) { const fav = song.favorite; const key = cardKey(song); @@ -626,11 +661,22 @@ ? ('Custom Tuning: ' + targetNotes) : tuningLabel; const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2'); + // Tag the chip with its offsets so decorateTuningChips() can colour it + // green (matches your current tuning) / amber (needs a retune) after paint. + // Also flag a bass-only song (every arrangement is a bass part) so coverage + // scores its bass tuning against the bass base pitches, not guitar — otherwise + // a 4-string bass tuning read as guitar can false-match a guitar player. + const chipArrs = song.arrangements || []; + const chipIsBass = chipArrs.length > 0 + && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || '')); + const matchAttr = (rawOffsets && rawOffsets.length) + ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"' + + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; if (targetNotes) { - tuning = '' + tuning = '' + esc('Custom Tuning') + '
' + esc(targetNotes) + '
'; } else { - tuning = '' + esc(tuningLabel) + ''; + tuning = '' + esc(tuningLabel) + ''; } } // Display-only (pointer-events-none) so a click falls through to the @@ -1032,6 +1078,7 @@ grid.style.top = (firstRow * rowH) + 'px'; grid.innerHTML = _renderCardsRange(start, end); wireCards(grid); + decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected) state.winRange = { start, end }; state.renderedSelectMode = state.selectMode; if (sm && typeof sm.emit === 'function') { @@ -1858,5 +1905,13 @@ if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); } else _libraryDirty = true; }); + // Your live tuning changed (retune / instrument swap / reset) → re-colour the + // visible tuning chips against the new tuning. Cheap: re-decorates in place, + // no re-fetch or re-paint. No-op off the Songs grid or without the capability. + sm.on('working-tuning-changed', () => { + if (typeof songsActive === 'function' && !songsActive()) return; + if (state.view !== 'grid') return; + decorateTuningChips(_gridEl()); + }); } })(); diff --git a/tests/js/tuner_auto_open.test.js b/tests/js/tuner_auto_open.test.js index 33cf9b0..d0ea0c8 100644 --- a/tests/js/tuner_auto_open.test.js +++ b/tests/js/tuner_auto_open.test.js @@ -591,9 +591,11 @@ test('a configured standard-guitar player still covers a standard song', async ( const covered = await sandbox.window._tunerAutoOpen.coveredByPlayerInstrument(E_STANDARD); assert.equal(covered, true, 'a known standard guitar covers a standard song (no regression)'); }); -// ── #657 fix (#680): coverage is deduped — the auto-open gate and the badge cue both -// call coverageReport() on the same song:ready; they must share ONE /api/settings fetch. -test('coverage reports for the same song share one settings fetch, and a new song refetches', async () => { +// ── #657 fix (#680) + #668 fix: coverage is deduped, and the player tuning is memoized +// across songs (it depends on the selected instrument, not the song). Many coverage +// calls — the auto-open gate, the badge cue, AND the library's per-song tuning-match +// chips — share ONE /api/settings fetch until the instrument / working tuning changes. +test('coverage reports share one /api/settings fetch across songs (player tuning memoized)', async () => { const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } }); let settingsFetches = 0; const origFetch = sandbox.window.fetch; @@ -605,8 +607,27 @@ test('coverage reports for the same song share one settings fetch, and a new son const [a, b] = await Promise.all([api.coverageReport(DROP_D), api.coverageReport(DROP_D)]); assert.equal(settingsFetches, 1, 'concurrent reports for the same song share one fetch'); assert.deepEqual(a, b); - // A new song invalidates the cache → a fresh fetch. + // A different song re-evaluates coverage but reuses the memoized player tuning — the + // player didn't retune or switch instruments, so no second /api/settings read. api.onSongLoading(); await api.coverageReport(E_STANDARD); - assert.equal(settingsFetches, 2, 'a new song refetches'); + assert.equal(settingsFetches, 1, 'a different song reuses the memoized player tuning — no refetch'); +}); + +// ── #668 fix: a transient /api/settings failure must NOT be pinned by the player-tuning +// memo — the next read retries (else one hiccup freezes coverage as "unknown" for good). +test('a transient /api/settings failure is not cached — the next coverage read retries', async () => { + const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } }); + let failNext = true; + const origFetch = sandbox.window.fetch; + sandbox.window.fetch = (url) => { + if (String(url).includes('/api/settings') && failNext) { failNext = false; return Promise.reject(new Error('boom')); } + return origFetch(url); + }; + const api = sandbox.window._tunerAutoOpen; + const r1 = await api.coverageReport(DROP_D); // settings read failed → conservative "none" report + assert.equal(r1.retune.length, 0, 'a fetch failure yields the empty/unknown report'); + api.onSongLoading(); // clear the coverage cache to force a recompute + const r2 = await api.coverageReport(DROP_D); // retry: settings now readable → a real report + assert.equal(r2.retune.length, 1, 'the retry actually computes coverage (Drop-D low string vs standard)'); });