539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
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";
|
||
import { PeqUndoButton } from "./PeqUndoButton";
|
||
|
||
/* ── Y 轴刻度范围(±dB)可选项与持久化 ── */
|
||
const Y_RANGE_OPTIONS = [6, 12, 15, 20] as const;
|
||
type YRangeDb = (typeof Y_RANGE_OPTIONS)[number];
|
||
const DEFAULT_Y_RANGE_DB: YRangeDb = 12;
|
||
const Y_RANGE_STORAGE_KEY = "x9-peq-chart-y-db-max";
|
||
/** 每档范围的网格整数步长(均可整除边界,确保标签为整数) */
|
||
const Y_RANGE_STEP: Record<YRangeDb, number> = { 6: 2, 12: 3, 15: 5, 20: 5 };
|
||
|
||
function loadYRangeDb(): YRangeDb {
|
||
try {
|
||
const raw = Number(localStorage.getItem(Y_RANGE_STORAGE_KEY));
|
||
if (Y_RANGE_OPTIONS.includes(raw as YRangeDb)) return raw as YRangeDb;
|
||
} catch {
|
||
/* localStorage 不可用时回退默认值 */
|
||
}
|
||
return DEFAULT_Y_RANGE_DB;
|
||
}
|
||
|
||
/* ── 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,
|
||
canUndo,
|
||
onUndo,
|
||
onApplyB,
|
||
onSaveB,
|
||
onBandDrag,
|
||
onBandDragStart,
|
||
onBandDragEnd,
|
||
onBandSelect,
|
||
eqUi,
|
||
}: {
|
||
bands: Array<{
|
||
freq: number;
|
||
gain: number;
|
||
q: number;
|
||
type: string;
|
||
enabled: boolean;
|
||
/** 旁通的带贡献 0 dB,曲线与手柄纵坐标自动排除它 */
|
||
bypassed?: boolean;
|
||
}>;
|
||
rawCurve: number[] | null;
|
||
rawCurveLoading?: boolean;
|
||
selectedBand: number;
|
||
abMode: "A" | "B";
|
||
onAbToggle: (m: "A" | "B") => void;
|
||
onCopyAndSwitchTo: (to: "A" | "B") => void;
|
||
canUndo: boolean;
|
||
onUndo: () => void;
|
||
onApplyB: () => void;
|
||
onSaveB: () => void;
|
||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||
onBandDragStart?: () => void;
|
||
onBandDragEnd?: () => 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);
|
||
const [yDbMax, setYDbMax] = useState<YRangeDb>(loadYRangeDb);
|
||
|
||
const handleYRangeChange = (range: YRangeDb) => {
|
||
setYDbMax(range);
|
||
try {
|
||
localStorage.setItem(Y_RANGE_STORAGE_KEY, String(range));
|
||
} catch {
|
||
/* 忽略持久化失败,仅影响下次进入页面的默认值 */
|
||
}
|
||
};
|
||
|
||
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 = yDbMax;
|
||
|
||
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);
|
||
onBandDragStart?.();
|
||
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 = () => {
|
||
if (draggingBandRef.current === null) return;
|
||
draggingBandRef.current = null;
|
||
setIsBandDragging(false);
|
||
onBandDragEnd?.();
|
||
};
|
||
|
||
const curveData = useMemo(
|
||
() =>
|
||
buildPeqSvgCurveData({
|
||
bands,
|
||
width: W,
|
||
height: H,
|
||
fs: 48000,
|
||
yDbMax: Y_DB_MAX,
|
||
minFreq: 20,
|
||
maxFreq: 20000,
|
||
paddingY: 10,
|
||
}),
|
||
[bands, W, H, Y_DB_MAX],
|
||
);
|
||
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];
|
||
// 按当前范围的整数步长生成网格标签(如 ±12 → 12,9,6,3,0,-3,-6,-9,-12)
|
||
const gainLabels = useMemo(() => {
|
||
const step = Y_RANGE_STEP[Y_DB_MAX];
|
||
const intervals = Math.round((2 * Y_DB_MAX) / step);
|
||
const labels: number[] = [];
|
||
for (let i = 0; i <= intervals; i++) {
|
||
labels.push(Number((Y_DB_MAX - i * step).toFixed(2)));
|
||
}
|
||
return labels;
|
||
}, [Y_DB_MAX]);
|
||
const formatGainLabel = (g: number) => {
|
||
const v = Number(g.toFixed(2));
|
||
return v > 0 ? `+${v}` : String(v);
|
||
};
|
||
|
||
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>
|
||
{/* Y 轴刻度范围切换 */}
|
||
<div
|
||
className="ml-auto flex shrink-0 items-center overflow-hidden rounded-[8px]"
|
||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||
>
|
||
{Y_RANGE_OPTIONS.map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
className="px-2 py-1 text-[10px] transition-all duration-150"
|
||
style={
|
||
yDbMax === r
|
||
? { background: "#00FFF6", color: "#000", fontWeight: 600 }
|
||
: { color: "rgba(255,255,255,0.4)" }
|
||
}
|
||
onClick={() => handleYRangeChange(r)}
|
||
>
|
||
±{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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>
|
||
<PeqUndoButton
|
||
disabled={!canUndo}
|
||
label={eqUi.undo ?? "Undo"}
|
||
onClick={onUndo}
|
||
/>
|
||
</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}
|
||
>
|
||
<defs>
|
||
{/* 绘图区裁剪:超出当前 Y 轴刻度范围的曲线部分直接截断不显示 */}
|
||
<clipPath id="freq-chart-plot-clip">
|
||
<rect x="0" y="10" width={W} height={H - 20} />
|
||
</clipPath>
|
||
</defs>
|
||
{/* dB grid */}
|
||
{gainLabels.map((g) => (
|
||
<g key={`gain-${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)">
|
||
{formatGainLabel(g)}
|
||
</text>
|
||
</g>
|
||
))}
|
||
{/* Freq grid */}
|
||
{freqLabels.map((f) => (
|
||
<line key={`freq-line-${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" />
|
||
<g clipPath="url(#freq-chart-plot-clip)">
|
||
{/* 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"
|
||
/>
|
||
)}
|
||
</g>
|
||
{/* Band nodes with index */}
|
||
{showEq && bands.map((band, i) => (
|
||
<g
|
||
key={`band-${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 = Math.max(10, Math.min(H - 10, 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={`freq-text-${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>
|
||
);
|
||
}
|