This commit is contained in:
yangy
2026-04-23 18:00:33 +08:00
parent 4da1fcd53f
commit 6cce6d1f73
3 changed files with 188 additions and 90 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

+173 -79
View File
@@ -94,9 +94,10 @@ function normalizeFilterType(type: string | number | undefined): string {
/* ── Frequency Response Chart ── */
function FreqChart({
bands, selectedBand, abMode, onAbToggle, onBandDrag, onBandSelect,
bands, rawCurve, selectedBand, abMode, onAbToggle, onBandDrag, onBandSelect,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
rawCurve: number[] | null;
selectedBand: number;
abMode: "A" | "B";
onAbToggle: (m: "A" | "B") => void;
@@ -194,6 +195,26 @@ function FreqChart({
);
const pathD = curveData.pathD;
const fillD = curveData.fillD;
const [showEq, setShowEq] = useState(true);
const [showRaw, setShowRaw] = useState(true);
const [showEqualized, setShowEqualized] = useState(true);
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;
// Reusable left-to-right stroke draw animation (skip while dragging a band).
useStrokeDrawAnimation(curvePathRef, pathD, {
@@ -208,18 +229,32 @@ function FreqChart({
<div className="ios-list-group p-3 mb-3">
{/* Legend row */}
<div className="flex items-center gap-3 mb-2 px-1">
<div className="flex items-center gap-1.5">
<div className="w-3 h-[2px] rounded" style={{ background: "#f59e0b" }} />
<span className="text-[10px] text-white/45">Equalizer</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-[2px] rounded bg-white/25" />
<span className="text-[10px] text-white/45">Raw</span>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity"
onClick={() => setShowEq((v) => !v)}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#FFED00" }} />
<span className="text-[10px] text-white/45">Equalized</span>
</div>
<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={() => setShowRaw((v) => !v)}
disabled={!hasRawCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#ffffff" }} />
<span className="text-[10px]" style={{ color: "#ffffff", opacity: 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={() => setShowEqualized((v) => !v)}
disabled={!hasEqualizedCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#23d2fe" }} />
<span className="text-[10px]" style={{ color: "#23d2fe", opacity: showEqualized ? 1 : 0.35 }}>Equalized</span>
</button>
</div>
{/* A/B + DIFF controls */}
@@ -286,8 +321,9 @@ function FreqChart({
{/* Zero line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
{/* Fill */}
<path d={fillD} fill="rgba(255, 237, 0, 0.08)" />
{/* Equalized curve (stroke-dash anim via useEffect) */}
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
{/* EQ curve */}
{showEq && (
<path
ref={curvePathRef}
d={pathD}
@@ -297,10 +333,33 @@ function FreqChart({
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Raw curve (flat) */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" strokeDasharray="4,3" />
)}
{/* Raw */}
{rawPathD && showRaw && (
<path
d={rawPathD}
fill="none"
stroke="#ffffff"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.9"
/>
)}
{/* Equalized = EQ + Raw */}
{equalizedPathD && showEqualized && (
<path
d={equalizedPathD}
fill="none"
stroke="#23d2fe"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.95"
/>
)}
{/* Band nodes with index */}
{bands.map((band, i) => (
{showEq && bands.map((band, i) => (
<g
key={i}
style={{ cursor: "grab" }}
@@ -479,6 +538,7 @@ export default function EQPage() {
const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState<string | undefined>(undefined);
const [selectedCatalogTarget, setSelectedCatalogTarget] = useState<string>("");
const [isConfirmingTarget, setIsConfirmingTarget] = useState(false);
const [currentRawCurve, setCurrentRawCurve] = useState<number[] | null>(null);
const allowPeqRemoteSyncRef = useRef(false);
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(null);
@@ -786,8 +846,7 @@ export default function EQPage() {
const latest = await api.getPeqState();
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
toast.success("已删除耳机");
} catch (error) {
console.error("removePeq", error);
} catch {
toast.error("删除耳机失败");
}
};
@@ -863,8 +922,7 @@ export default function EQPage() {
}
setIsAddPresetDialogOpen(false);
toast.success("已新增预设");
} catch (error) {
console.error("saveAddPreset", error);
} catch {
toast.error("新增预设失败");
}
};
@@ -879,6 +937,7 @@ export default function EQPage() {
// 获取耳机原始曲线
const getModelCurve = async (brand: string, name: string) => {
try {
console.log("[modelCurve] request", { brand, name });
const resp = await fetch(
`//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}`
);
@@ -886,17 +945,74 @@ export default function EQPage() {
// 使用自定义 Base64 解码
const decoded = decodeCustomBase64(data);
const parsed = JSON.parse(decoded);
console.log('型号的原始数据:', parsed);
if (parsed.hasOwnProperty('fr')) {
return parsed.fr;
}
return null;
console.log("[modelCurve] decoded", {
hasFrRaw:
!!(parsed &&
typeof parsed === "object" &&
"fr" in (parsed as Record<string, unknown>) &&
(parsed as { fr?: unknown }).fr &&
typeof (parsed as { fr?: unknown }).fr === "object" &&
"raw" in ((parsed as { fr?: Record<string, unknown> }).fr ?? {})),
});
console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw);
return parsed;
} catch (error) {
console.error("Failed to get model curve:", error);
console.log("[modelCurve] request failed", error);
return null;
}
};
const loadRawCurveForPeq = useCallback(
async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise<number[] | null> => {
let brand = peq?.brand?.trim() ?? "";
let model = peq?.model?.trim() ?? "";
// Some presets only have `name` (e.g. "Apple AirPods Pro") and miss explicit brand/model.
if ((!brand || !model) && peq?.name) {
const [first, ...rest] = peq.name.trim().split(/\s+/);
if (!brand && first) brand = first;
if (!model && rest.length > 0) model = rest.join(" ");
}
if (!brand || !model) return null;
const modelCurve = await getModelCurve(brand, model);
if (modelCurve && typeof modelCurve === "object") {
const payload = modelCurve as {
fr?: { raw?: unknown } | unknown;
};
const rawCandidate =
payload.fr && typeof payload.fr === "object" && Array.isArray((payload.fr as { raw?: unknown }).raw)
? (payload.fr as { raw?: unknown }).raw
: null;
const raw = Array.isArray(rawCandidate)
? rawCandidate
.map((point) => {
if (typeof point === "number" && Number.isFinite(point)) return point;
if (Array.isArray(point) && point.length >= 2 && typeof point[1] === "number") return point[1];
if (point && typeof point === "object") {
const y = (point as Record<string, unknown>).y
?? (point as Record<string, unknown>).value
?? (point as Record<string, unknown>).db
?? (point as Record<string, unknown>).gain;
if (typeof y === "number" && Number.isFinite(y)) return y;
}
return NaN;
})
: null;
const cleanedRaw = raw && raw.every((v) => Number.isFinite(v)) ? (raw as number[]) : null;
console.log("[modelCurve] raw check", {
brand,
model,
hasRaw: !!cleanedRaw,
rawLength: cleanedRaw?.length ?? 0,
});
return cleanedRaw;
}
return null;
},
[],
);
// 加载耳机列表并初始化曲线
useEffect(() => {
async function loadHeadphones() {
@@ -914,6 +1030,7 @@ export default function EQPage() {
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
setCurrentRawCurve(null);
return;
}
try {
@@ -928,33 +1045,14 @@ export default function EQPage() {
// 修复 filters 数据(兼容不同的字段名)
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
console.log("Fixed filters:", fixedFilters);
// 更新 bands 状态
if (fixedFilters && fixedFilters.length > 0) {
setBands(fixedFilters);
}
// 如果有 brand 和 model,获取原始曲线
let raw: number[] | null = null;
if ((currentPeq as any).brand && (currentPeq as any).model) {
try {
const modelCurve = await getModelCurve((currentPeq as any).brand, (currentPeq as any).model);
if (modelCurve && 'raw' in modelCurve) {
raw = modelCurve.raw;
}
} catch (error) {
console.warn("Failed to load model curve:", error);
}
}
// raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理,
// 避免首次进入页面时重复请求 modelCurve。
// 渲染图表(使用修复后的 fixedFilters
// 使用 setTimeout 确保 DOM 已经渲染
setTimeout(() => {
renderCharts(fixedFilters.length > 0 ? fixedFilters : bands, raw, false);
}, 100);
console.log("Initialized PEQ:", currentPeq.name, "Filters count:", fixedFilters.length, "Raw curve:", raw ? "loaded" : "not available");
}
} else {
// 如果没有数据,使用默认列表
@@ -968,9 +1066,9 @@ export default function EQPage() {
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
setCurrentRawCurve(null);
}
} catch (error) {
console.error("Failed to load headphone models:", error);
} catch {
// 出错时使用默认列表
const defaultModels = [
"Sennheiser HD 650",
@@ -982,13 +1080,14 @@ export default function EQPage() {
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
setCurrentRawCurve(null);
}
} finally {
allowPeqRemoteSyncRef.current = true;
}
}
loadHeadphones();
}, [api, isDemoMode]);
}, [api, isDemoMode, loadRawCurveForPeq]);
// 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写)
useEffect(() => {
@@ -999,10 +1098,20 @@ export default function EQPage() {
syncingHeadphoneRef.current = true;
setBands(nextBands);
setSelectedBand(0);
let cancelled = false;
void (async () => {
const raw = await loadRawCurveForPeq(peq as { brand?: string; model?: string });
if (cancelled) return;
setCurrentRawCurve(raw);
renderCharts(nextBands, raw, false);
})();
requestAnimationFrame(() => {
syncingHeadphoneRef.current = false;
});
}, [headphoneIdx, peqItems]);
return () => {
cancelled = true;
};
}, [headphoneIdx, peqItems, loadRawCurveForPeq]);
const schedulePeqSync = (
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
@@ -1028,9 +1137,7 @@ export default function EQPage() {
canDel: peq.canDel ?? 1,
},
};
console.log("upgradePeqChange payload:", payload);
upgradePeqChange(payload).catch((err) => {
console.error("upgradePeqChange", err);
toast.error("EQ 保存失败");
});
}, delay);
@@ -1072,9 +1179,8 @@ export default function EQPage() {
const fs = 48000;
// Calculate coefficient matrix for each filter
peqFilters.forEach((item, index) => {
peqFilters.forEach((item) => {
const filterType = getFilterType(item.type);
console.log(`Filter ${index}: type="${item.type}" -> mapped to ${filterType}, freq=${item.freq}, gain=${item.gain}, q=${item.q}`);
const coeff = getSectionsMatrix(
item.gain,
@@ -1086,14 +1192,12 @@ export default function EQPage() {
);
if (coeff) {
list.push(coeff);
} else {
console.error(`Filter ${index} returned null/undefined coeff!`);
}
});
// Get frequency response data
const dataSet = visualizeResponse(list, fs);
const ops = getChartOps(dataSet, 20, -20, '#FFED00');
const ops = getChartOps(dataSet, 20, -20, '#FFED00') as any;
// Get or create chart instance
const chartDom = document.getElementById('freq-chart');
@@ -1107,7 +1211,7 @@ export default function EQPage() {
// Handle changeParam (get raw data from existing chart)
if (changeParam && myChart) {
const option = myChart.getOption();
const option = myChart.getOption() as { series?: Array<{ data?: number[] }> };
if (option.series && option.series.length > 1) {
raw = (option.series[1] as any).data;
}
@@ -1116,8 +1220,8 @@ export default function EQPage() {
// Clear and rebuild chart
myChart.clear();
// Add Raw and Equalized curves if raw data is available
if (raw) {
// Add Raw and Equalized curves if raw data is available (expects same 349 points as EQ curve)
if (Array.isArray(raw) && raw.length === dataSet[1].length) {
ops.series.push({
name: 'Raw',
data: raw,
@@ -1129,7 +1233,7 @@ export default function EQPage() {
});
// Calculate equalized curve
const equalizedRaw = dataSet[1].map((value, index) => value + (raw as any)[index]);
const equalizedRaw = dataSet[1].map((value, index) => value + raw[index]);
ops.series.push({
name: 'Equalized',
data: equalizedRaw,
@@ -1147,11 +1251,11 @@ export default function EQPage() {
};
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b));
const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b));
setBands(nextBands);
// Re-render chart when band changes
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
const currentPeq = peqItems[headphoneIdx];
renderCharts(bands, null, false);
renderCharts(nextBands, currentRawCurve, false);
}
};
@@ -1286,6 +1390,7 @@ export default function EQPage() {
{/* ── Frequency response chart ── */}
<FreqChart
bands={bands}
rawCurve={currentRawCurve}
selectedBand={selectedBand}
abMode={abMode}
onAbToggle={setAbMode}
@@ -1767,10 +1872,7 @@ export default function EQPage() {
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") {
console.warn("getCurve decoded: missing parametric_eq", { brand, name, target, data: parsed });
return;
}
if (!parametricEqRaw || typeof parametricEqRaw !== "object") return;
const parametricEq = parametricEqRaw as {
filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>;
@@ -1834,16 +1936,8 @@ export default function EQPage() {
}
setIsBrandDrawerOpen(false);
console.log("getCurve decoded + peqChange posted:", {
brand,
name,
target,
data: parsed,
postPeq,
});
toast.success("新耳机 EQ 已上报");
} catch (error) {
console.error("getCurve failed:", error);
} catch {
toast.error("获取曲线失败");
} finally {
setIsConfirmingTarget(false);
+6 -2
View File
@@ -32,7 +32,6 @@ import {
Activity,
Gauge,
ChevronDown,
Zap,
Wifi,
Bluetooth,
} from "lucide-react";
@@ -462,7 +461,12 @@ export default function Home() {
className={cn("icon-circle-btn", bypassOn && "active")}
onClick={() => updateSetting({ dsp_enable: bypassOn ? 1 : 0 })}
>
<Zap size={22} className={bypassOn ? "text-[#00FFF6]" : "text-white/60"} />
<img
src={`${import.meta.env.BASE_URL}home/bypass.png`}
alt="Bypass"
className="w-[25px] h-[25px] object-contain"
style={{ opacity: bypassOn ? 1 : 0.6, transform: "translateX(1.5px)" }}
/>
</button>
<span className={cn("text-[11px]", bypassOn ? "text-[#00FFF6]" : "text-white/35")}>Bypass</span>
</div>