/* ============================================================ HP-EQ PAGE — Parametric EQ Design: Reference luxsin_x8_hpeq_ui.jpeg Layout (top to bottom): 1. Page header: < HP-EQ [toggle] 2. Two action cards: 批量编辑 / 添加耳机型号 3. Headphone model row: [Name ▼] [−] [+] 4. Freq response chart: legend + A/B + 复制到B + DIFF + SVG curve 5. Band grid: 10 pills (2 rows × 5) 6. Band detail card: 滤波器 / FREQ / GAIN / Q值 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation"; import BottomNav from "@/components/BottomNav"; import { toast } from "sonner"; import { decodeCustomBase64, fetchLuxsinAudioBrands, fetchLuxsinAudioCurve, fetchLuxsinAudioModelList, fetchLuxsinAudioModels, type LuxsinAudioBrand, type LuxsinAudioModelListItem, type LuxsinAudioModel, type PeqFilter, type PeqChangePayload, } from "@/lib/luxsinApi"; import * as echarts from "echarts"; import { getSectionsMatrix, visualizeResponse, getChartOps, getFilterType, getFilterShortName, buildPeqSvgCurveData, } from "@/lib/peqAudio"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; type PeqEqUi = NonNullable["eqUi"]>; function eqInterp(template: string | undefined, vars: Record): string { if (!template) return ""; let s = template; for (const [k, v] of Object.entries(vars)) { s = s.split(`{{${k}}}`).join(String(v)); } return s; } /* ── iOS Toggle ── */ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ); } /* ── Cyan Slider ── */ function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: { value: number; min: number; max: number; step?: number; onChange: (v: number) => void; disabled?: boolean; }) { const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); return (
onChange(Number(e.target.value))} />
); } /* ── Constants ── */ const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"]; function normalizeFilterType(type: string | number | undefined): string { if (type === undefined || type === null) return "PEAK"; if (typeof type === "number") { switch (type) { case 0: return "LPF"; case 1: return "HPF"; case 2: return "BPF"; case 3: return "NOTCH"; case 4: return "PEAK"; case 5: return "LSHELF"; case 6: return "HSHELF"; case 7: return "APF"; default: return "PEAK"; } } return getFilterShortName(type); } /* ── Frequency Response Chart ── */ function FreqChart({ bands, rawCurve, selectedBand, abMode, onAbToggle, onCopyMode, onBandDrag, onBandSelect, eqUi, }: { 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; onCopyMode: (from: "A" | "B", to: "A" | "B") => void; onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; onBandSelect: (idx: number) => void; eqUi: PeqEqUi; }) { const H = 140; const chartContainerRef = useRef(null); const svgRef = useRef(null); const draggingBandRef = useRef(null); const curvePathRef = useRef(null); const rawPathRef = useRef(null); const equalizedPathRef = useRef(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) => { e.preventDefault(); e.stopPropagation(); draggingBandRef.current = idx; setIsBandDragging(true); onBandSelect(idx); updateBandFromPointer(idx, e.clientX, e.clientY); }; const handleSvgPointerMove = (e: React.PointerEvent) => { 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], ); const pathD = curveData.pathD; const fillD = curveData.fillD; 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; // Reusable left-to-right stroke draw animation (skip while dragging a band). useStrokeDrawAnimation(curvePathRef, pathD, { enabled: !isBandDragging, durationMs: 1350, }); useStrokeDrawAnimation(rawPathRef, rawPathD, { enabled: !isBandDragging && !!rawPathD, durationMs: 1350, }); useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, { enabled: !isBandDragging && !!equalizedPathD, durationMs: 1350, }); 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 (
{/* Legend row */}
{/* A/B + DIFF controls */}
{/* A/B toggle pill */}
{(["A", "B"] as const).map((m) => ( ))}
{/* SVG chart */}
{/* dB grid */} {gainLabels.map((g) => ( {g > 0 ? `+${g}` : g} ))} {/* Freq grid */} {freqLabels.map((f) => ( ))} {/* Zero line */} {/* Fill */} {showEq && } {/* EQ curve */} {showEq && ( )} {/* Raw */} {rawPathD && showRaw && ( )} {/* Equalized = EQ + Raw */} {equalizedPathD && showEqualized && ( )} {/* Band nodes with index */} {showEq && bands.map((band, i) => ( 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"; return ( <> {i + 1} ); })()} ))} {/* Freq axis labels */} {freqLabels.map((f) => ( {f >= 1000 ? `${f / 1000}k` : f} ))}
); } /* ── Default bands matching reference image ── */ const DEFAULT_BANDS = [ { freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true }, { freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true }, { freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true }, { freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true }, { freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true }, { freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true }, { freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true }, { freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true }, { freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true }, { freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true }, ]; function cloneBands( source: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>, ) { return source.map((band) => ({ ...band })); } function freqLabel(f: number) { return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; } function getUniquePresetName(base: string, existingNames: string[]) { if (!existingNames.includes(base)) return base; let index = 1; while (existingNames.includes(`${base}_${index}`)) { index += 1; } return `${base}_${index}`; } const FLAT_PRESET_FILTERS: PeqFilter[] = [ { type: 4, fc: 80, gain: 0, q: 0.1 }, { type: 4, fc: 150, gain: 0, q: 0.1 }, { type: 4, fc: 350, gain: 0, q: 0.1 }, { type: 4, fc: 750, gain: 0, q: 0.1 }, { type: 4, fc: 1500, gain: 0, q: 0.1 }, { type: 4, fc: 3000, gain: 0, q: 0.1 }, { type: 4, fc: 6000, gain: 0, q: 0.1 }, { type: 4, fc: 10000, gain: 0, q: 0.1 }, { type: 4, fc: 14000, gain: 0, q: 0.1 }, { type: 4, fc: 18000, gain: 0, q: 0.1 }, ]; type CatalogTarget = { name: string; bassBoost: { fc: number; q: number; gain: number }; ear: "in" | "over" | "all"; }; const CATALOG_TARGETS: CatalogTarget[] = [ { name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, { name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, { name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, { name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, { name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" }, { name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" }, { name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, { name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, { name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" }, ]; /** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number }): PeqFilter { return { fc: b.freq, gain: b.gain, q: b.q, type: getFilterType(b.type), }; } /* ── Main Component ── */ export default function EQPage() { const [, setLocation] = useLocation(); const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice(); const eqOn = (deviceState?.peqEnable ?? 0) === 1; const eqUi = useMemo((): PeqEqUi => { const lang = deviceState?.language; const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh; const peq = pack.peq as typeof localeZh.peq; return (peq.eqUi ?? (localeZh.peq as typeof localeZh.peq).eqUi) as PeqEqUi; }, [deviceState?.language]); const peqCardLabels = useMemo(() => { const lang = deviceState?.language; const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh; return pack.peq ?? localeZh.peq; }, [deviceState?.language]); const [bandsByMode, setBandsByMode] = useState>({ A: cloneBands(DEFAULT_BANDS), B: cloneBands(DEFAULT_BANDS), }); const [selectedBandByMode, setSelectedBandByMode] = useState>({ A: 0, B: 0, }); const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0); const [headphoneModels, setHeadphoneModels] = useState([]); const [peqItems, setPeqItems] = useState< Array<{ name: string; brand?: string; model?: string; filters?: any[] | string; autoPre?: number; preamp?: number; canDel?: number; }> >([]); const [abMode, setAbMode] = useState<"A" | "B">("A"); const [isHeadphoneMenuOpen, setIsHeadphoneMenuOpen] = useState(false); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false); const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy"); const [copyPresetName, setCopyPresetName] = useState(""); const [flatPresetName, setFlatPresetName] = useState(""); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [batchEditText, setBatchEditText] = useState(""); type BrandDrawerTab = "brands" | "models" | "target"; const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false); const [brandDrawerTab, setBrandDrawerTab] = useState("brands"); const [brandSearchQuery, setBrandSearchQuery] = useState(""); const [catalogBrands, setCatalogBrands] = useState([]); const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false); const [catalogBrandsError, setCatalogBrandsError] = useState(null); const [catalogSearchResults, setCatalogSearchResults] = useState([]); const [catalogSearchLoading, setCatalogSearchLoading] = useState(false); const [catalogSearchError, setCatalogSearchError] = useState(null); const [selectedCatalogBrand, setSelectedCatalogBrand] = useState(""); const [catalogModels, setCatalogModels] = useState([]); const [catalogModelsLoading, setCatalogModelsLoading] = useState(false); const [catalogModelsError, setCatalogModelsError] = useState(null); const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState(""); const [selectedCatalogModelName, setSelectedCatalogModelName] = useState(""); const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState(undefined); const [selectedCatalogTarget, setSelectedCatalogTarget] = useState(""); const [isConfirmingTarget, setIsConfirmingTarget] = useState(false); const [currentRawCurve, setCurrentRawCurve] = useState(null); const allowPeqRemoteSyncRef = useRef(false); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; const selectedBand = selectedBandByMode[abMode]; const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => { const cloned = cloneBands(nextBands); setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) }); }, []); const setBands = useCallback( (updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)) => { setBandsByMode((prev) => ({ ...prev, [abMode]: typeof updater === "function" ? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)(prev[abMode]) : cloneBands(updater), })); }, [abMode], ); const setSelectedBand = useCallback( (updater: number | ((prev: number) => number)) => { setSelectedBandByMode((prev) => ({ ...prev, [abMode]: typeof updater === "function" ? (updater as (prev: number) => number)(prev[abMode]) : updater, })); }, [abMode], ); const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => { setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) })); setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] })); }, []); const currentPeq = peqItems[headphoneIdx]; const preampValue = Number(currentPeq?.preamp ?? 0); const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; const filteredCatalogBrands = useMemo(() => { const q = brandSearchQuery.trim().toLowerCase(); if (!q) return catalogBrands; return catalogBrands.filter((b) => b.name.toLowerCase().includes(q)); }, [catalogBrands, brandSearchQuery]); const availableCatalogTargets = useMemo(() => { if (selectedCatalogModelForm === "in-ear") { return CATALOG_TARGETS.filter((item) => item.ear === "in" || item.ear === "all"); } if (selectedCatalogModelForm === "over-ear") { return CATALOG_TARGETS.filter((item) => item.ear === "over" || item.ear === "all"); } return CATALOG_TARGETS; }, [selectedCatalogModelForm]); const loadCatalogBrands = useCallback(async () => { setCatalogBrandsLoading(true); setCatalogBrandsError(null); try { const list = await fetchLuxsinAudioBrands(); const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); setCatalogBrands(sorted); } catch (e) { const msg = e instanceof Error ? e.message : "加载失败"; setCatalogBrandsError(msg); toast.error(eqUi.toastBrandsFail); } finally { setCatalogBrandsLoading(false); } }, [eqUi]); const searchCatalogByKeyword = useCallback(async (keyword: string) => { const q = keyword.trim(); if (!q) { setCatalogSearchResults([]); setCatalogSearchError(null); setCatalogSearchLoading(false); return; } setCatalogSearchLoading(true); setCatalogSearchError(null); try { const list = await fetchLuxsinAudioModelList(q, 1000); setCatalogSearchResults(list); } catch (e) { const msg = e instanceof Error ? e.message : "搜索失败"; setCatalogSearchError(msg); setCatalogSearchResults([]); } finally { setCatalogSearchLoading(false); } }, []); const loadCatalogModels = useCallback(async (brandName: string) => { setSelectedCatalogModelFromSearch(""); setCatalogModelsLoading(true); setCatalogModelsError(null); try { const list = await fetchLuxsinAudioModels(brandName); const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); setCatalogModels(sorted); } catch (e) { const msg = e instanceof Error ? e.message : "加载失败"; setCatalogModelsError(msg); toast.error(eqUi.toastModelsFail); } finally { setCatalogModelsLoading(false); } }, [eqUi]); const openAddHeadsetCatalog = () => { setIsBrandDrawerOpen(true); setBrandDrawerTab("brands"); setBrandSearchQuery(""); setCatalogSearchResults([]); setCatalogSearchError(null); setCatalogSearchLoading(false); setSelectedCatalogBrand(""); setSelectedCatalogModelFromSearch(""); setSelectedCatalogModelName(""); setSelectedCatalogModelForm(undefined); setSelectedCatalogTarget(""); setCatalogModels([]); setCatalogModelsError(null); void loadCatalogBrands(); }; useEffect(() => { if (!isBrandDrawerOpen || brandDrawerTab !== "brands") return; const timer = window.setTimeout(() => { void searchCatalogByKeyword(brandSearchQuery); }, 260); return () => window.clearTimeout(timer); }, [isBrandDrawerOpen, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]); useEffect(() => { if (!isBrandDrawerOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setIsBrandDrawerOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [isBrandDrawerOpen]); useEffect(() => { if (!isBrandDrawerOpen) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [isBrandDrawerOpen]); const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { if (!peq) return [] as typeof bands; let filters: any[] = []; if (typeof peq.filters === "string") { try { filters = JSON.parse(peq.filters); } catch { filters = []; } } else if (Array.isArray(peq.filters)) { filters = peq.filters; } return filters.map((f: any) => { const freq = f.fc || f.freq || f.frequency || 1000; return { freq: Number(freq), gain: Number(f.gain || 0), q: Number(f.q || 1), type: normalizeFilterType(f.type), enabled: f.enabled !== undefined ? f.enabled : true, }; }); }; const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString(); const buildBatchEditText = useCallback(() => { const preamp = Number(currentPeq?.preamp ?? 0); const source = bands.length > 0 ? bands : DEFAULT_BANDS; const lines = [`Preamp:${toCompactNumber(preamp)}dB`]; for (let i = 0; i < 10; i++) { const band = source[i] ?? DEFAULT_BANDS[i] ?? DEFAULT_BANDS[0]; lines.push( `Filter ${i + 1}: ${band.enabled ? "ON" : "OFF"} ${normalizeFilterType(band.type)} Fc ${toCompactNumber(band.freq)} Hz Gain ${toCompactNumber(band.gain)} dB Q ${toCompactNumber(band.q)}` ); } return lines.join("\n"); }, [bands, currentPeq?.preamp]); const openBatchEditDialog = () => { setBatchEditText(buildBatchEditText()); setIsBatchEditDialogOpen(true); }; const parseBatchEditText = useCallback( (raw: string) => { const t = eqUi; const lines = raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (lines.length < 11) { throw new Error(eqInterp(t.errMinLines, {})); } const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i); if (!preampMatch) { throw new Error(eqInterp(t.errPreampFirstLine, {})); } const preamp = Number(preampMatch[1]); if (!Number.isFinite(preamp)) { throw new Error(eqInterp(t.errPreampValue, {})); } const parsedFilters = new Array<{ enabled: boolean; type: string; freq: number; gain: number; q: number; }>(10); for (let i = 1; i <= 10; i++) { const line = lines[i]; const match = line.match( /^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i ); if (!match) { throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) })); } const filterNo = Number(match[1]); if (filterNo < 1 || filterNo > 10) { throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) })); } const type = normalizeFilterType(match[3].toUpperCase()); if (!FILTER_TYPES.includes(type)) { throw new Error(eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] })); } const freq = Number(match[4]); const gain = Number(match[5]); const q = Number(match[6]); if (![freq, gain, q].every(Number.isFinite)) { throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) })); } parsedFilters[filterNo - 1] = { enabled: match[2].toUpperCase() === "ON", type, freq: Number(freq.toFixed(2)), gain: Number(gain.toFixed(2)), q: Number(q.toFixed(2)), }; } if (parsedFilters.some((item) => !item)) { throw new Error(eqInterp(t.errFilterAll, {})); } return { preamp: Number(preamp.toFixed(2)), filters: parsedFilters as typeof bands, }; }, [eqUi], ); const handleSaveBatchEdit = () => { try { const parsed = parseBatchEditText(batchEditText); let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; setPeqItems((prev) => prev.map((item, i) => { if (i !== headphoneIdx) return item; const merged = { ...item, autoPre: 0, preamp: parsed.preamp, filters: parsed.filters.map(bandToPeqFilter), }; updatedPeq = merged; return merged; }) ); setBands(parsed.filters); schedulePeqSync(updatedPeq, parsed.filters, 0); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); } catch (error) { toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError); } }; const applyPeqStateToUI = ( peqState: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }, ) => { const items = peqState.peq ?? []; setPeqItems(items as typeof peqItems); setHeadphoneModels(items.map((item) => item.name)); if (items.length === 0) { setHeadphoneIdx(0); setBandsForBothModes(DEFAULT_BANDS); setSelectedBandByMode({ A: 0, B: 0 }); return; } const nextIdx = Math.min(Math.max(peqState.peqSelect ?? 0, 0), items.length - 1); setHeadphoneIdx(nextIdx); const nextBands = normalizeFiltersFromPeq(items[nextIdx] as { filters?: any[] | string }); if (nextBands.length > 0) { setBandsForBothModes(nextBands); } setSelectedBandByMode({ A: 0, B: 0 }); }; const handleDeleteHeadphone = async () => { const target = peqItems[headphoneIdx]; if (!target?.name) return; const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name })); if (!confirmed) return; try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } await api.removePeq([target.name]); const latest = await api.getPeqState(); applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); toast.success(eqUi.toastDeleted); } catch { toast.error(eqUi.toastDeleteFail); } }; const openAddPresetDialog = () => { const existingNames = headphoneModels; const currentName = headphoneModels[headphoneIdx] ?? "Preset"; setCopyPresetName(getUniquePresetName(currentName, existingNames)); setFlatPresetName(getUniquePresetName("Flat", existingNames)); setAddPresetMode("copy"); setIsAddPresetDialogOpen(true); }; const handleSaveAddPreset = async () => { const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim(); if (!nextName) { toast.error("名称不能为空"); return; } if (headphoneModels.includes(nextName)) { toast.error("名称已存在,请修改后再保存"); return; } const copyPayload: PeqChangePayload = { peqChange: { name: nextName, filters: bands.map(bandToPeqFilter), autoPre: currentPeq?.autoPre ?? 0, preamp: currentPeq?.preamp ?? 0, canDel: currentPeq?.canDel ?? 1, }, }; const flatPayload: PeqChangePayload = { peqChange: { name: nextName, preamp: 0, canDel: 1, autoPre: 0, filters: FLAT_PRESET_FILTERS, }, }; try { await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload); if (api && !isDemoMode) { const latest = await api.getPeqState(); applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); const createdIndex = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; if (createdIndex >= 0) { setHeadphoneIdx(createdIndex); updateSetting({ peqSelect: createdIndex }); } } else { const localFilters = addPresetMode === "copy" ? bands.map(bandToPeqFilter) : FLAT_PRESET_FILTERS; const localAutoPre = addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0; const localPreamp = addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0; setHeadphoneModels((prev) => [...prev, nextName]); setPeqItems((prev) => [ ...prev, { name: nextName, filters: localFilters, autoPre: localAutoPre, preamp: localPreamp, canDel: 1, }, ]); setHeadphoneIdx(headphoneModels.length); } setIsAddPresetDialogOpen(false); toast.success("已新增预设"); } catch { toast.error("新增预设失败"); } }; // 同步 peqSelect 变化 useEffect(() => { if (deviceState?.peqSelect !== undefined) { setHeadphoneIdx(deviceState.peqSelect); } }, [deviceState?.peqSelect]); // 获取耳机原始曲线 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)}` ); const data = await resp.text(); // 使用自定义 Base64 解码 const decoded = decodeCustomBase64(data); const parsed = JSON.parse(decoded); console.log("[modelCurve] decoded", { hasFrRaw: !!(parsed && typeof parsed === "object" && "fr" in (parsed as Record) && (parsed as { fr?: unknown }).fr && typeof (parsed as { fr?: unknown }).fr === "object" && "raw" in ((parsed as { fr?: Record }).fr ?? {})), }); console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw); return parsed; } catch (error) { console.log("[modelCurve] request failed", error); return null; } }; const loadRawCurveForPeq = useCallback( async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise => { 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).y ?? (point as Record).value ?? (point as Record).db ?? (point as Record).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() { allowPeqRemoteSyncRef.current = false; try { if (isDemoMode || !api) { // 演示模式使用默认列表 const defaultModels = [ "Sennheiser HD 650", "Sennheiser HD 800", "Sony WH-1000XM5", "AKG K701", "Beyerdynamic DT 990", "Audio-Technica ATH-M50x", ]; setHeadphoneModels(defaultModels); setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); setCurrentRawCurve(null); return; } try { const peqState = await api.getPeqState(); if (peqState.peq && peqState.peq.length > 0) { setHeadphoneModels(peqState.peq.map(h => h.name)); setPeqItems(peqState.peq); // 初始化当前选中的耳机曲线 const currentPeq = peqState.peq[peqState.peqSelect || 0]; if (currentPeq) { // 修复 filters 数据(兼容不同的字段名) const fixedFilters = normalizeFiltersFromPeq(currentPeq); // 更新 bands 状态 if (fixedFilters && fixedFilters.length > 0) { setBandsForBothModes(fixedFilters); setSelectedBandByMode({ A: 0, B: 0 }); } // raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理, // 避免首次进入页面时重复请求 modelCurve。 } } else { // 如果没有数据,使用默认列表 const defaultModels = [ "Sennheiser HD 650", "Sennheiser HD 800", "Sony WH-1000XM5", "AKG K701", "Beyerdynamic DT 990", "Audio-Technica ATH-M50x", ]; setHeadphoneModels(defaultModels); setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); setCurrentRawCurve(null); } } catch { // 出错时使用默认列表 const defaultModels = [ "Sennheiser HD 650", "Sennheiser HD 800", "Sony WH-1000XM5", "AKG K701", "Beyerdynamic DT 990", "Audio-Technica ATH-M50x", ]; setHeadphoneModels(defaultModels); setPeqItems(defaultModels.map(name => ({ name, filters: [] }))); setCurrentRawCurve(null); } } finally { allowPeqRemoteSyncRef.current = true; } } loadHeadphones(); }, [api, isDemoMode, loadRawCurveForPeq]); // 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写) useEffect(() => { const peq = peqItems[headphoneIdx]; if (!peq) return; const nextBands = normalizeFiltersFromPeq(peq); if (!nextBands.length) return; syncingHeadphoneRef.current = true; setBandsForBothModes(nextBands); setSelectedBandByMode({ A: 0, B: 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; }); return () => { cancelled = true; }; }, [headphoneIdx, peqItems, loadRawCurveForPeq]); const schedulePeqSync = ( peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined, filtersSource: typeof bands, delay = 320 ) => { if (!allowPeqRemoteSyncRef.current) return; if (!isDemoMode && !api) return; if (!peq?.name) return; if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); const payload: PeqChangePayload = { peqChange: { name: peq.name, filters, autoPre: peq.autoPre ?? 0, preamp: peq.preamp ?? 0, canDel: peq.canDel ?? 1, }, }; upgradePeqChange(payload).catch((err) => { toast.error("EQ 保存失败"); }); }, delay); }; // 参数变更(含拖动曲线)→ POST peqChange(防抖) useEffect(() => { if (syncingHeadphoneRef.current) return; schedulePeqSync(peqItems[headphoneIdx], bands, 320); return () => { if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } }; }, [bands, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange]); useEffect(() => { const onPointerDown = (event: PointerEvent) => { const target = event.target as Node; const inFilter = filterMenuRef.current?.contains(target) ?? false; const inHeadphone = headphoneMenuRef.current?.contains(target) ?? false; if (!inFilter) { setIsFilterMenuOpen(false); } if (!inHeadphone) { setIsHeadphoneMenuOpen(false); } }; window.addEventListener("pointerdown", onPointerDown); return () => window.removeEventListener("pointerdown", onPointerDown); }, []); const chartRef = useRef(null); const band = bands[selectedBand]; // Render frequency response chart using ECharts const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => { const list: any[] = []; const fs = 48000; // Calculate coefficient matrix for each filter peqFilters.forEach((item) => { const filterType = getFilterType(item.type); const coeff = getSectionsMatrix( item.gain, item.freq, item.q, filterType, false, fs ); if (coeff) { list.push(coeff); } }); // Get frequency response data const dataSet = visualizeResponse(list, fs); const ops = getChartOps(dataSet, 20, -20, '#FFED00') as any; // Get or create chart instance const chartDom = document.getElementById('freq-chart'); if (!chartDom) return; if (!chartRef.current) { chartRef.current = echarts.init(chartDom); } const myChart = chartRef.current; // Handle changeParam (get raw data from existing chart) if (changeParam && myChart) { const option = myChart.getOption() as { series?: Array<{ data?: number[] }> }; if (option.series && option.series.length > 1) { raw = (option.series[1] as any).data; } } // Clear and rebuild chart myChart.clear(); // 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, type: 'line', showSymbol: false, lineStyle: { color: '#ffffff', }, }); // Calculate equalized curve const equalizedRaw = dataSet[1].map((value, index) => value + raw[index]); ops.series.push({ name: 'Equalized', data: equalizedRaw, type: 'line', showSymbol: false, lineStyle: { color: '#23d2fe', width: 6, opacity: 0.7, }, }); } myChart.setOption(ops, true); }; const updateBand = (idx: number, patch: Partial) => { 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) { renderCharts(nextBands, currentRawCurve, false); } }; const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => { let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; setPeqItems((prev) => { const next = prev.map((item, i) => { if (i !== headphoneIdx) return item; const merged = { ...item, ...patch }; updatedPeq = merged; return merged; }); return next; }); schedulePeqSync(updatedPeq, bands, 0); }; return (
{/* ── Header ── */}

