拆分 EQ 页面

This commit is contained in:
eafonyang
2026-06-15 13:38:37 +08:00
parent 517ae96ab1
commit b620805e13
9 changed files with 1978 additions and 1828 deletions
@@ -0,0 +1,442 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { toast } from "sonner";
import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
import {
buildPeqSvgCurveData,
sampleCombinedPeqMagnitudeDb,
} from "@/lib/peqAudio";
import type { PeqEqUi } from "../constants";
import { eqInterp } from "../utils";
/* ── Legend indicator ── */
function LegendCurveIndicator({
loading,
color,
}: {
loading: boolean;
color: string;
}) {
if (loading) {
return (
<span
className="inline-block w-3 h-3 rounded-full border-2 animate-spin flex-shrink-0"
style={{ borderColor: `${color}40`, borderTopColor: color }}
aria-hidden="true"
/>
);
}
return <div className="w-3 h-[2px] rounded flex-shrink-0" style={{ background: color }} />;
}
/* ── Frequency Response Chart ── */
export function FreqChart({
bands,
rawCurve,
rawCurveLoading = false,
selectedBand,
abMode,
onAbToggle,
onCopyAndSwitchTo,
onApplyB,
onSaveB,
onBandDrag,
onBandSelect,
eqUi,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
rawCurve: number[] | null;
rawCurveLoading?: boolean;
selectedBand: number;
abMode: "A" | "B";
onAbToggle: (m: "A" | "B") => void;
onCopyAndSwitchTo: (to: "A" | "B") => void;
onApplyB: () => void;
onSaveB: () => void;
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
onBandSelect: (idx: number) => void;
eqUi: PeqEqUi;
}) {
/** 频响图 SVG 高度(viewBox 与 CSS 一致) */
const H = 400;
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const draggingBandRef = useRef<number | null>(null);
const curvePathRef = useRef<SVGPathElement | null>(null);
const rawPathRef = useRef<SVGPathElement | null>(null);
const equalizedPathRef = useRef<SVGPathElement | null>(null);
const [chartWidth, setChartWidth] = useState(340);
const W = chartWidth;
const [isBandDragging, setIsBandDragging] = useState(false);
useEffect(() => {
const node = chartContainerRef.current;
if (!node) return;
const updateWidth = () => {
const nextWidth = Math.round(node.clientWidth);
if (nextWidth > 0) setChartWidth(nextWidth);
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(node);
return () => observer.disconnect();
}, []);
const Y_DB_MAX = 20;
const freqToX = (f: number) => {
const logMin = Math.log10(20), logMax = Math.log10(20000);
return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W;
};
const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10);
const xToFreq = (x: number) => {
const logMin = Math.log10(20), logMax = Math.log10(20000);
const clampedX = Math.max(0, Math.min(W, x));
return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin));
};
const yToGain = (y: number) => {
const clampedY = Math.max(10, Math.min(H - 10, y));
return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX;
};
const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => {
const svg = svgRef.current;
if (!svg || W <= 0) return;
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const x = ((clientX - rect.left) / rect.width) * W;
const y = ((clientY - rect.top) / rect.height) * H;
const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x))));
const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1));
onBandDrag(idx, { freq, gain });
};
const handleBandPointerDown = (idx: number, e: React.PointerEvent<SVGGElement>) => {
e.preventDefault();
e.stopPropagation();
draggingBandRef.current = idx;
setIsBandDragging(true);
onBandSelect(idx);
updateBandFromPointer(idx, e.clientX, e.clientY);
};
const handleSvgPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
const idx = draggingBandRef.current;
if (idx === null) return;
e.preventDefault();
updateBandFromPointer(idx, e.clientX, e.clientY);
};
const stopDragging = () => {
draggingBandRef.current = null;
setIsBandDragging(false);
};
const curveData = useMemo(
() =>
buildPeqSvgCurveData({
bands,
width: W,
height: H,
fs: 48000,
yDbMax: Y_DB_MAX,
minFreq: 20,
maxFreq: 20000,
paddingY: 10,
}),
[bands, W, H],
);
const pathD = curveData.pathD;
const fillD = curveData.fillD;
/** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */
const combinedMagDbAtHandles = useMemo(
() => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)),
[bands],
);
const [curveVisibilityByMode, setCurveVisibilityByMode] = useState<
Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }>
>({
A: { eq: true, raw: true, equalized: true },
B: { eq: true, raw: false, equalized: false },
});
const showEq = curveVisibilityByMode[abMode].eq;
const showRaw = curveVisibilityByMode[abMode].raw;
const showEqualized = curveVisibilityByMode[abMode].equalized;
const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => {
setCurveVisibilityByMode((prev) => ({
...prev,
[abMode]: {
...prev[abMode],
[key]: !prev[abMode][key],
},
}));
};
const rawPathD = useMemo(() => {
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
return curveData.points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`)
.join(" ");
}, [rawCurve, curveData.points]);
const equalizedPathD = useMemo(() => {
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
return curveData.points
.map((p, i) => {
const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0));
return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
}, [rawCurve, curveData.points]);
const hasRawCurve = !!rawPathD;
const hasEqualizedCurve = !!equalizedPathD;
// Intro draw once; EQ param edits update path in place without replaying the stroke animation.
const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const };
useStrokeDrawAnimation(curvePathRef, pathD, {
...strokeAnimOpts,
enabled: !isBandDragging,
});
useStrokeDrawAnimation(rawPathRef, rawPathD, {
...strokeAnimOpts,
enabled: !isBandDragging && !!rawPathD,
});
useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, {
...strokeAnimOpts,
enabled: !isBandDragging && !!equalizedPathD,
});
const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
return (
<div className="ios-list-group p-3 mb-3">
{/* Legend row */}
<div className="flex items-center gap-3 mb-2 px-1">
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity"
onClick={() => toggleCurveVisibility("eq")}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#FFED00" }} />
<span className="text-[10px]" style={{ color: "#FFED00", opacity: showEq ? 1 : 0.35 }}>Equalizer</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("raw")}
disabled={rawCurveLoading || !hasRawCurve}
>
<LegendCurveIndicator loading={rawCurveLoading} color="#ffffff" />
<span
className="text-[10px]"
style={{ color: "#ffffff", opacity: rawCurveLoading || showRaw ? 1 : 0.35 }}
>
Raw
</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("equalized")}
disabled={rawCurveLoading || !hasEqualizedCurve}
>
<LegendCurveIndicator loading={rawCurveLoading} color="#23d2fe" />
<span
className="text-[10px]"
style={{ color: "#23d2fe", opacity: rawCurveLoading || showEqualized ? 1 : 0.35 }}
>
Equalized
</span>
</button>
</div>
{/* A/B + DIFF controls */}
<div className="flex w-full items-center gap-2 mb-2 px-1">
<div className="flex min-w-0 items-center gap-2">
{/* A/B toggle pill */}
<div className="flex items-center rounded-[8px] overflow-hidden"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}>
{(["A", "B"] as const).map((m) => (
<button key={m}
className="px-3 py-1 text-[12px] font-semibold transition-all duration-150"
style={abMode === m ? {
background: "#00FFF6", color: "#000", borderRadius: 6,
} : { color: "rgba(255,255,255,0.4)" }}
onClick={() => onAbToggle(m)}>
{m}
</button>
))}
</div>
<button
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => {
onCopyAndSwitchTo(targetMode);
setCurveVisibilityByMode((prev) => ({
...prev,
[targetMode]: { ...prev[abMode] },
}));
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
}}>
{copyButtonText}
</button>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<button
type="button"
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => onApplyB()}
>
{eqUi.chartApplyB}
</button>
<button
type="button"
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => onSaveB()}
>
{eqUi.chartSaveB}
</button>
</div>
</div>
{/* SVG chart */}
<div
ref={chartContainerRef}
className="rounded-[10px] overflow-hidden"
style={{ background: "rgba(10,12,10,0.7)" }}
>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, touchAction: "none" }}
onPointerMove={handleSvgPointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onPointerLeave={stopDragging}
>
{/* dB grid */}
{gainLabels.map((g) => (
<g key={g}>
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
{g > 0 ? `+${g}` : g}
</text>
</g>
))}
{/* Freq grid */}
{freqLabels.map((f) => (
<line key={f} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
))}
{/* Zero line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
{/* Fill */}
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
{/* EQ curve */}
{showEq && (
<path
ref={curvePathRef}
d={pathD}
fill="none"
stroke="#FFED00"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Raw */}
{rawPathD && showRaw && (
<path
ref={rawPathRef}
d={rawPathD}
fill="none"
stroke="#ffffff"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.9"
/>
)}
{/* Equalized = EQ + Raw */}
{equalizedPathD && showEqualized && (
<path
ref={equalizedPathRef}
d={equalizedPathD}
fill="none"
stroke="#23d2fe"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.95"
/>
)}
{/* Band nodes with index */}
{showEq && bands.map((band, i) => (
<g
key={i}
style={{ cursor: "grab" }}
onPointerDown={(e) => handleBandPointerDown(i, e)}
>
{(() => {
const isSelected = i === selectedBand;
const outerR = isSelected ? 8.8 : 7;
const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)";
const strokeColor = isSelected ? "#FFED00" : "#FFED00";
const textColor = isSelected ? "#000000" : "#FFED00";
const handleY = gainToY(combinedMagDbAtHandles[i] ?? 0);
return (
<>
<circle
cx={freqToX(band.freq)}
cy={handleY}
r="14"
fill="transparent"
/>
<circle
cx={freqToX(band.freq)}
cy={handleY}
r={outerR}
fill={fillColor}
stroke={strokeColor}
strokeWidth={isSelected ? 1.8 : 1.5}
/>
<text
x={freqToX(band.freq)}
y={handleY + 3.5}
textAnchor="middle"
fontSize="7"
fill={textColor}
fontWeight="bold"
pointerEvents="none"
>
{i + 1}
</text>
</>
);
})()}
</g>
))}
{/* Freq axis labels */}
{freqLabels.map((f) => (
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle"
fontSize="7" fill="rgba(255,255,255,0.25)">
{f >= 1000 ? `${f / 1000}k` : f}
</text>
))}
</svg>
</div>
</div>
);
}