Files
pi-multifx-pedal-ui/src/Tuner.jsx
T

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>
);
}