feedBack/plugins/tuner/screen.js
OmikronApex 5e78f2f7f7
fix(tuner): remove unused settings + fix sidebar panel position (#661)
Removes the Floating Button and Tuning Visibility settings sections and finishes retiring their still-live config: drops the disabledTunings menu filter and showFloatingButton gate from screen.js/ui.js and their persistence in routes.py (retired keys are stripped on write). Repositions the tuner panel opened from the v3 sidebar Plugins popover to anchor beside it via the host's stable plugin-control slot API (falling back to the popover id), clamped to the viewport so it can't open off-screen, and re-anchored on resize. Updates tuner config tests to the retired-key behavior; plugins/tuner 1.3.2 -> 1.3.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:35:18 +02:00

923 lines
49 KiB
JavaScript
Raw Permalink 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.

// 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.');
})();