import { useState, useEffect, useRef, useCallback } from "react"; // ── Design tokens (mirrored) ────────────────────────────────── const T = { bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", }; // ── String labels matching _STRING_NAMES ────────────────────── const STRING_LABELS = [ { num: 6, name: "E", freq: 82.41, color: "#C84040" }, // Low E { num: 5, name: "A", freq: 110.0, color: "#D07030" }, // A { num: 4, name: "D", freq: 146.83, color: "#E8A030" }, // D { num: 3, name: "G", freq: 196.0, color: "#60A0E0" }, // G { num: 2, name: "B", freq: 246.94, color: "#3A7BA8" }, // B { num: 1, name: "e", freq: 329.63, color: "#50C080" }, // High e ]; // ── Note names for reference ───────────────────────────────── const NOTE_FLAT = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"]; // ── Mock tuner data generator (when pedal offline) ─────────── const MOCK_NOTES = [ { note: "E2", freq: 82.41 }, { note: "F2", freq: 87.31 }, { note: "G2", freq: 98.0 }, { note: "A2", freq: 110.0 }, { note: "B2", freq: 123.47 }, { note: "C3", freq: 130.81 }, { note: "D3", freq: 146.83 }, { note: "E3", freq: 164.81 }, { note: "F3", freq: 174.61 }, { note: "G3", freq: 196.0 }, { note: "A3", freq: 220.0 }, { note: "B3", freq: 246.94 }, { note: "C4", freq: 261.63 }, { note: "D4", freq: 293.66 }, { note: "E4", freq: 329.63 }, ]; function getMockTunerData() { // Simulate a slowly wandering note const t = Date.now() / 1000; const idx = Math.floor((t * 0.3) % MOCK_NOTES.length); const n = MOCK_NOTES[idx]; const wobble = Math.sin(t * 2.5) * 15; // cent wobble const confidence = 0.6 + Math.sin(t * 0.7) * 0.25; // Guess string let string = -1; for (let si = 0; si < STRING_LABELS.length; si++) { if (Math.abs(n.freq - STRING_LABELS[si].freq) / STRING_LABELS[si].freq < 0.2) { string = STRING_LABELS[si].num; break; } } return { note: n.note, cents: Math.round(wobble), string, confidence: Math.max(0, Math.min(1, confidence)), frequency: n.freq + wobble / 10, }; } // ── Tuner Screen ────────────────────────────────────────────── export default function TunerScreen({ tunerData, connected, onExit }) { const [data, setData] = useState(() => ({ note: "--", cents: 0, string: -1, confidence: 0, frequency: 0, })); // Fast update from external data or mock useEffect(() => { if (connected && tunerData) { setData({ note: tunerData.tuner_note || "--", cents: tunerData.tuner_cents ?? 0, string: tunerData.tuner_string ?? -1, confidence: tunerData.tuner_confidence ?? 0, frequency: tunerData.tuner_frequency ?? 0, }); } }, [connected, tunerData]); // Mock animation when offline or no data useEffect(() => { if (connected && tunerData?.tuner_enabled) return; const id = setInterval(() => { setData(getMockTunerData()); }, 200); return () => clearInterval(id); }, [connected, tunerData]); // Fast polling for tuner pitch data when enabled useEffect(() => { if (!connected) return; if (!tunerData?.tuner_enabled) return; let cancelled = false; const poll = async () => { try { const res = await fetch("/api/tuner/pitch"); if (!cancelled && res.ok) { const d = await res.json(); setData({ note: d.note || "--", cents: d.cents ?? 0, string: d.string ?? -1, confidence: d.confidence ?? 0, frequency: d.frequency ?? 0, }); } } catch {} }; poll(); // Initial fetch const id = setInterval(poll, 80); // ~12.5 Hz for smooth needle return () => { cancelled = true; clearInterval(id); }; }, [connected, tunerData?.tuner_enabled]); // ── Calculate needle angle from cents ── const cents = data.cents; const confidence = data.confidence; // Needle angle: -50 to +50 degrees for -50 to +50 cents const needleAngle = Math.max(-50, Math.min(50, cents)); const needlePct = (needleAngle + 50) / 100; // 0.0 to 1.0 const isInTune = Math.abs(cents) <= 3; const isClose = Math.abs(cents) <= 10; // ── String indicators ── const stringMatch = data.string; // ── Cent display segments ── const centSegments = []; for (let i = -50; i <= 50; i += 5) { centSegments.push(i); } // Determine note color (green = in tune, amber = close, red = far) const noteColor = isInTune ? T.green : isClose ? T.amber : T.textPrimary; return (
{/* ── Header ── */}
{isInTune ? "● In Tune" : "◌ Tuner"}
{/* ── Main tuner area ── */}
{/* Note name — very large */}
{data.note}
{/* Cent deviation — needle display */}
{/* Cent tick marks */}
{centSegments.map((c, i) => { const isMid = c === 0; const isMajor = c % 10 === 0; return (
); })}
{/* Cent labels */}
-50¢ -25¢ +25¢ +50¢
{/* Needle */}
{/* Needle line */}
{/* Needle dot */}
{/* ── Cent readout ── */}
{cents > 0 ? `+${cents}¢` : `${cents}¢`} {isInTune && }
{/* ── String indicator bar ── */}
{STRING_LABELS.map(s => { const isActive = s.num === stringMatch; return (
{s.name} {s.num}
); })}
{/* ── Confidence bar ── */}
SIG
0.7 ? T.green : (confidence > 0.3 ? T.amber : T.red), borderRadius: 2, transition: "width .08s", }} />
{Math.round(confidence * 100)}%
); }