mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 05:41:53 +00:00
Working-tuning follow-ups: - Mic-verify used _state.currentSongOffsets for the 'verified' stamp, but for a MANUALLY-selected tuning (tuner opened off a song) that's a stale/different song's tuning — so verify could mark the WRONG tuning verified. It now derives the verified offsets from the tuning actually being checked (its target freqs; the player's reference pitch cancels in the ratio), so 'verified' always attaches to the tuning the player confirmed. Explicit offsets still win. - Adds docs/working-tuning-on-device-tests.md: the checklist for the parts that can't be covered headlessly — the auto-open/gate flow, both-directions prompts, mic-verify detection, and the tuner-mic-vs-note_detect ASIO/exclusive-mode contention flagged in the design charrette. Test: mic-verify with no explicit offsets / no song context derives the correct offsets (Drop-D). 55 tuner tests green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
933 lines
49 KiB
JavaScript
933 lines
49 KiB
JavaScript
// 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',
|
||
showFloatingButton: true,
|
||
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 _isTuningEnabled(instrument, name) {
|
||
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
|
||
}
|
||
|
||
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) {
|
||
const all = _state._allTunings[instrument] || {};
|
||
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
|
||
return Object.fromEntries(
|
||
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
|
||
);
|
||
}
|
||
|
||
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.showFloatingButton = config.showFloatingButton !== false;
|
||
_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.');
|
||
})();
|