408 lines
12 KiB
React
408 lines
12 KiB
React
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 (
|
|
<div style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
background: T.bg,
|
|
overflow: "hidden",
|
|
}}>
|
|
{/* ── Header ── */}
|
|
<div style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "6px 12px",
|
|
background: T.panel,
|
|
borderBottom: `1px solid ${T.border}`,
|
|
flexShrink: 0,
|
|
}}>
|
|
<div style={{
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
letterSpacing: ".12em",
|
|
textTransform: "uppercase",
|
|
color: isInTune ? T.green : T.amber,
|
|
}}>
|
|
{isInTune ? "● In Tune" : "◌ Tuner"}
|
|
</div>
|
|
<button
|
|
onClick={onExit}
|
|
style={{
|
|
padding: "6px 14px",
|
|
borderRadius: 6,
|
|
background: T.surface,
|
|
border: `1px solid ${T.border}`,
|
|
color: T.textPrimary,
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
✕ Exit Tuner
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Main tuner area ── */}
|
|
<div style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
padding: "8px 16px",
|
|
}}>
|
|
{/* Note name — very large */}
|
|
<div style={{
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
fontSize: 72,
|
|
fontWeight: 700,
|
|
color: noteColor,
|
|
textShadow: isInTune
|
|
? `0 0 30px ${T.green}44`
|
|
: isClose
|
|
? `0 0 20px ${T.amber}33`
|
|
: "none",
|
|
lineHeight: 1,
|
|
letterSpacing: ".02em",
|
|
transition: "color .08s, text-shadow .08s",
|
|
minHeight: 76,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}>
|
|
{data.note}
|
|
</div>
|
|
|
|
{/* Cent deviation — needle display */}
|
|
<div style={{
|
|
width: "100%",
|
|
maxWidth: 320,
|
|
height: 60,
|
|
position: "relative",
|
|
marginTop: 4,
|
|
}}>
|
|
{/* Cent tick marks */}
|
|
<div style={{
|
|
position: "absolute",
|
|
bottom: 16,
|
|
left: 0,
|
|
right: 0,
|
|
height: 16,
|
|
display: "flex",
|
|
alignItems: "flex-end",
|
|
justifyContent: "space-between",
|
|
padding: "0 10px",
|
|
}}>
|
|
{centSegments.map((c, i) => {
|
|
const isMid = c === 0;
|
|
const isMajor = c % 10 === 0;
|
|
return (
|
|
<div key={i} style={{
|
|
width: isMid ? 3 : (isMajor ? 2 : 1),
|
|
height: isMid ? 16 : (isMajor ? 10 : 5),
|
|
background: isMid ? T.amber : T.border,
|
|
borderRadius: 1,
|
|
flexShrink: 0,
|
|
}} />
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Cent labels */}
|
|
<div style={{
|
|
position: "absolute",
|
|
bottom: 2,
|
|
left: 0,
|
|
right: 0,
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
padding: "0 6px",
|
|
fontSize: 7,
|
|
color: T.textDim,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
}}>
|
|
<span>-50¢</span>
|
|
<span>-25¢</span>
|
|
<span>0¢</span>
|
|
<span>+25¢</span>
|
|
<span>+50¢</span>
|
|
</div>
|
|
|
|
{/* Needle */}
|
|
<div style={{
|
|
position: "absolute",
|
|
bottom: 16,
|
|
left: `calc(10px + ${needlePct} * (100% - 20px))`,
|
|
transform: "translateX(-50%)",
|
|
transition: "left .04s ease-out",
|
|
}}>
|
|
{/* Needle line */}
|
|
<div style={{
|
|
width: 2,
|
|
height: 44,
|
|
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
|
|
borderRadius: 1,
|
|
margin: "0 auto",
|
|
boxShadow: isInTune
|
|
? `0 0 8px ${T.green}`
|
|
: `0 0 4px ${isClose ? T.amber : "transparent"}`,
|
|
}} />
|
|
{/* Needle dot */}
|
|
<div style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
|
|
margin: "-1px auto 0",
|
|
boxShadow: isInTune
|
|
? `0 0 12px ${T.green}`
|
|
: `0 0 6px ${isClose ? T.amber : "transparent"}`,
|
|
}} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Cent readout ── */}
|
|
<div style={{
|
|
fontSize: 13,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
color: noteColor,
|
|
letterSpacing: ".04em",
|
|
minHeight: 18,
|
|
}}>
|
|
{cents > 0 ? `+${cents}¢` : `${cents}¢`}
|
|
{isInTune && <span style={{color: T.green}}> ✓</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── String indicator bar ── */}
|
|
<div style={{
|
|
display: "flex",
|
|
gap: 6,
|
|
padding: "8px 16px 12px",
|
|
justifyContent: "center",
|
|
borderTop: `1px solid ${T.border}`,
|
|
flexShrink: 0,
|
|
}}>
|
|
{STRING_LABELS.map(s => {
|
|
const isActive = s.num === stringMatch;
|
|
return (
|
|
<div
|
|
key={s.num}
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 8,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: isActive ? `${s.color}22` : T.surface,
|
|
border: `2px solid ${isActive ? s.color : T.border}`,
|
|
transition: "all .1s",
|
|
boxShadow: isActive ? `0 0 12px ${s.color}44` : "none",
|
|
}}
|
|
>
|
|
<span style={{
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
fontSize: 16,
|
|
fontWeight: 700,
|
|
color: isActive ? s.color : T.textDim,
|
|
lineHeight: 1,
|
|
}}>
|
|
{s.name}
|
|
</span>
|
|
<span style={{
|
|
fontSize: 8,
|
|
color: isActive ? s.color : T.textDim,
|
|
opacity: 0.7,
|
|
lineHeight: 1,
|
|
}}>
|
|
{s.num}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ── Confidence bar ── */}
|
|
<div style={{
|
|
padding: "0 16px 8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
flexShrink: 0,
|
|
}}>
|
|
<span style={{ fontSize: 8, color: T.textDim, fontFamily: "'JetBrains Mono', monospace" }}>
|
|
SIG
|
|
</span>
|
|
<div style={{
|
|
flex: 1,
|
|
height: 3,
|
|
borderRadius: 2,
|
|
background: T.border,
|
|
overflow: "hidden",
|
|
}}>
|
|
<div style={{
|
|
height: "100%",
|
|
width: `${Math.max(0, Math.min(100, confidence * 100))}%`,
|
|
background: confidence > 0.7 ? T.green : (confidence > 0.3 ? T.amber : T.red),
|
|
borderRadius: 2,
|
|
transition: "width .08s",
|
|
}} />
|
|
</div>
|
|
<span style={{
|
|
fontSize: 8,
|
|
color: T.textDim,
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
minWidth: 20,
|
|
textAlign: "right",
|
|
}}>
|
|
{Math.round(confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|