/* ============================================================ 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: below lg breakpoint 2×5 pills; lg+ single row ×10 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 BottomNav from "@/components/BottomNav"; import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; import { decodeCustomBase64, fetchLuxsinAudioBrands, fetchLuxsinAudioCurve, fetchLuxsinAudioModelList, fetchLuxsinAudioModels, type LuxsinAudioBrand, type LuxsinAudioModelListItem, type LuxsinAudioModel, type PeqFilter, type PeqApplyPayload, type PeqChangePayload, type PeqPresetBody, type PeqState, } from "@/lib/luxsinApi"; import * as echarts from "echarts"; import { getSectionsMatrix, visualizeResponse, getChartOps, getFilterType, getFilterShortName } 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"; import { FreqChart } from "./eq/components/FreqChart"; import { CyanSlider, IOSToggle } from "./eq/components/EqPrimitives"; import { BAND_FREQ_MAX, BAND_FREQ_MIN, BAND_GAIN_MAX, BAND_GAIN_MIN, BAND_PARAM_VALUE_BOX_STYLE, BAND_Q_MAX, BAND_Q_MIN, CATALOG_TARGETS, DEFAULT_BANDS, FILTER_TYPES, FLAT_PRESET_FILTERS, } from "./eq/eqConstants"; import { eqInterp, formatBandFreqDisplay, formatBandGainDisplay, formatBandParamForInput, formatBandQDisplay, freqLabel, normalizeFilterType, normalizeBandFreqHz, parseBandParamInput, } from "./eq/eqFormatters"; import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, getUniquePresetName } from "./eq/peqMappers"; import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types"; /* ── Main Component ── */ export default function EQPage() { const [, setLocation] = useLocation(); const { deviceState, peqState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply, syncPeqCatalog, } = useDevice(); const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; const peqOn = (deviceState?.peqEnable ?? 0) === 1; const eqOn = peqOn && !bypassOn; 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 [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false); const [saveBPresetName, setSaveBPresetName] = useState(""); const [bandParamDialog, setBandParamDialog] = useState(null); const [bandParamInput, setBandParamInput] = useState(""); /** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */ const [bandSliderPreview, setBandSliderPreview] = useState<{ freq?: number; gain?: number; q?: number; } | null>(null); 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 lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); const skipPeqAutoSyncRef = useRef(false); /** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */ const skipBandsSyncFromAbToggleRef = useRef(false); /** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */ const peqPresetCacheRef = useRef>({}); 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 currentPeqName = currentPeq?.name ?? ""; const currentPeqPreamp = currentPeq?.preamp ?? 0; const currentPeqAutoPre = currentPeq?.autoPre ?? 0; const preampValue = Number(currentPeqPreamp); const autoPreOn = currentPeqAutoPre === 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 rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000; const freq = normalizeBandFreqHz(rawFreq) ?? 1000; return { freq: Math.round(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 filters = parsed.filters.map(bandToPeqFilter); const merged = { ...item, autoPre: 0, preamp: parsed.preamp, filters, }; rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); updatedPeq = merged; return merged; }) ); setBands(parsed.filters); schedulePeqSync(updatedPeq, parsed.filters, abMode, 0); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); } catch (error) { toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError); } }; const rememberPeqPresetCache = useCallback( (presetName: string | undefined, patch: PeqPresetLocalCache) => { if (!presetName) return; const prev = peqPresetCacheRef.current[presetName] ?? {}; const next: PeqPresetLocalCache = { ...prev, ...patch }; if (next.filters?.length === 0) delete next.filters; peqPresetCacheRef.current[presetName] = next; }, [], ); const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => { return items.map((item) => { const cached = peqPresetCacheRef.current[item.name]; if (!cached) return item; return { ...item, ...(cached.filters?.length ? { filters: cached.filters } : {}), ...(cached.preamp !== undefined ? { preamp: cached.preamp } : {}), ...(cached.autoPre !== undefined ? { autoPre: cached.autoPre } : {}), }; }); }, []); const syncPeqCatalogFromState = useCallback( ( remote: { peq?: PeqCatalogItem[]; peqSelect?: number }, devicePeqSelect?: number, ) => { const items = mergeRemotePeqCatalog(remote.peq ?? []); setPeqItems(items as typeof peqItems); setHeadphoneModels(items.map((item) => item.name)); if (items.length === 0) { setHeadphoneIdx(0); return; } const nextIdx = Math.min( Math.max(devicePeqSelect ?? remote.peqSelect ?? 0, 0), items.length - 1, ); setHeadphoneIdx(nextIdx); }, [mergeRemotePeqCatalog], ); /** 将当前 A 曲线与总增益写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */ const persistPresetEditsToPeqItem = useCallback( ( catalogIdx: number, filtersSource: typeof DEFAULT_BANDS, meta?: { preamp?: number; autoPre?: number }, ) => { const filters = filtersSource.map(bandToPeqFilter); setPeqItems((prev) => prev.map((item, i) => { if (i !== catalogIdx) return item; const merged = { ...item, filters, ...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}), ...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}), }; rememberPeqPresetCache(item.name, { filters, preamp: merged.preamp, autoPre: merged.autoPre, }); return merged; }), ); }, [rememberPeqPresetCache], ); const handleSelectHeadphone = useCallback( (idx: number) => { if (idx === headphoneIdx) { setIsHeadphoneMenuOpen(false); return; } const leaving = peqItems[headphoneIdx]; const leavingFilters = bandsByMode.A.map(bandToPeqFilter); rememberPeqPresetCache(leaving?.name, { filters: leavingFilters, preamp: leaving?.preamp, autoPre: leaving?.autoPre, }); persistPresetEditsToPeqItem(headphoneIdx, bandsByMode.A, { preamp: leaving?.preamp, autoPre: leaving?.autoPre, }); if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); peqSyncTimerRef.current = null; } setHeadphoneIdx(idx); updateSetting({ peqSelect: idx }); setIsHeadphoneMenuOpen(false); }, [ bandsByMode.A, headphoneIdx, peqItems, persistPresetEditsToPeqItem, rememberPeqPresetCache, updateSetting, ], ); const applyPeqStateToUI = ( remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number; filters?: PeqFilter[] }, ) => { if (remote.peq !== undefined) { syncPeqCatalog({ filters: remote.filters ?? peqState?.filters ?? [], peq: remote.peq, peqSelect: remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect, }); } lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, deviceState?.peqSelect); syncPeqCatalogFromState(remote, deviceState?.peqSelect); const items = mergeRemotePeqCatalog((remote.peq ?? []) as PeqCatalogItem[]); if (items.length === 0) { setBandsForBothModes(DEFAULT_BANDS); setSelectedBandByMode({ A: 0, B: 0 }); return; } const nextIdx = Math.min( Math.max(deviceState?.peqSelect ?? remote.peqSelect ?? 0, 0), items.length - 1, ); 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); delete peqPresetCacheRef.current[target.name]; applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } delete peqPresetCacheRef.current[target.name]; await api.removePeq([target.name]); const latest = await api.getPeqState(); applyPeqStateToUI(latest); 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 openSaveBDialog = () => { const currentName = peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset"; const defaultName = getUniquePresetName(`${currentName}_B`, headphoneModels); setSaveBPresetName(defaultName); setIsSaveBDialogOpen(true); }; const handleSaveAddPreset = async () => { const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim(); if (!nextName) { toast.error(eqUi.addPresetNameEmpty); return; } if (headphoneModels.includes(nextName)) { toast.error(eqUi.addPresetNameExists); 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(eqUi.toastAddPresetOk); } catch { toast.error(eqUi.toastAddPresetFail); } }; const handleSaveBAsPreset = async () => { const nextName = saveBPresetName.trim(); if (!nextName) { toast.error(eqUi.addPresetNameEmpty); return; } if (headphoneModels.includes(nextName)) { toast.error(eqUi.addPresetNameExists); return; } const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { peqChange: { name: nextName, filters, autoPre: currentPeq?.autoPre ?? 0, preamp: currentPeq?.preamp ?? 0, canDel: currentPeq?.canDel ?? 1, }, }; try { await upgradePeqChange(payload); 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 { setHeadphoneModels((prev) => [...prev, nextName]); setPeqItems((prev) => [ ...prev, { name: nextName, filters, autoPre: currentPeq?.autoPre ?? 0, preamp: currentPeq?.preamp ?? 0, canDel: currentPeq?.canDel ?? 1, }, ]); setHeadphoneIdx(headphoneModels.length); } setIsSaveBDialogOpen(false); toast.success(eqUi.toastSaveBOk); } catch { toast.error(eqUi.toastSaveBFail); } }; // msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项 useEffect(() => { if (!peqState?.peq) return; const key = buildPeqCatalogSyncKey(peqState, deviceState?.peqSelect); if (!key || key === lastPeqCatalogSyncKeyRef.current) return; lastPeqCatalogSyncKeyRef.current = key; syncPeqCatalogFromState(peqState, deviceState?.peqSelect); }, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]); // 切换耳机时默认回到 A 组对比 useEffect(() => { setAbMode("A"); }, [headphoneIdx]); // 获取耳机原始曲线 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 loadedPeq = await api.getPeqState(); if (loadedPeq.peq && loadedPeq.peq.length > 0) { syncPeqCatalog(loadedPeq); setHeadphoneModels(loadedPeq.peq.map((h) => h.name)); setPeqItems(loadedPeq.peq); const nextIdx = Math.min( Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), loadedPeq.peq.length - 1, ); setHeadphoneIdx(nextIdx); lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect); // 初始化当前选中的耳机曲线 const currentPeq = loadedPeq.peq[nextIdx]; 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,避免编辑时写回 peqItems 把 B 试听曲线重置 */ const headphoneSwitchKey = useMemo(() => { const peq = peqItems[headphoneIdx]; if (!peq) return String(headphoneIdx); return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001"); }, [headphoneIdx, peqItems]); // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) 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; }; }, [headphoneSwitchKey, loadRawCurveForPeq]); type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode"; const schedulePeqSync = useCallback( ( peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined, filtersSource: typeof DEFAULT_BANDS, mode: "A" | "B", delay = 320, topLevel: PeqSubmitTopLevel = "byMode", catalogIdx = headphoneIdx, ) => { if (!allowPeqRemoteSyncRef.current) return; if (!isDemoMode && !api) return; if (!peq?.name) return; if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } const idxAtSchedule = catalogIdx; peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); const body: PeqPresetBody = { name: peq.name, filters, autoPre: peq.autoPre ?? 0, preamp: peq.preamp ?? 0, canDel: peq.canDel ?? 1, }; const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, { preamp: peq.preamp, autoPre: peq.autoPre, }); } const submit = usePeqChange ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); submit().catch(() => { toast.error("EQ 保存失败"); }); }, delay); }, [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], ); /** 点击 A/B 切换试听:顶层固定 peqApply(A/B 数据结构相同,仅 filters 不同) */ const handleAbToggle = useCallback( (m: "A" | "B") => { if (m === abMode) return; skipBandsSyncFromAbToggleRef.current = true; setAbMode(m); schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply"); }, [abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync], ); const handleCopyAndSwitchTo = useCallback( (to: "A" | "B") => { const from = abMode; const copied = cloneBands(bandsByMode[from]); copyModeParams(from, to); skipBandsSyncFromAbToggleRef.current = true; setAbMode(to); schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply"); }, [abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync], ); // 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发) useEffect(() => { if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { skipPeqAutoSyncRef.current = false; return; } if (skipBandsSyncFromAbToggleRef.current) { skipBandsSyncFromAbToggleRef.current = false; return; } schedulePeqSync(peqItems[headphoneIdx], bands, abMode, 320, "byMode", headphoneIdx); return () => { if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } }; }, [bands, abMode, headphoneIdx, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]); 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] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; useEffect(() => { setBandSliderPreview(null); }, [selectedBand, abMode, headphoneSwitchKey]); // 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 handleApplyB = useCallback(async () => { const peq = peqItems[headphoneIdx]; if (!peq?.name) { toast.error(eqUi.toastApplyBFail); return; } const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { peqChange: { name: peq.name, filters, autoPre: peq.autoPre ?? 0, preamp: peq.preamp ?? 0, canDel: peq.canDel ?? 1, }, }; skipPeqAutoSyncRef.current = true; setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); rememberPeqPresetCache(peq.name, { filters, preamp: peq.preamp, autoPre: peq.autoPre, }); setPeqItems((prev) => prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })), ); setAbMode("A"); if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); peqSyncTimerRef.current = null; } try { await upgradePeqChange(payload); if (peqItems.length > 0 && headphoneIdx < peqItems.length) { renderCharts(bBands, currentRawCurve, false); } toast.success(eqUi.toastApplyBSuccess); } catch { skipPeqAutoSyncRef.current = false; toast.error(eqUi.toastApplyBFail); } }, [ bandsByMode.B, currentRawCurve, eqUi.toastApplyBFail, eqUi.toastApplyBSuccess, headphoneIdx, peqItems, upgradePeqChange, ]); const updateBand = useCallback( (idx: number, patch: Partial) => { setBands((prevBands) => prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b))); }, [setBands], ); const bandParamDialogMeta = useMemo(() => { if (!bandParamDialog) return null; const rangeMessages = { invalid: eqUi.paramInputInvalid ?? "请输入有效数值", outOfRange: "", }; switch (bandParamDialog) { case "freq": return { title: eqUi.paramInputTitleFreq ?? "设置频率", hint: eqUi.paramInputHintFreq ?? "范围:20 – 20000 Hz,可使用 k 表示 kHz(如 9.5k)", placeholder: "9500", inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, outOfRange: eqInterp(eqUi.paramInputOutOfRangeFreq, { min: String(BAND_FREQ_MIN), max: String(BAND_FREQ_MAX), }) || `频率需在 ${BAND_FREQ_MIN} – ${BAND_FREQ_MAX} Hz 之间`, }, }; case "gain": return { title: eqUi.paramInputTitleGain ?? "设置增益", hint: eqUi.paramInputHintGain ?? "范围:-15.0 – +15.0 dB", placeholder: "0.0", inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, outOfRange: eqInterp(eqUi.paramInputOutOfRangeGain, { min: BAND_GAIN_MIN.toFixed(1), max: BAND_GAIN_MAX.toFixed(1), }) || `增益需在 ${BAND_GAIN_MIN} – ${BAND_GAIN_MAX} dB 之间`, }, }; case "q": return { title: eqUi.paramInputTitleQ ?? "设置 Q 值", hint: eqUi.paramInputHintQ ?? "范围:0.10 – 10.00", placeholder: "1.41", inputMode: "decimal" as const, rangeMessages: { ...rangeMessages, outOfRange: eqInterp(eqUi.paramInputOutOfRangeQ, { min: BAND_Q_MIN.toFixed(2), max: BAND_Q_MAX.toFixed(2), }) || `Q 值需在 ${BAND_Q_MIN} – ${BAND_Q_MAX} 之间`, }, }; } }, [bandParamDialog, eqUi]); const openBandParamDialog = (kind: BandParamKind) => { setBandParamDialog(kind); setBandParamInput(formatBandParamForInput(kind, band)); }; const closeBandParamDialog = () => { setBandParamDialog(null); setBandParamInput(""); }; const applyBandParamDialog = () => { if (!bandParamDialog || !bandParamDialogMeta) return; const parsed = parseBandParamInput(bandParamDialog, bandParamInput, bandParamDialogMeta.rangeMessages); if (!parsed.ok) { toast.error(parsed.message); return; } if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value }); else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value }); else updateBand(selectedBand, { q: parsed.value }); closeBandParamDialog(); }; 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; rememberPeqPresetCache(item.name, patch); return merged; }); return next; }); schedulePeqSync(updatedPeq, bands, abMode, 0); }; return (
{/* ── Header ── */}