HP-EQ

updateSetting({ peqEnable: v ? 1 : 0 })} />
{/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
{/* ── Headphone model selector ── */}
{isHeadphoneMenuOpen && (
{headphoneModels.map((model, idx) => { const active = idx === headphoneIdx; return ( ); })}
)}
{/* ── Frequency response chart ── */} setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))} onBandSelect={setSelectedBand} eqUi={eqUi} /> {/* ── Band grid (2 rows × 5) ── */}
{bands.map((b, i) => ( ))}
{/* ── Band detail card ── */}
{/* Filter type selector */}
{peqCardLabels.filters?.label ?? "滤波器"}
{isFilterMenuOpen && (
{FILTER_TYPES.map((type) => { const active = normalizeFilterType(band.type) === type; return ( ); })}
)}
{/* FREQ */}
FREQ {band.freq >= 1000 ? `${(band.freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${band.freq} Hz`}
updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} />
{/* GAIN */}
GAIN {band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB
updateBand(selectedBand, { gain: v })} />
{/* Q 值 */}
{peqCardLabels.q ?? "Q值"} {band.q.toFixed(2)}
updateBand(selectedBand, { q: v })} />
{/* ── Total gain card ── */}
{peqCardLabels.preamp} {preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)} dB
AUTO updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })} />
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })} />
{isAddPresetDialogOpen && (

新增预设

)} {isBatchEditDialogOpen && (
e.stopPropagation()} >

{eqUi.batchEditTitle}

{eqUi.batchEditHint}