// Guitar/Bass Tuner Plugin for FeedBack (function() { 'use strict'; const _TUNER_STORAGE_KEY = 'feedBack_tuner_settings'; // ── Player sync state ───────────────────────────────────────────── let _onScreenChanged = null; let _onSongReady = null; let _outsideClickClose = null; // ── Auto-open on tuning change (in-memory only) ─────────────────── let _lastTuningKey = null; let _lastAutoOpenSessionKey = null; let _autoOpenDismissedSessionKey = null; let _autoOpenGeneration = 0; // Bumped on every enable()/disable() so an in-flight open (which awaits audio start // with the panel already visible) can detect it was dismissed mid-open and NOT flip // _state.enabled on afterwards — avoiding a zombie enabled-but-hidden tuner. let _openGen = 0; let _onAutoOpenSongLoading = null; let _onAutoOpenSongReady = null; // Autoplay gate (E2): when the feature is on, hold playback on song:loading and // release it once we know we won't open (covered / unchanged) or the tuner is // dismissed — so playback waits behind a genuinely-needed retune. let _autoplayRelease = null; let _gateClaimed = false; // ── Shared mutable state (read/written by screen.js; UI reads via closure) ── const _state = { uiContainer: null, vizContainer: null, instrumentSelect: null, tuningSelect: null, stringNoteContainer: null, saveAsCustomContainer: null, activeViz: null, selectedInstrument: 'guitar-6', selectedTuning: null, selectedTuningName: 'Standard', manualTargetFreq: null, tunings: {}, _allTunings: {}, referencePitch: 440, visualizationMode: 'default', currentSongOffsets: null, currentSongIsBass: false, currentSongStringCount: 0, _serverConfig: null, useFlats: false, enabled: false, _instrumentSentinel: null, selectedDeviceId: '', selectedChannel: 'mono', audioInputMode: 'auto', freeTune: false, freeTuneToggle: null, }; let _tunerUIApi = null; // ── Script loader ───────────────────────────────────────────────── const _loadedScripts = new Set(); function _loadScript(url) { if (_loadedScripts.has(url)) return Promise.resolve(); return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; s.onload = () => { _loadedScripts.add(url); resolve(); }; s.onerror = () => reject(new Error(`Tuner: failed to load "${url}"`)); document.head.appendChild(s); }); } function _loadVizScript(name) { return _loadScript(`/api/plugins/tuner/visualization/${name}.js`); } async function _setVisualization(name) { if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; } try { await _loadVizScript(name); const factory = window[`_tunerViz_${name}`]; if (typeof factory !== 'function') throw new Error(`Tuner: _tunerViz_${name} not defined`); _state.activeViz = factory(_state.vizContainer); } catch (e) { console.error(e); if (name !== 'default') { _state.visualizationMode = 'default'; await _setVisualization('default'); } } } // ── Tuning helpers ──────────────────────────────────────────────── function _instrumentForTuning(name) { for (var key in _state._allTunings) { if (_state._allTunings[key] && _state._allTunings[key][name]) return key; } return 'guitar-6'; } function _buildTuningsForInstrument(instrument) { return { ...(_state._allTunings[instrument] || {}) }; } function _tuningIdentityKey(songInfo) { if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null; const ctx = (typeof window.feedBack?.songTuningContext === 'function') ? window.feedBack.songTuningContext(songInfo) : { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name, }; const isBass = (typeof window.feedBack?.isBassArrangement === 'function') ? window.feedBack.isBassArrangement(ctx) : (songInfo.arrangement || '').toLowerCase().includes('bass'); const sc = (typeof window.feedBack?.effectiveStringCount === 'function') ? window.feedBack.effectiveStringCount(songInfo.tuning, ctx) : (songInfo.stringCount || songInfo.tuning.length); if (!sc || sc <= 0) return null; const offsets = songInfo.tuning.slice(0, sc); if (!offsets.length) return null; return (isBass ? 'b' : 'g') + ':' + sc + ':' + offsets.join(','); } function _autoOpenSessionKey(songInfo) { if (!songInfo) return ''; const cur = window.feedBack?.currentSong; const filename = (cur && cur.filename) || songInfo.filename || songInfo.title || 'unknown'; const arr = (cur && cur.arrangementIndex != null) ? cur.arrangementIndex : (songInfo.arrangement_index != null ? songInfo.arrangement_index : (songInfo.arrangement || '')); return filename + '::' + arr; } // ── §4 instrument-coverage ──────────────────────────────────────────────── // FeedBack is tune-to-song: the highway draws tab in the SONG's tuning, so the // player tunes their instrument to match. We therefore only auto-open when the // player's CURRENT physical tuning doesn't already cover the song — i.e. the // song's open-string tuning isn't an exact contiguous run inside the player's // strings, OR the global reference differs. So an 8-string F# player isn't // nagged for a 6-/7-string standard song (its top strings already match), but a // Drop-A song whose dropped open string the player lacks still prompts. function _openMidisFromFreqs(freqs) { const u = window._tunerUtils; if (!u || !Array.isArray(freqs)) return null; return freqs.map((f) => Math.round(u.freqToMidi(f))); } // The song's open-string MIDI (at A440; centOffset handled separately as a // global). Mirrors _tuningIdentityKey's isBass / string-count derivation. function _songOpenMidis(songInfo) { const u = window._tunerUtils; if (!u || !songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null; const ctx = (typeof window.feedBack?.songTuningContext === 'function') ? window.feedBack.songTuningContext(songInfo) : { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name }; const isBass = (typeof window.feedBack?.isBassArrangement === 'function') ? window.feedBack.isBassArrangement(ctx) : (songInfo.arrangement || '').toLowerCase().includes('bass'); const sc = (typeof window.feedBack?.effectiveStringCount === 'function') ? window.feedBack.effectiveStringCount(songInfo.tuning, ctx) : (songInfo.stringCount || songInfo.tuning.length); if (!sc || sc <= 0) return null; const offsets = songInfo.tuning.slice(0, sc); if (!offsets.length) return null; return _openMidisFromFreqs(u.offsetsToFreqs(offsets, isBass)); } // The player's CURRENT physical tuning. Prefer the host-owned live working // tuning (window.feedBack.workingTuning) — what the instrument is *actually* in // right now, which advances as the player retunes — so coverage prompts fire in // BOTH directions (E→C# and back), not only away from a fixed profile. Feature- // detected: on a host without the working-tuning capability, or before it's been // set, we fall back to the static /api/settings instrument tuning (today's // behavior). Returns { midis, refCents } or null. // The SELECTED physical instrument's working-tuning slot identity, from /api/settings. // The read (_playerTuning) and the write (_publishWorkingTuning) MUST agree on this // key — the working tuning is a property of the player's selected instrument, not of // any one song — or a published tuning lands in a slot coverage never reads back. // Returns { isBass, sc, key }. function _selectedInstrument(s) { const isBass = !!(s && s.instrument === 'bass'); const sc = (s && Number(s.string_count)) || (isBass ? 4 : 6); return { isBass, sc, key: (isBass ? 'bass' : 'guitar') + '-' + sc }; } // 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 // tuning, from /api/settings. let s = null; try { s = await fetch('/api/settings').then((r) => (r && r.ok ? r.json() : null)); } catch (_) { s = null; } const { isBass, sc, key } = _selectedInstrument(s); // Cache the resolved selection so the (synchronous) publish-on-clear writes to // the EXACT slot this read path uses — no second /api/settings fetch that could // race an instrument switch. Only cache when settings were actually read: on a // fetch failure we keep the last confident selection (or none → publish skips) // rather than recording a bogus default-instrument slot to publish into later. // Coverage runs before any auto-open, so this is set by the time a clear can publish. if (s) { _state._playerSelected = { isBass, sc, key, refPitch: Number(s.reference_pitch) || 440 }; } // The LIVE per-instrument working tuning (advances as the player retunes) — // this is what makes coverage prompt in BOTH directions, not only away from a // fixed profile. Feature-detected; falls back to the static settings tuning // when unset / no host capability. const wt = (window.feedBack && window.feedBack.workingTuning && typeof window.feedBack.workingTuning.get === 'function') ? window.feedBack.workingTuning.get(key) : null; const wtHasOffsets = !!(wt && Array.isArray(wt.offsets) && wt.offsets.length); let freqs = null; if (wtHasOffsets) { freqs = u.offsetsToFreqs(wt.offsets.slice(0, sc), isBass); } else if (s && Array.isArray(s.tuning)) { freqs = u.offsetsToFreqs(s.tuning.slice(0, sc), isBass); } else if (s && typeof s.tuning === 'string') { const named = _state._allTunings && _state._allTunings[key]; if (named && Array.isArray(named[s.tuning])) freqs = named[s.tuning]; } if (!freqs) { // No confident instrument identity — settings absent, OR present but carrying // no instrument/string_count/tuning (a fresh profile: /api/settings omits them) // — and no live working tuning. We can't tell the player's tuning, so fail // toward prompting (null → not-covered) rather than silently assuming standard // and suppressing a genuinely-needed prompt. const hasIdentity = !!(s && (s.instrument || s.string_count || s.tuning)); if (!hasIdentity && !wtHasOffsets) return null; freqs = u.offsetsToFreqs(new Array(sc).fill(0), isBass); // standard fallback } const midis = _openMidisFromFreqs(freqs); if (!midis) return null; const refPitch = (wt && Number(wt.referencePitch)) || (s && Number(s.reference_pitch)) || 440; return { midis, refCents: 1200 * Math.log2(refPitch / 440) }; } // PR: host workingTuning — when the player clears an AUTO-OPENED tuner we assume // they tuned their (selected) instrument to the song, so publish the song's tuning // as the live working tuning for that instrument ('assumed' — PR 4's explicit // "I tuned / Skip" will replace this heuristic). After the instrument->chart routing // PR the loaded arrangement matches the selected instrument, so the song's tuning IS // the player's instrument's new tuning. // // We write to the SELECTED instrument's slot (the exact key _playerTuning reads), // NOT a song-derived one, so the publish can't be stranded in a slot coverage never // looks at. If the cleared song is a chart for the OTHER instrument (a manual switch // to e.g. the bass part while guitar is selected), we skip — that isn't evidence the // selected instrument was retuned, and writing it would pollute the wrong slot. // // Synchronous, off the selection _playerTuning last resolved (`_state._playerSelected`) // — an auto-open always runs a coverage check first, so it's populated by the time a // clear can publish. Reusing it (rather than re-fetching /api/settings here) keeps the // write key identical to the read key and avoids racing an instrument switch. function _publishWorkingTuning(songInfo) { const wt = window.feedBack && window.feedBack.workingTuning; if (!wt || typeof wt.set !== 'function') return; if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return; const sel = _state._playerSelected; if (!sel || !sel.key || !(sel.sc > 0)) return; // coverage hasn't resolved the instrument yet const ctx = (typeof window.feedBack?.songTuningContext === 'function') ? window.feedBack.songTuningContext(songInfo) : { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name }; const songIsBass = (typeof window.feedBack?.isBassArrangement === 'function') ? window.feedBack.isBassArrangement(ctx) : (songInfo.arrangement || '').toLowerCase().includes('bass'); if (songIsBass !== sel.isBass) return; // cross-instrument chart — don't pollute the selected slot wt.set({ offsets: songInfo.tuning.slice(0, sel.sc), stringCount: sel.sc, instrument: sel.isBass ? 'bass' : 'guitar', referencePitch: sel.refPitch, source: 'tuner', }, { instrument: sel.key, provenance: 'assumed' }); } // The retune the player would need to match this song, as a structured report: // { covered, retune: [{ from, to }], reference, cantCover } // — covered: the physical tuning already matches (the song's open strings are an // exact contiguous run inside the player's strings) → no retune; // — retune: the per-string note changes (e.g. { from:'B', to:'A' }) of the best // contiguous alignment — what the badge cue names; // — reference: a whole-instrument A4/centOffset mismatch (A440 vs A432, octave); // — cantCover: the song needs more strings than the instrument has. // Conservative: any missing data → { covered:false } so a needed prompt/cue is // never silently dropped on a fetch hiccup. async function _computeCoverageReport(songInfo) { const u = window._tunerUtils; const none = { covered: false, retune: [], reference: false, cantCover: false }; if (!u) return none; const song = _songOpenMidis(songInfo); if (!song || !song.length) return none; const player = await _playerTuning(); if (!player || !player.midis.length) return none; const reference = Math.abs((Number(songInfo?.centOffset) || 0) - player.refCents) > 25; if (player.midis.length < song.length) return { covered: false, retune: [], reference, cantCover: true }; // Best contiguous alignment = the run with the fewest per-string mismatches // (extended-range adds strings at the ends — match by pitch, not index). let best = null; for (let start = 0; start + song.length <= player.midis.length; start++) { const diffs = []; for (let i = 0; i < song.length; i++) { const pm = player.midis[start + i]; if (pm !== song[i]) diffs.push({ from: u.midiToNote(pm, false), to: u.midiToNote(song[i], false) }); } if (!best || diffs.length < best.length) best = diffs; if (!diffs.length) break; } const covered = !reference && best.length === 0; return { covered, retune: covered ? [] : best, reference, cantCover: false }; } // Dedup the coverage computation (which fetches /api/settings): the auto-open gate // AND the badge cue both call this on the same song:ready. Cache the in-flight/last // result per song so they share ONE fetch. Invalidated when anything that changes the // answer happens — a new song (song:loading), an instrument switch (instrument:changed), // or a retune (working-tuning-changed) — so the cache can never go stale within a song. let _coverageCache = null; // { key, promise } function _coverageReport(songInfo) { const key = _autoOpenSessionKey(songInfo) + '|' + (songInfo && Array.isArray(songInfo.tuning) ? songInfo.tuning.join(',') : '') + '|' + (songInfo && songInfo.centOffset != null ? songInfo.centOffset : ''); // coverage uses centOffset if (_coverageCache && _coverageCache.key === key) return _coverageCache.promise; const promise = _computeCoverageReport(songInfo); _coverageCache = { key, promise }; return promise; } function _invalidateCoverageCache() { _coverageCache = null; } // Boolean form used to gate the auto-open prompt. async function _coveredByPlayerInstrument(songInfo) { return (await _coverageReport(songInfo)).covered; } function _releaseGate() { if (_autoplayRelease) { try { _autoplayRelease(); } catch (_) { /* */ } _autoplayRelease = null; } } function _onAutoOpenSongLoadingHandler() { _autoOpenGeneration++; _autoOpenDismissedSessionKey = null; _lastAutoOpenSessionKey = null; _invalidateCoverageCache(); // Claim the autoplay gate NOW (synchronously, before song:ready) when the // feature is on, so playback can wait behind a needed retune. Released on // song:ready if we don't open, or when the tuner is dismissed. _releaseGate(); _gateClaimed = false; _autoplayRelease = (_state._serverConfig && _state._serverConfig.autoOpenOnTuningChange && window.feedBack && typeof window.feedBack.holdAutoplay === 'function') ? window.feedBack.holdAutoplay() : null; } async function _maybeAutoOpenOnTuningChange() { if (!document.getElementById('player')?.classList.contains('active')) return; // Opt-in (default off): only auto-open when the user enabled it in the // tuner settings. Ensure config is loaded so the first song:ready after // boot still reads the real flag; fail closed if it can't load. if (!_state._serverConfig) { try { await loadConfig(); } catch (_) { /* */ } } if (!_state._serverConfig || !_state._serverConfig.autoOpenOnTuningChange) return; const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong; if (!songInfo) return; const tuningKey = _tuningIdentityKey(songInfo); if (!tuningKey) return; const sessionKey = _autoOpenSessionKey(songInfo); const myGen = _autoOpenGeneration; if (_lastTuningKey === null) { _lastTuningKey = tuningKey; return; } if (tuningKey === _lastTuningKey) return; _lastTuningKey = tuningKey; if (_autoOpenDismissedSessionKey === sessionKey) return; if (_state.enabled) return; if (_lastAutoOpenSessionKey === sessionKey) return; if (!window.tuner || typeof window.tuner.enable !== 'function') return; // §4: skip the prompt when the player's physical instrument already covers // this song's tuning (e.g. an 8-string F# playing a 6-/7-string standard // song). Async (fetches /api/settings) — re-check the generation after. const covered = await _coveredByPlayerInstrument(songInfo); if (myGen !== _autoOpenGeneration) return; if (covered) return; _lastAutoOpenSessionKey = sessionKey; try { await window.tuner.enable({ auto: true }); if (myGen !== _autoOpenGeneration) return; _gateClaimed = true; // tuner is open → keep the autoplay gate until it's dismissed // The hold is now intentional and user-dismissable — cancel the fail-open // backstop so it can't start playback while the player is still tuning. if (_autoplayRelease && typeof _autoplayRelease.settle === 'function') _autoplayRelease.settle(); } catch (e) { console.warn('Tuner: auto-open failed:', e && e.message ? e.message : e); if (_lastAutoOpenSessionKey === sessionKey) _lastAutoOpenSessionKey = null; // NOTE: _lastTuningKey stays committed here. Rolling it back to retry // a failed enable on the same tuning would defeat the duplicate- // song:ready dedup this gate also enforces; a transient enable failure // (e.g. mic denied) is therefore not auto-retried until the tuning // changes. A proper retry needs a separate flag, deferred. } } function _installAutoOpenListeners() { if (_onAutoOpenSongLoading || !window.feedBack?.on) return; _onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler; _onAutoOpenSongReady = async () => { const myGen = _autoOpenGeneration; await _maybeAutoOpenOnTuningChange(); // A newer song:loading may have superseded us while awaiting — it owns the // gate/_gateClaimed now, so don't release its hold based on our stale view. if (myGen !== _autoOpenGeneration) return; if (!_gateClaimed) _releaseGate(); // not gating this song → let it play }; window.feedBack.on('song:loading', _onAutoOpenSongLoading); window.feedBack.on('song:ready', _onAutoOpenSongReady); // The badge (static/v3/badges.js) emits this on the feedBack bus when the player // 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; _invalidatePlayerTuning(); _invalidateCoverageCache(); }); // A retune (working tuning published on a tuner clear) changes coverage for the // current song — drop the cached player tuning + report so a re-evaluation recomputes. window.feedBack.on('working-tuning-changed', () => { _invalidatePlayerTuning(); _invalidateCoverageCache(); }); } // ── Player sync helpers ─────────────────────────────────────────── function _syncCurrentTuning() { const songInfo = window.highway?.getSongInfo(); const onPlayer = document.getElementById('player')?.classList.contains('active'); const wantCurrent = _state.selectedTuningName === '_current' || (onPlayer && songInfo?.tuning?.length); if (songInfo?.tuning?.length && wantCurrent) { _state.selectedTuningName = '_current'; const ctx = (typeof window.feedBack?.songTuningContext === 'function') ? window.feedBack.songTuningContext(songInfo) : { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name, }; const isBass = (typeof window.feedBack?.isBassArrangement === 'function') ? window.feedBack.isBassArrangement(ctx) : (songInfo.arrangement || '').toLowerCase().includes('bass'); const sc = (typeof window.feedBack?.effectiveStringCount === 'function') ? window.feedBack.effectiveStringCount(songInfo.tuning, ctx) : (songInfo.stringCount || songInfo.tuning.length); // A tuning change invalidates an in-flight mic-verify — its captured targets // and offsets are now stale, so it must not complete against the old tuning. if (_verify && String(songInfo.tuning.slice(0, sc)) !== String(_verify.offsets)) { verifyCancel(); _tunerUIApi?.resetVerify?.(); } _state.currentSongOffsets = songInfo.tuning.slice(0, sc); _state.currentSongIsBass = isBass; _state.currentSongStringCount = sc; const _refScale = _state.referencePitch / 440; _state.selectedTuning = window._tunerUtils.offsetsToFreqs(_state.currentSongOffsets, isBass).map(f => f * _refScale); const songInstrument = isBass ? ('bass-' + (sc === 5 ? 5 : 4)) : (sc === 8 ? 'guitar-8' : sc === 7 ? 'guitar-7' : 'guitar-6'); if (songInstrument !== _state.selectedInstrument) { _state.selectedInstrument = songInstrument; _state.tunings = _buildTuningsForInstrument(_state.selectedInstrument); _tunerUIApi?.updateInstrumentDisplay(); } if (_state.tuningSelect) _state.tuningSelect.value = '_current'; } else { const first = Object.keys(_state.tunings)[0]; if (first) { _state.selectedTuningName = first; _state.selectedTuning = _state.tunings[first]; if (_state.tuningSelect) _state.tuningSelect.value = first; const derivedInstrument = _instrumentForTuning(first); if (derivedInstrument && derivedInstrument !== _state.selectedInstrument) { _state.selectedInstrument = derivedInstrument; if (_state.instrumentSelect) { _state.instrumentSelect.value = derivedInstrument; _tunerUIApi?.updateInstrumentDisplay(); } } } } _tunerUIApi?.renderStringNotes(); _tunerUIApi?.updateSaveAsCustomVisibility(); } // ── Persistence ─────────────────────────────────────────────────── function loadSettings() { try { const s = JSON.parse(localStorage.getItem(_TUNER_STORAGE_KEY) || '{}'); if (s.deviceId !== undefined) _state.selectedDeviceId = s.deviceId; if (['mono', 'left', 'right'].includes(s.channel)) _state.selectedChannel = s.channel; } catch (e) { /* unavailable */ } } function saveSettings() { try { localStorage.setItem(_TUNER_STORAGE_KEY, JSON.stringify({ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, })); } catch (e) { /* unavailable */ } } async function loadConfig() { try { const [config, tuningsData] = await Promise.all([ fetch('/api/plugins/tuner/config').then(r => r.json()), fetch('/api/tunings').then(r => r.json()), ]); _state._serverConfig = config; _state._allTunings = tuningsData.tunings || {}; _state.referencePitch = tuningsData.referencePitch || 440; _state.visualizationMode = config.visualizationMode || 'default'; _state.audioInputMode = config.audioInputMode || 'auto'; if (config.lastInstrument && _state._allTunings[config.lastInstrument]) { _state.selectedInstrument = config.lastInstrument; } if (_state.instrumentSelect) { _state.instrumentSelect.value = _state.selectedInstrument; _tunerUIApi?.updateInstrumentDisplay(); } _state.tunings = _buildTuningsForInstrument(_state.selectedInstrument); const lastName = config.lastTuning; // Legacy saves stored 'free-tune' as lastTuning; treat that as // freeTune=true with no specific named tuning. const legacyFreeTune = lastName === 'free-tune'; if (!legacyFreeTune && lastName && _state.tunings[lastName]) { _state.selectedTuningName = lastName; _state.selectedTuning = _state.tunings[lastName]; } else { const first = Object.keys(_state.tunings)[0]; if (first) { _state.selectedTuningName = first; _state.selectedTuning = _state.tunings[first]; } } _state.freeTune = legacyFreeTune || !!config.freeTune; _state.useFlats = window._tunerUtils ? window._tunerUtils.preferFlats(_state.selectedTuningName) : /\b[A-G]b\b/.test(_state.selectedTuningName || ''); if (_state.tuningSelect) _tunerUIApi?.renderTuningOptions(); if (_state.uiContainer && !_state.uiContainer.classList.contains('hidden')) _tunerUIApi?.renderStringNotes(); _tunerUIApi?.updateSaveAsCustomVisibility(); _tunerUIApi?.updateFreeTuneUI(); _tunerUIApi?.updateFloatingButtonVisibility(); } catch (e) { console.error('Tuner: Failed to load config', e); } } window._tunerReloadConfig = loadConfig; async function saveConfig() { // '_current' is the live song tuning; 'free-tune' is now tracked via the // freeTune boolean — neither should land in lastTuning. const tuningToSave = (_state.selectedTuningName === '_current' || _state.selectedTuningName === 'free-tune') ? null : _state.selectedTuningName; try { await fetch('/api/plugins/tuner/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lastTuning: tuningToSave, lastInstrument: _state.selectedInstrument, visualizationMode: _state.visualizationMode, freeTune: _state.freeTune, }), }); } catch (e) { console.error('Tuner: Failed to save config', e); } } // ── Audio lifecycle ─────────────────────────────────────────────── async function restartAudio() { _state.uiContainer?.querySelector('.tuner-mic-error')?.remove(); try { await window._tunerAudio.restart({ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode }); } catch (e) { console.error('Tuner: Failed to restart audio', e); disable(); _tunerUIApi?.showMicError(e); } } async function enable(opts) { if (_state.enabled) return; const myOpen = ++_openGen; // this open's token; a disable()/newer open invalidates it // An AUTO-open (the "this song needs a different tuning" nudge) must // PERSIST: it is NOT dismissed by the autoplay song:play that follows // song entry, a stray click, or a same-screen re-emit — only by the // Skip/× buttons or leaving the song. A manual open keeps the classic // click-away / play-to-close behaviour. const auto = !!(opts && opts.auto); _state.autoOpened = auto; await _loadScript('/api/plugins/tuner/utils/tuning-utils.js'); await _loadScript('/api/plugins/tuner/utils/audio.js'); await _loadScript('/api/plugins/tuner/utils/ui.js'); loadSettings(); await loadConfig(); if (document.querySelector('.screen.active')?.id === 'player') _state.selectedTuningName = '_current'; if (!_tunerUIApi) { _tunerUIApi = window._tunerUI(_state, { saveConfig, loadConfig, saveSettings, disable, restartAudio, setVisualization: _setVisualization, buildTuningsForInstrument: _buildTuningsForInstrument, }); } _tunerUIApi.initUI(); _tunerUIApi.renderInstrumentOptions(); _tunerUIApi.renderTuningOptions(); if (_state.selectedTuningName === '_current') _syncCurrentTuning(); else if (_state.selectedTuning) _tunerUIApi.renderStringNotes(); _tunerUIApi.updateSaveAsCustomVisibility(); await _setVisualization(_state.visualizationMode); _state.uiContainer.classList.remove('hidden'); _state.uiContainer.classList.add('flex'); _tunerUIApi.positionPanel(); _tunerUIApi.updateFreeTuneUI(); // "Skip" is the auto-open nudge's explicit dismiss; hidden for a manual // open (the × / click-away already close those). if (_state.skipBtn) _state.skipBtn.classList.toggle('hidden', !auto); // Auto-open shows the "Back to library" escape hatch and hides the ×: // the Skip / Back buttons + Esc are the auto-open's dismiss surface, so a // gated retune always offers a way forward AND a way out. if (_state.backBtn) _state.backBtn.classList.toggle('hidden', !auto); if (_state.closeBtn) _state.closeBtn.classList.toggle('hidden', !!auto); // Close when clicking outside the panel. Deferred so the badge's opening // click doesn't bubble up to the document and fire immediately. Skipped // for an auto-open: the user never clicked to open it, so their first // unrelated click must not dismiss it (it persists until Skip / Back to // library / Esc). if (!auto) { if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose); _outsideClickClose = () => { if (_state.enabled) disable(); }; setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0); } if (window.feedBack && !_onScreenChanged) { // Auto-opened: close only when we actually LEAVE the song — a player // re-emit while staying put must not tear down the nudge. Manual: // unchanged (any screen change closes it). _onScreenChanged = () => { if (!_state.autoOpened || !document.getElementById('player')?.classList.contains('active')) disable(); }; _onSongReady = () => { _tunerUIApi.renderTuningOptions(); if (_state.selectedTuningName === '_current') _syncCurrentTuning(); }; window.feedBack.on('screen:changed', _onScreenChanged); window.feedBack.on('song:ready', _onSongReady); } _state.uiContainer?.querySelector('.tuner-mic-error')?.remove(); try { // start() calls _doStop() internally, so this cleanly replaces any // existing auto-start session and registers the full UI callback. await window._tunerAudio.start( { deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode }, _tunerUIApi.updateUI ); // The panel is visible (with ×/Skip) across the audio-start await above, so a // dismiss can land here. If so, disable() already tore the panel down and // bumped _openGen — do NOT flip enabled on (that would leave enabled-but-hidden). if (myOpen !== _openGen) return; _state.enabled = true; if (window.tuner?.updateButtons) window.tuner.updateButtons(); } catch (e) { console.error('Tuner: Failed to start audio', e); disable(); _tunerUIApi?.showMicError(e); } } function disable() { _openGen++; // invalidate any in-flight enable() so it won't re-enable after this teardown const wasEnabled = _state.enabled; const wasAutoOpened = _state.autoOpened; const onPlayer = document.getElementById('player')?.classList.contains('active'); _state.enabled = false; _state.autoOpened = false; _releaseGate(); // dismissing a gated auto-open releases playback (it starts now) _state.manualTargetFreq = null; verifyCancel(); // a running mic-verify ends when the panel closes _tunerUIApi?.resetVerify?.(); if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; } if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; } if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); } if (_onScreenChanged) { window.feedBack?.off('screen:changed', _onScreenChanged); _onScreenChanged = null; } if (_onSongReady) { window.feedBack?.off('song:ready', _onSongReady); _onSongReady = null; } if (window._tunerAudio) window._tunerAudio.stop(); if (_state.vizContainer) _state.vizContainer.innerHTML = ''; if (window.tuner?.updateButtons) window.tuner.updateButtons(); // Resume background audio so the live badge keeps updating after the panel closes. if (window._tunerAudio && _tunerUIApi) { window._tunerAudio.start( { deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode }, _tunerUIApi.updateUI ).catch(e => console.warn('Tuner: badge audio resume failed:', e && e.message ? e.message : e)); } if (wasEnabled && onPlayer) { const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong; if (songInfo) { _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo); // Clearing an auto-opened tuner = the player tuned to this song: // publish the song's tuning as their instrument's live working tuning // so coverage stops nagging for it (and prompts on the way back). But // if a mic-verify already wrote 'verified' this session, a plain // 'assumed' publish would immediately clobber it — leave it verified. if (wasAutoOpened && !_verifiedPublished) _publishWorkingTuning(songInfo); } } _verifiedPublished = false; // consumed — fresh for the next tuner session } window.tuner = { enable, disable, toggle: () => _state.enabled ? disable() : enable(), updateButtons: () => { _tunerUIApi?.updateFloatingButton(); _tunerUIApi?.updatePlayerButton(); _tunerUIApi?.updateFloatingButtonVisibility(); }, }; // Boot: load scripts, add toggle button, then auto-start audio for the live badge Promise.all([ _loadScript('/api/plugins/tuner/utils/tuning-utils.js'), _loadScript('/api/plugins/tuner/utils/audio.js'), _loadScript('/api/plugins/tuner/utils/ui.js'), ]).then(async () => { _tunerUIApi = window._tunerUI(_state, { saveConfig, loadConfig, saveSettings, disable, restartAudio, setVisualization: _setVisualization, buildTuningsForInstrument: _buildTuningsForInstrument, }); _tunerUIApi.addButton(); loadSettings(); await loadConfig(); // Auto-start audio so the v3 badge receives live tuner:frame events from // page load, without requiring the user to open the tuner panel first. // Errors are silent — a permission prompt or missing device is non-fatal // here; the user will see the mic error modal if they explicitly open the // tuner via enable(). try { await window._tunerAudio.start( { deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode }, _tunerUIApi.updateUI ); } catch (e) { console.warn('Tuner: auto-start audio failed (badge will be static):', e && e.message ? e.message : e); } _installAutoOpenListeners(); }).catch(e => console.error(e)); _installAutoOpenListeners(); // ── Mic-verify (working-tuning PR 9b) ────────────────────────────────────── // A choreographed per-string check that promotes the current working tuning // from 'assumed' to 'verified' — the ONLY thing that may ever claim 'verified' // (audio-engine's honesty rule). The player plays each string; once every one // reads in-tune (±VERIFY_TOL_CENTS) and holds for VERIFY_STABLE frames we stamp // provenance:'verified' + verifiedStrings. Cancels on tuner close / tuning change. const VERIFY_TOL_CENTS = 6; const VERIFY_STABLE = 8; let _verify = null; let _verifiedPublished = false; // set when a mic-verify wrote 'verified' this session function verifyState() { if (!_verify) return null; return { complete: _verify.complete, done: _verify.targets.map((t) => t.done), remaining: _verify.targets.filter((t) => !t.done).length, }; } // Start a verify session for `targets` (freqs; defaults to the selected tuning). // Captures the tuning's OFFSETS now (explicit arg, else the current song's) so that // when it completes we stamp 'verified' onto the exact tuning that was confirmed. // Per-string semitone offsets (from standard) of the tuning we're verifying, derived // from its target freqs. This is authoritative for BOTH the song ('_current') tuning // and a manually-selected tuning — unlike _state.currentSongOffsets, which for a manual // tuning is a stale/different song's tuning that must NOT be stamped 'verified'. The // player's reference pitch scales both the targets and the standard, so it cancels. function _tuningOffsetsFromFreqs(freqs) { const u = window._tunerUtils; if (!u || typeof u.offsetsToFreqs !== 'function' || typeof u.freqToMidi !== 'function') return null; const isBass = /^bass/.test(_state.selectedInstrument || ''); // the selected instrument, not the song's const refScale = (Number(_state.referencePitch) || 440) / 440; const std = u.offsetsToFreqs(new Array(freqs.length).fill(0), isBass); if (!Array.isArray(std) || std.length !== freqs.length) return null; const out = []; for (let i = 0; i < freqs.length; i++) { const f = Number(freqs[i]); const s = Number(std[i]) * refScale; if (!(f > 0) || !(s > 0)) return null; out.push(Math.round(u.freqToMidi(f) - u.freqToMidi(s)) || 0); // normalize -0 → 0 } return out; } function verifyStart(targets, offsets) { const freqs = (Array.isArray(targets) && targets.length) ? targets : _state.selectedTuning; if (!Array.isArray(freqs) || !freqs.length) return null; // Explicit offsets win (callers/tests that already have them); otherwise derive // them from the tuning actually being verified. const offs = (Array.isArray(offsets) && offsets.length) ? offsets.slice() : _tuningOffsetsFromFreqs(freqs); _verify = { targets: freqs.map((f) => ({ freq: f, streak: 0, done: false })), complete: false, offsets: offs }; _verifiedPublished = false; return verifyState(); } function verifyCancel() { _verify = null; } // Feed one processed frame (its matched target freq + cents-off). Requires // CONSECUTIVE in-tune frames per string: the one confirmed string advances, and // every other not-yet-done string's streak resets — so a run can't accumulate across // silence / wrong-string / out-of-tune frames. Completes + stamps 'verified' when all pass. function verifyFeed(targetFreq, cents) { if (!_verify || _verify.complete) return verifyState(); let hit = null; if (targetFreq != null) { const t = _verify.targets.find((x) => Math.abs(x.freq - targetFreq) < 0.5); if (t && !t.done && isFinite(cents) && Math.abs(cents) <= VERIFY_TOL_CENTS) hit = t; } for (const t of _verify.targets) { if (t.done) continue; if (t === hit) { if (++t.streak >= VERIFY_STABLE) t.done = true; } else t.streak = 0; } if (_verify.targets.every((x) => x.done)) { _verify.complete = true; _publishVerified(); } return verifyState(); } // Promote the working tuning to 'verified'. Write the CONFIRMED tuning's offsets // atomically with provenance + verifiedStrings (into the selected instrument's slot), // so 'verified' can never attach to stale offsets the slot happened to hold. function _publishVerified() { const wt = window.feedBack && window.feedBack.workingTuning; if (!wt || typeof wt.set !== 'function') return; const offsets = (_verify && Array.isArray(_verify.offsets)) ? _verify.offsets.slice() : null; if (!offsets || !offsets.length) return; // nothing concrete to claim verified const sel = _state._playerSelected; const next = { offsets: offsets, stringCount: offsets.length, verifiedStrings: offsets.map(() => true) }; if (sel && sel.key) { next.instrument = sel.isBass ? 'bass' : 'guitar'; next.referencePitch = sel.refPitch; } const opts = (sel && sel.key) ? { instrument: sel.key, provenance: 'verified' } : { provenance: 'verified' }; try { wt.set(next, opts); _verifiedPublished = true; } catch (_) { /* noop */ } } window._tunerAutoOpen = { tuningIdentityKey: _tuningIdentityKey, sessionKey: _autoOpenSessionKey, maybeAutoOpenOnTuningChange: _maybeAutoOpenOnTuningChange, coveredByPlayerInstrument: _coveredByPlayerInstrument, coverageReport: _coverageReport, playerTuning: _playerTuning, publishWorkingTuning: _publishWorkingTuning, verifyStart: verifyStart, verifyFeed: verifyFeed, verifyCancel: verifyCancel, verifyState: verifyState, onSongLoading: _onAutoOpenSongLoadingHandler, getState() { return { lastTuningKey: _lastTuningKey, lastAutoOpenSessionKey: _lastAutoOpenSessionKey, autoOpenDismissedSessionKey: _autoOpenDismissedSessionKey, autoOpenGeneration: _autoOpenGeneration, enabled: _state.enabled, }; }, resetState() { _lastTuningKey = null; _lastAutoOpenSessionKey = null; _autoOpenDismissedSessionKey = null; _autoOpenGeneration = 0; }, setEnabledForTests(value) { _state.enabled = !!value; }, }; console.log('Tuner plugin loaded. Use window.tuner.toggle() to open.'); })();