优化eq
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 439 B |
+173
-79
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user