diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index ba40dad..20eaa5a 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -56,13 +56,16 @@ import { 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 } from "./eq/types"; +import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types"; /* ── Main Component ── */ export default function EQPage() { @@ -126,6 +129,12 @@ export default function EQPage() { 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"; @@ -155,8 +164,8 @@ export default function EQPage() { const skipPeqAutoSyncRef = useRef(false); /** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */ const skipBandsSyncFromAbToggleRef = useRef(false); - /** 本地编辑过的预设 filters(按名称);syncPeq 拉取不会覆盖 */ - const peqFiltersCacheRef = useRef>({}); + /** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */ + const peqPresetCacheRef = useRef>({}); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; @@ -325,9 +334,10 @@ export default function EQPage() { filters = peq.filters; } return filters.map((f: any) => { - const freq = f.fc || f.freq || f.frequency || 1000; + const rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000; + const freq = normalizeBandFreqHz(rawFreq) ?? 1000; return { - freq: Number(freq), + freq: Math.round(freq), gain: Number(f.gain || 0), q: Number(f.q || 1), type: normalizeFilterType(f.type), @@ -445,7 +455,7 @@ export default function EQPage() { preamp: parsed.preamp, filters, }; - rememberPeqFiltersForPreset(item.name, filters); + rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); updatedPeq = merged; return merged; }) @@ -460,18 +470,27 @@ export default function EQPage() { } }; - const rememberPeqFiltersForPreset = useCallback( - (presetName: string | undefined, filters: PeqFilter[]) => { - if (!presetName || filters.length === 0) return; - peqFiltersCacheRef.current[presetName] = filters; + 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 = peqFiltersCacheRef.current[item.name]; - return cached?.length ? { ...item, filters: cached } : 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 } : {}), + }; }); }, []); @@ -496,19 +515,33 @@ export default function EQPage() { [mergeRemotePeqCatalog], ); - /** 将当前 A 曲线写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */ - const persistFiltersToPeqItem = useCallback( - (catalogIdx: number, filtersSource: typeof DEFAULT_BANDS) => { + /** 将当前 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; - rememberPeqFiltersForPreset(item.name, filters); - return { ...item, filters }; + 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; }), ); }, - [rememberPeqFiltersForPreset], + [rememberPeqPresetCache], ); const handleSelectHeadphone = useCallback( @@ -519,8 +552,15 @@ export default function EQPage() { } const leaving = peqItems[headphoneIdx]; const leavingFilters = bandsByMode.A.map(bandToPeqFilter); - rememberPeqFiltersForPreset(leaving?.name, leavingFilters); - persistFiltersToPeqItem(headphoneIdx, bandsByMode.A); + 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; @@ -533,8 +573,8 @@ export default function EQPage() { bandsByMode.A, headphoneIdx, peqItems, - persistFiltersToPeqItem, - rememberPeqFiltersForPreset, + persistPresetEditsToPeqItem, + rememberPeqPresetCache, updateSetting, ], ); @@ -580,13 +620,13 @@ export default function EQPage() { try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); - delete peqFiltersCacheRef.current[target.name]; + delete peqPresetCacheRef.current[target.name]; applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } - delete peqFiltersCacheRef.current[target.name]; + delete peqPresetCacheRef.current[target.name]; await api.removePeq([target.name]); const latest = await api.getPeqState(); applyPeqStateToUI(latest); @@ -973,7 +1013,10 @@ export default function EQPage() { const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { - persistFiltersToPeqItem(idxAtSchedule, filtersSource); + persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, { + preamp: peq.preamp, + autoPre: peq.autoPre, + }); } const submit = usePeqChange ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) @@ -983,7 +1026,7 @@ export default function EQPage() { }); }, delay); }, - [api, headphoneIdx, isDemoMode, persistFiltersToPeqItem, upgradePeqApply, upgradePeqChange], + [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], ); /** 点击 A/B 切换试听:顶层固定 peqApply(A/B 数据结构相同,仅 filters 不同) */ @@ -1045,7 +1088,11 @@ export default function EQPage() { }, []); const chartRef = useRef(null); - const band = bands[selectedBand]; + 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) => { @@ -1146,7 +1193,11 @@ export default function EQPage() { skipPeqAutoSyncRef.current = true; setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); - rememberPeqFiltersForPreset(peq.name, filters); + rememberPeqPresetCache(peq.name, { + filters, + preamp: peq.preamp, + autoPre: peq.autoPre, + }); setPeqItems((prev) => prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })), ); @@ -1177,10 +1228,12 @@ export default function EQPage() { upgradePeqChange, ]); - const updateBand = (idx: number, patch: Partial) => { - const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b)); - setBands(nextBands); - }; + 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; @@ -1264,6 +1317,7 @@ export default function EQPage() { if (i !== headphoneIdx) return item; const merged = { ...item, ...patch }; updatedPeq = merged; + rememberPeqPresetCache(item.name, patch); return merged; }); return next; @@ -1436,7 +1490,12 @@ export default function EQPage() { background: "rgba(44,44,46,0.65)", }} onClick={() => setSelectedBand(i)}> - {freqLabel(b.freq)} + + {freqLabel(b.freq)} + {getFilterShortName(b.type)} ))} @@ -1508,18 +1567,23 @@ export default function EQPage() { updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} + onChange={(v) => { + const freq = Math.round(Math.pow(10, v)); + setBandSliderPreview((prev) => ({ ...prev, freq })); + updateBand(selectedBand, { freq }); + }} /> @@ -1533,15 +1597,18 @@ export default function EQPage() { className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform" style={BAND_PARAM_VALUE_BOX_STYLE} > - {band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB + {formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)} updateBand(selectedBand, { gain: v })} + onChange={(v) => { + setBandSliderPreview((prev) => ({ ...prev, gain: v })); + updateBand(selectedBand, { gain: v }); + }} /> @@ -1555,15 +1622,18 @@ export default function EQPage() { className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform" style={BAND_PARAM_VALUE_BOX_STYLE} > - {band.q.toFixed(2)} + {formatBandQDisplay(bandSliderPreview?.q ?? band.q)} updateBand(selectedBand, { q: v })} + onChange={(v) => { + setBandSliderPreview((prev) => ({ ...prev, q: v })); + updateBand(selectedBand, { q: v }); + }} /> diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 5b64a88..f6c3750 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -89,7 +89,7 @@ function vuHeightsForDance(elapsedMs: number): number[] { const kick = Math.sin(beatPhase * Math.PI); return Array.from({ length: VU_BAR_COUNT }, (_, i) => { - const dist = Math.abs(i - lead); + const dist = Math.abs(i - lead); let peak = 0.06; if (dist === 0) peak = 1; else if (dist === 1) peak = 0.82; diff --git a/client/src/pages/eq/components/EqPrimitives.tsx b/client/src/pages/eq/components/EqPrimitives.tsx index b101d95..729ca92 100644 --- a/client/src/pages/eq/components/EqPrimitives.tsx +++ b/client/src/pages/eq/components/EqPrimitives.tsx @@ -44,6 +44,7 @@ export function CyanSlider({ className="cyan-slider relative z-10" disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} + onInput={(e) => onChange(Number(e.currentTarget.value))} /> ); diff --git a/client/src/pages/eq/eqFormatters.ts b/client/src/pages/eq/eqFormatters.ts index fc4c41c..d1dce63 100644 --- a/client/src/pages/eq/eqFormatters.ts +++ b/client/src/pages/eq/eqFormatters.ts @@ -18,10 +18,40 @@ export function eqInterp(template: string | undefined, vars: Record= 1000 - ? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` - : `${freq} Hz`; +/** 将设备/表单中的频率统一为 Hz 数值;无法解析时返回 null */ +export function normalizeBandFreqHz(freq: unknown): number | null { + if (typeof freq === "number" && Number.isFinite(freq)) return freq; + if (typeof freq === "string") { + const trimmed = freq.trim().replace(/,/g, ""); + if (!trimmed) return null; + let s = trimmed.toLowerCase().replace(/hz$/, ""); + const kHz = /k(hz)?$/.test(s); + if (kHz) s = s.replace(/k(hz)?$/, ""); + const num = Number.parseFloat(s); + return Number.isFinite(num) ? (kHz ? num * 1000 : num) : null; + } + return null; +} + +export function formatBandGainDisplay(gain: unknown) { + const db = typeof gain === "number" && Number.isFinite(gain) ? gain : Number(gain); + if (!Number.isFinite(db)) return "0.0 dB"; + const text = db.toFixed(1); + return `${db >= 0 ? `+${text}` : text} dB`; +} + +export function formatBandQDisplay(q: unknown) { + const n = typeof q === "number" && Number.isFinite(q) ? q : Number(q); + return Number.isFinite(n) ? n.toFixed(2) : "—"; +} + +export function formatBandFreqDisplay(freq: unknown) { + const hz = normalizeBandFreqHz(freq); + if (hz === null) return "—"; + if (hz >= 1000) { + return `${(hz / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz`; + } + return `${Math.round(hz)} Hz`; } export function formatBandParamForInput( @@ -106,6 +136,14 @@ export function normalizeFilterType(type: string | number | undefined): string { return getFilterShortName(type); } -export function freqLabel(f: number) { - return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; +/** 10 段滤波器按钮上的紧凑频率标签(始终为数字,避免被翻译插件改成英文单词) */ +export function freqLabel(freq: unknown) { + const hz = normalizeBandFreqHz(freq); + if (hz === null) return "—"; + if (hz >= 1000) { + const k = hz / 1000; + const text = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, ""); + return `${text}K`; + } + return String(Math.round(hz)); } diff --git a/client/src/pages/eq/types.ts b/client/src/pages/eq/types.ts index a7288ef..a9500c7 100644 --- a/client/src/pages/eq/types.ts +++ b/client/src/pages/eq/types.ts @@ -1,4 +1,4 @@ -import type { PeqState } from "@/lib/luxsinApi"; +import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; import type localeZh from "@/locales/data-zh.json"; export type PeqEqUi = NonNullable["eqUi"]>; @@ -15,6 +15,13 @@ export type EqBand = { export type PeqCatalogItem = NonNullable[number]; +/** 本地编辑缓存(按预设名称);syncPeq 拉取时优先于设备 catalog */ +export type PeqPresetLocalCache = { + filters?: PeqFilter[]; + preamp?: number; + autoPre?: number; +}; + export type CatalogTarget = { name: string; bassBoost: { fc: number; q: number; gain: number };