/* * fee[dB]ack v0.3.0 — topbar Tuner + Instrument Selector cards. * * Implements the Google Stitch "Tuner and Instrument Selector Components" * (project 2627687099825089475): charcoal rounded cards — a tuner display * (vertical heat-gradient meter + active-green segment + big italic note + * Hz) and an instrument selector (guitar icon + chevron dropdown), scaled to * the header row. * * Behaviour: * - Clicking the tuner card opens the SAME tuner as the plugin's floating * "Tuner" button (window.tuner.toggle() / #tuner-toggle-btn). * - The instrument selector persists instrument/strings/tuning/reference in * /api/settings, emits `instrument:changed`, AND pushes the selection into * the tuner plugin (POST /api/plugins/tuner/config + window._tunerReloadConfig) * so the tuner auto-switches its tuning. */ (function () { 'use strict'; const sm = window.feedBack; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] }; const PATHWAY_OPTIONS = [ { id: 'songs', label: 'Songs' }, { id: 'practice', label: 'Practice' }, { id: 'learn', label: 'Learn' }, { id: 'studio', label: 'Studio' }, ]; // Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from // GET /api/tunings. Falls back to empty arrays until the fetch resolves. let _tuningsByKey = {}; // Last instrument-coverage report for the current song (from the tuner plugin) — // drives a passive "different tuning" cue on the tuner badge. null = covered / // unknown / off the player. let _lastCoverageReport = null; // Monotonic token so a slow coverage fetch can't restore a stale cue after a newer // song started loading / we left the player. Bumped on every refresh and clear. let _coverageCueToken = 0; function _tuningsForKey(key) { return Object.keys(_tuningsByKey[key] || {}); } function _tuningsForInstrument(instrument, string_count) { return _tuningsForKey(instrument + '-' + string_count); } // Lowest-string note per tuning name, for the tuner card's note readout. // Populated from /api/tunings frequencies: low string = index 0. let TUNING_NOTE = {}; // Chromatic scale (C-based). Index 0 = lowest string relative to E2 (82.41 Hz). // The lowest open string of guitar-6 Standard is E2; offset 0 maps to 'E'. const NOTE_NAMES = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B']; function _freqToNote(hz) { if (!hz || !Number.isFinite(hz) || hz <= 0) return 'E'; const midi = Math.round(69 + 12 * Math.log2(hz / 440)); return NOTE_NAMES[((midi % 12) + 12) % 12]; } // Resolve a custom offset-array tuning to a readable low-string note name. // Offsets are semitones from E Standard (index 0 = lowest string). function lowStringNote(off) { const semis = Array.isArray(off) && Number.isFinite(off[0]) ? off[0] : 0; return NOTE_NAMES[(((4 + semis) % 12) + 12) % 12]; } // Display label for the active tuning: a named tuning as-is, or 'Custom' // for an offset-array tuning (which has no canonical name). function tuningLabel() { return typeof settings.tuning === 'string' ? settings.tuning : 'Custom'; } // The SELECTED instrument's live working tuning (host `workingTuning` capability). // Feature-detected: returns null when the host doesn't expose it, so the card // quietly falls back to the profile tuning. `offsets` are named via the shared // displayTuningName resolver; "home" = still in the profile/default tuning. function workingTuningInfo() { var wt = window.feedBack && window.feedBack.workingTuning; var st = wt && typeof wt.get === 'function' ? wt.get() : null; if (!st) return null; var offsets = Array.isArray(st.offsets) ? st.offsets : null; var nameFor = window.displayTuningName || (window.feedBack && window.feedBack.displayTuningName); // Resolve BOTH the home tuning and the working tuning through the SAME namer so // "home?" is a like-for-like comparison — comparing a raw settings string ('Custom' // / 'E Standard') against a from-offsets name would mislabel a real home tuning. var homeLabel = (typeof nameFor === 'function') ? (nameFor(typeof settings.tuning === 'string' ? settings.tuning : null, Array.isArray(settings.tuning) ? settings.tuning : null) || tuningLabel()) : tuningLabel(); var label = (offsets && typeof nameFor === 'function') ? nameFor(null, offsets) : homeLabel; return { label: label, short: label.replace(/ Standard\b/, ' Std').replace(/Custom Tuning/, 'Custom'), // Home = no explicit working offsets, OR the working tuning names the same as // the profile's home tuning (both via `nameFor`, so the compare is consistent). isHome: !offsets || label === homeLabel, provenance: st.provenance === 'verified' ? 'verified' : 'assumed', }; } // Honesty glyph: a hollow diamond for an assumed tuning, a filled one for a // per-string mic-verified tuning (see the workingTuning provenance flag). function provenanceGlyph(p) { return p === 'verified' ? '◆' : '◇'; } // Tell the host which instrument is now selected, so workingTuning.get() // surfaces THIS instrument's own remembered tuning. No-op without the capability. function setWorkingInstrument(inst, sc) { var wt = window.feedBack && window.feedBack.workingTuning; if (wt && typeof wt.setCurrentInstrument === 'function') { try { wt.setCurrentInstrument(inst, sc); } catch (_) { /* noop */ } } } let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; async function loadTunings() { try { const r = await fetch('/api/tunings'); if (!r.ok) return; const data = await r.json(); _tuningsByKey = data.tunings || {}; // Build TUNING_NOTE from the lowest string of each tuning. Prefer the // exact integer midis the server now sends (tuningMidis, #829) — the // frequency path reconstructs the note via log2 against a hardcoded // 440 and can land a semitone off at non-440 reference pitches. // Frequencies remain the fallback for older cached responses. const midisByKey = data.tuningMidis || {}; TUNING_NOTE = {}; for (const key of Object.keys(_tuningsByKey)) { for (const [name, freqs] of Object.entries(_tuningsByKey[key])) { if (name in TUNING_NOTE) continue; const midis = midisByKey[key] && midisByKey[key][name]; if (Array.isArray(midis) && midis.length > 0 && Number.isFinite(midis[0])) { TUNING_NOTE[name] = NOTE_NAMES[((midis[0] % 12) + 12) % 12]; } else if (Array.isArray(freqs) && freqs.length > 0) { TUNING_NOTE[name] = _freqToNote(freqs[0]); } } } } catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ } } function pathwayForProfile(profiles, profileId, fallback) { const p = profiles && profiles[profileId]; return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs'); } function profileIdForInstrument(inst) { return inst === 'bass' ? 'bass' : 'guitar-lead'; } async function loadSettings() { try { const r = await fetch('/api/settings'); if (r.ok) { const s = await r.json(); // Clamp persisted values to valid ranges — config.json could // hold out-of-range data (hand-edited or from an import), and the // badge/tuner must render consistent state, not a bad number. const instrument = s.instrument === 'bass' ? 'bass' : 'guitar'; const counts = STRING_COUNTS[instrument]; const sc = Number(s.string_count); const scValid = counts.includes(sc) ? sc : counts[0]; const tunings = _tuningsForInstrument(instrument, scValid); let ref = Number(s.reference_pitch); if (!Number.isFinite(ref)) ref = 440; // tuning: a known named tuning is used as-is; a custom // offset-array tuning (see /api/settings) is PRESERVED rather // than discarded — the named-tuning badge can't label it yet // (tracked for P23), and pushToTuner()/renderTuner() guard the // non-string case — anything else falls back to the default. let tuning; if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard'); else if (Array.isArray(s.tuning)) tuning = s.tuning; else tuning = tunings[0] || 'Standard'; const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {}; const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs'; settings = { instrument: instrument, string_count: scValid, tuning: tuning, reference_pitch: Math.min(450, Math.max(430, ref)), pathway: pathway, instrument_profiles: profiles, active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument), }; } } catch (e) { /* settings endpoint always present */ } } function syncLocalProfilePatch(patch) { const profileId = profileIdForInstrument(patch.instrument || settings.instrument); if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {}; if (patch.instrument) settings.active_instrument_profile = profileId; const profile = Object.assign({}, settings.instrument_profiles[profileId] || {}); let changed = false; if (patch.instrument) { profile.instrument = patch.instrument; changed = true; } if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; } if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; } if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; } if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; } if (changed) settings.instrument_profiles[profileId] = profile; } async function saveSettings(patch) { // Only adopt the patch once the server accepts it. /api/settings returns // {error: ...} with HTTP 200 on a validation failure, so a rejected // patch must NOT mutate local state, emit instrument:changed, or push to // the tuner — otherwise the UI/tuner desync from the persisted config. let accepted = false; try { const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (r.ok) { const body = await r.json().catch(() => ({})); accepted = !(body && body.error); } } catch (e) { /* non-fatal — leave settings unchanged */ } if (!accepted) return false; Object.assign(settings, patch); syncLocalProfilePatch(patch); if (sm && sm.emit) sm.emit('instrument:changed', { instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway, }); pushToTuner(); renderTuner(); // reflect new tuning on the tuner card return true; } // Drive the tuner plugin's instrument + tuning from the selection. async function pushToTuner() { try { const lastInstrument = settings.instrument + '-' + settings.string_count; // e.g. guitar-6, bass-4 // The tuner plugin keys its config by tuning NAME. A custom // offset-array tuning has no name, so sync only the instrument and // skip lastTuning rather than POST an array the plugin can't parse. const body = { lastInstrument }; if (typeof settings.tuning === 'string') { body.lastTuning = settings.tuning; } await fetch('/api/plugins/tuner/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (typeof window._tunerReloadConfig === 'function') await window._tunerReloadConfig(); } catch (e) { /* tuner plugin may be absent */ } } function openTuner() { // Same action as the plugin's floating "Tuner" button. if (window.tuner && typeof window.tuner.toggle === 'function') { window.tuner.toggle(); return; } const btn = document.getElementById('tuner-toggle-btn'); if (btn) btn.click(); // else: tuner plugin not installed — no-op. } // ── Live tuner badge helpers ──────────────────────────────────────────-- let _lastFrame = null; // 11-bar heat gradient: index 0 = very flat (dark red), 5 = center (green), 10 = very sharp (dark red). // Each step = 5 cents; range ±25 cents. const _SEG_BASE = [ 'bg-red-900', // 0 –25 cents 'bg-red-700', // 1 –20 cents 'bg-orange-600', // 2 –15 cents 'bg-yellow-600', // 3 –10 cents 'bg-emerald-700', // 4 –5 cents 'bg-emerald-700', // 5 0 cents (center) 'bg-emerald-700', // 6 +5 cents 'bg-yellow-600', // 7 +10 cents 'bg-orange-600', // 8 +15 cents 'bg-red-700', // 9 +20 cents 'bg-red-900', // 10 +25 cents ]; function _segActiveClass(i) { if (i === 5) return 'bg-emerald-400 shadow-[0_0_10px_3px_rgba(52,211,153,0.95)]'; if (i === 4 || i === 6) return 'bg-emerald-500 shadow-[0_0_8px_2px_rgba(52,211,153,0.85)]'; if (i === 3 || i === 7) return 'bg-yellow-400 shadow-[0_0_8px_2px_rgba(234,179,8,0.9)]'; if (i === 2 || i === 8) return 'bg-orange-500 shadow-[0_0_6px_2px_rgba(249,115,22,0.85)]'; if (i === 1 || i === 9) return 'bg-red-500 shadow-[0_0_6px_2px_rgba(239,68,68,0.8)]'; return 'bg-red-700 shadow-[0_0_4px_1px_rgba(185,28,28,0.7)]'; // 0 or 10 } // Returns true when the tuning name implies flat notation (mirrors tuning-utils.js). function _preferFlats(tuningName) { return typeof tuningName === 'string' && /\b[A-G]b\b/.test(tuningName); } // Compute nearest chromatic note + cents deviation from raw freq (always free-tune). function _freeTuneCents(freq, useFlats, referencePitch) { if (!freq || freq <= 0) return { note: '—', cents: 0 }; const ref = (referencePitch > 0 && isFinite(referencePitch)) ? referencePitch : 440; const midi = 69 + 12 * Math.log2(freq / ref); const rounded = Math.round(midi); const cents = Math.round((midi - rounded) * 100); const sharps = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; const flats = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B']; return { note: (useFlats ? flats : sharps)[((rounded % 12) + 12) % 12], cents }; } function _applyFrame(frame) { const host = document.getElementById('v3-badge-tuner'); if (!host) return; const noteEl = host.querySelector('[data-tuner-note]'); const hzEl = host.querySelector('[data-tuner-hz]'); const segs = host.querySelectorAll('[data-tuner-seg]'); if (!segs.length) return; // skeleton not yet rendered if (!frame || !frame.hasSignal) { const fallbackNote = typeof settings.tuning === 'string' ? (TUNING_NOTE[settings.tuning] || 'E') : lowStringNote(settings.tuning); if (noteEl) noteEl.textContent = fallbackNote; if (hzEl) hzEl.textContent = Math.round(settings.reference_pitch || 440) + 'hz'; segs.forEach((s, i) => { s.className = 'w-5 h-[3px] rounded-full ' + _SEG_BASE[i]; }); return; } // Always free-tune: derive note + cents from raw freq, ignoring tuning target. const { freq } = frame; const { note, cents } = _freeTuneCents(freq, _preferFlats(settings.tuning), settings.reference_pitch); if (noteEl) noteEl.textContent = note; if (hzEl) hzEl.textContent = Math.round(freq) + 'hz'; // cents ±25 maps to indices 0–10; centre (0¢) = index 5, sharp (+) = top (0), flat (–) = bottom (10). const activeIdx = Math.max(0, Math.min(10, 5 - Math.round(cents / 5))); segs.forEach((s, i) => { s.className = 'w-5 h-[3px] rounded-full ' + (i === activeIdx ? _segActiveClass(i) : _SEG_BASE[i]); }); } // ── Tuner card (Stitch LeftTunerComponent) ────────────────────────────-- function renderTuner() { const host = document.getElementById('v3-badge-tuner'); if (!host) return; const hz = Math.round(settings.reference_pitch || 440); const initNote = typeof settings.tuning === 'string' ? (TUNING_NOTE[settings.tuning] || 'E') : lowStringNote(settings.tuning); const seg = (i) => '
'; const meter = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(seg).join(''); host.innerHTML = '