拆分 EQ 页面
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user