HP-EQ

{ if (v) { void updateSetting( bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }, ); } else { void updateSetting({ peqEnable: 0 }); } }} />
{ const bypass = (deviceState?.dsp_enable ?? 0) === 0; return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); }} > {/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
{/* ── Headphone model selector ── */}
{isHeadphoneMenuOpen && (
{headphoneModels.map((model, idx) => { const active = idx === headphoneIdx; return ( ); })}
)}
{/* ── Frequency response chart ── */} void handleApplyB()} onSaveB={openSaveBDialog} onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))} onBandSelect={setSelectedBand} eqUi={eqUi} /> {/* ── Band grid: 2×5 below lg, 1×10 on lg+ (typical desktop) ── */}
{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
{ const freq = Math.round(Math.pow(10, v)); setBandSliderPreview((prev) => ({ ...prev, freq })); updateBand(selectedBand, { freq }); }} />
{/* GAIN */}
GAIN
{ setBandSliderPreview((prev) => ({ ...prev, gain: v })); updateBand(selectedBand, { gain: v }); }} />
{/* Q 值 */}
{peqCardLabels.q ?? "Q值"}
{ setBandSliderPreview((prev) => ({ ...prev, q: v })); 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)) })} />
{bandParamDialog && bandParamDialogMeta && (
e.stopPropagation()} >

{bandParamDialogMeta.title}

{bandParamDialogMeta.hint}

setBandParamInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyBandParamDialog(); } }} inputMode={bandParamDialogMeta.inputMode} placeholder={bandParamDialogMeta.placeholder} className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" autoFocus />
)} {isSaveBDialogOpen && (
e.stopPropagation()} >

{eqUi.saveBTitle}

setSaveBPresetName(e.target.value)} className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15" autoFocus />
)} {isAddPresetDialogOpen && (
e.stopPropagation()} >

{eqUi.addPresetTitle}

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

{eqUi.batchEditTitle}

{eqUi.batchEditHint}