From 3f9b2f0e7be90a0eb795366b9e590c862e92c3fe Mon Sep 17 00:00:00 2001 From: yangy Date: Thu, 28 May 2026 16:26:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(eq):=20=E9=87=8D=E6=9E=84EQ=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8F=8A=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81=E4=BB=A5?= =?UTF-8?q?=E4=BC=98=E5=8C=96PEQ=E7=AE=A1=E7=90=86=E4=B8=8E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 禁用Vite配置中的manus debug collector插件,减少无用请求 - 对DeviceContext添加syncPeqCatalog方法用于同步PEQ状态 - 重构luxsinApi.ts中PEQ请求相关代码,统一POST请求封装,增强错误处理 - 将EQPage相关常量、工具和组件抽取至独立模块,简化主文件结构 - 使用缓存机制记忆预设滤波器列表,减少不必要的重复计算 - 优化PEQ状态同步逻辑,支持按模式(A/B)分别保存与应用滤波器 - 实现选择耳机型号时自动保存并更新对应滤波器缓存和显示 - 修改A/B切换逻辑,使用防抖同步和固定的peqApply调用 - 为音量滑块添加thumb-only滑动样式及拖拽区域限制,提升交互体验 - Home页面ListRow组件支持显示自定义缩略图 - 增加eq/constants.ts定义滤波器类型、参数范围和样式,统一管理配置 - 解决多处EQ页面组件交互和状态切换的潜在同步问题,提高代码维护性和可读性 --- client/src/contexts/DeviceContext.tsx | 8 +- client/src/index.css | 14 + client/src/lib/luxsinApi.ts | 37 +- client/src/pages/EQPage.tsx | 503 ++++++++---------- client/src/pages/Home.tsx | 53 +- client/src/pages/eq/components/CyanSlider.tsx | 46 ++ client/src/pages/eq/components/IOSToggle.tsx | 20 + client/src/pages/eq/constants.ts | 147 +++++ client/src/pages/eq/utils.ts | 176 ++++++ vite.config.ts | 2 +- 10 files changed, 700 insertions(+), 306 deletions(-) create mode 100644 client/src/pages/eq/components/CyanSlider.tsx create mode 100644 client/src/pages/eq/components/IOSToggle.tsx create mode 100644 client/src/pages/eq/constants.ts create mode 100644 client/src/pages/eq/utils.ts diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 9e0daac..35885cd 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -31,6 +31,8 @@ interface DeviceContextType { updatePeq: (filters: PeqFilter[]) => Promise; upgradePeqChange: (payload: PeqChangePayload) => Promise; upgradePeqApply: (payload: PeqApplyPayload) => Promise; + /** Align global peqState with device syncPeq (e.g. after EQ catalog add/delete). */ + syncPeqCatalog: (state: PeqState) => void; // Optimistic state updaters setVolume: (v: number) => void; setInput: (v: number) => void; @@ -72,6 +74,10 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { setPeqState(peq); }, []); + const syncPeqCatalog = useCallback((state: PeqState) => { + setPeqState(state); + }, []); + const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => { const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode; const targetIp = (connectIp ?? ip).trim(); @@ -263,7 +269,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { isConnected, isConnecting, isDemoMode, setDemoMode, deviceState, peqState, lastUpdated, error, connect, disconnect, refresh, api, - updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, + updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog, setVolume, setInput, setOutput, setBalance, }}> {children} diff --git a/client/src/index.css b/client/src/index.css index d35637f..1cf594d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -315,6 +315,20 @@ cursor: pointer; } + /* Home volume: track clicks disabled; only thumb is draggable */ + .cyan-slider-thumb-only { + cursor: default; + } + .cyan-slider-thumb-only::-webkit-slider-thumb { + cursor: grab; + } + .cyan-slider-thumb-only::-webkit-slider-thumb:active { + cursor: grabbing; + } + .cyan-slider-thumb-only::-moz-range-thumb { + cursor: grab; + } + /* ── Round icon button (like the 4 quick-action circles on home) ── */ .icon-circle-btn { width: 64px; diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 7779a05..42572a1 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -260,13 +260,7 @@ export class LuxsinAPI { } async setPeqFilters(filters: PeqFilter[]): Promise { - const payload = JSON.stringify({ peqChange: { filters } }); - const encoded = encodeCustomBase64(payload); - await fetch(`${this.baseUrl}/dev/info.cgi`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${encodeURIComponent(encoded)}`, - }); + await this.postPeqJson({ peqChange: { filters } } as PeqChangePayload); } /** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */ @@ -274,30 +268,39 @@ export class LuxsinAPI { await this.postPeqJson(body); } - /** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */ + /** Apply current EQ filters (e.g. A/B comparison curve) without saving preset metadata. */ async upgradePeqApply(body: PeqApplyPayload): Promise { await this.postPeqJson(body); } + /** POST `json=` — matches legacy axios `upgradePeq`. */ private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { - const payload = JSON.stringify(body); - const encoded = encodeCustomBase64(payload); - await fetch(`${this.baseUrl}/dev/info.cgi`, { + const encoded = encodeCustomBase64(JSON.stringify(body)); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${encodeURIComponent(encoded)}`, + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: form.toString(), }); + if (!response.ok) { + throw new Error(`PEQ request failed: ${response.status}`); + } } /** Remove one or more headphone PEQ profiles. */ async removePeq(names: string[]): Promise { - const payload = JSON.stringify({ peqRemove: names }); - const encoded = encodeCustomBase64(payload); - await fetch(`${this.baseUrl}/dev/info.cgi`, { + const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names })); + const form = new URLSearchParams(); + form.set("json", encoded); + const response = await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, - body: `json=${encodeURIComponent(encoded)}`, + body: form.toString(), }); + if (!response.ok) { + throw new Error(`PEQ remove failed: ${response.status}`); + } } setVolume(volume: number) { return this.setSetting({ volume }); } diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index ee38fa1..9b03c7a 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -47,140 +47,39 @@ import { import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; +import { + FILTER_TYPES, + BAND_FREQ_MIN, + BAND_FREQ_MAX, + BAND_GAIN_MIN, + BAND_GAIN_MAX, + BAND_Q_MIN, + BAND_Q_MAX, + BAND_PARAM_VALUE_BOX_STYLE, + DEFAULT_BANDS, + FLAT_PRESET_FILTERS, + CATALOG_TARGETS, + type BandParamKind, + type PeqEqUi, + type PeqCatalogItem, + type CatalogTarget, +} from "./eq/constants"; +import { + eqInterp, + formatBandFreqDisplay, + formatBandParamForInput, + parseBandParamInput, + normalizeFilterType, + cloneBands, + freqLabel, + buildPeqCatalogSyncKey, + getUniquePresetName, + bandToPeqFilter, +} from "./eq/utils"; +import { IOSToggle } from "./eq/components/IOSToggle"; +import { CyanSlider } from "./eq/components/CyanSlider"; -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"]; -const BAND_FREQ_MIN = 20; -const BAND_FREQ_MAX = 20000; -const BAND_GAIN_MIN = -15; -const BAND_GAIN_MAX = 15; -const BAND_Q_MIN = 0.1; -const BAND_Q_MAX = 10; - -type BandParamKind = "freq" | "gain" | "q"; - -const BAND_PARAM_VALUE_BOX_STYLE: React.CSSProperties = { - background: "rgba(44,44,46,0.9)", - border: "1px solid rgba(255,255,255,0.08)", -}; - -function formatBandFreqDisplay(freq: number) { - return freq >= 1000 - ? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` - : `${freq} Hz`; -} - -function formatBandParamForInput(kind: BandParamKind, band: { freq: number; gain: number; q: number }) { - switch (kind) { - case "freq": - return String(band.freq); - case "gain": - return band.gain.toFixed(1); - case "q": - return band.q.toFixed(2); - } -} - -function parseBandParamInput( - kind: BandParamKind, - raw: string, - messages: { invalid: string; outOfRange: string }, -): { ok: true; value: number } | { ok: false; message: string } { - const trimmed = raw.trim(); - if (!trimmed) return { ok: false, message: messages.invalid }; - - if (kind === "freq") { - let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, ""); - const kHz = /k(hz)?$/.test(s); - if (kHz) s = s.replace(/k(hz)?$/, ""); - const num = Number.parseFloat(s); - if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; - const hz = Math.round(kHz ? num * 1000 : num); - if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) { - return { ok: false, message: messages.outOfRange }; - } - return { ok: true, value: hz }; - } - - if (kind === "gain") { - const s = trimmed.replace(/\s*dB\s*$/i, "").trim(); - const num = Number.parseFloat(s); - if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; - const gain = Number(num.toFixed(1)); - if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) { - return { ok: false, message: messages.outOfRange }; - } - return { ok: true, value: gain }; - } - - const num = Number.parseFloat(trimmed); - if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; - const q = Number(num.toFixed(2)); - if (q < BAND_Q_MIN || q > BAND_Q_MAX) { - return { ok: false, message: messages.outOfRange }; - } - return { ok: true, value: q }; -} - -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({ @@ -189,7 +88,7 @@ function FreqChart({ selectedBand, abMode, onAbToggle, - onCopyMode, + onCopyAndSwitchTo, onApplyB, onSaveB, onBandDrag, @@ -201,7 +100,7 @@ function FreqChart({ selectedBand: number; abMode: "A" | "B"; onAbToggle: (m: "A" | "B") => void; - onCopyMode: (from: "A" | "B", to: "A" | "B") => void; + onCopyAndSwitchTo: (to: "A" | "B") => void; onApplyB: () => void; onSaveB: () => void; onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; @@ -421,12 +320,11 @@ function FreqChart({ className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors" style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }} onClick={() => { - onCopyMode(abMode, targetMode); + onCopyAndSwitchTo(targetMode); setCurveVisibilityByMode((prev) => ({ ...prev, [targetMode]: { ...prev[abMode] }, })); - onAbToggle(targetMode); toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode })); }}> {copyButtonText} @@ -584,102 +482,22 @@ function FreqChart({ ); } -/* ── 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 }, -]; +/* ── Default bands imported from eq/constants ── */ -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}`; -} - -type PeqCatalogItem = NonNullable[number]; - -function buildPeqCatalogSyncKey( - remote: Pick | null | undefined, - devicePeqSelect?: number, -): string { - const items = remote?.peq; - if (!items?.length) return items ? "empty" : ""; - const select = devicePeqSelect ?? remote?.peqSelect ?? 0; - return `${select}|${items.map((p) => p.name).join("\u0001")}`; -} - -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, peqState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } = - useDevice(); + 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; @@ -755,7 +573,9 @@ export default function EQPage() { const lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); + const peqFiltersCacheRef = useRef>({}); const skipPeqAutoSyncRef = useRef(false); + const skipBandsSyncFromAbToggleRef = useRef(false); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; @@ -1034,18 +854,20 @@ export default function EQPage() { 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: parsed.filters.map(bandToPeqFilter), + filters, }; + rememberPeqFiltersForPreset(item.name, filters); updatedPeq = merged; return merged; }) ); setBands(parsed.filters); - schedulePeqSync(updatedPeq, parsed.filters, 0); + schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); @@ -1054,12 +876,32 @@ export default function EQPage() { } }; + const rememberPeqFiltersForPreset = useCallback( + (presetName: string | undefined, filters: PeqFilter[]) => { + if (!presetName || filters.length === 0) return; + peqFiltersCacheRef.current[presetName] = filters; + }, + [], + ); + + const forgetPeqFiltersForPreset = useCallback((presetName: string | undefined) => { + if (!presetName) return; + delete peqFiltersCacheRef.current[presetName]; + }, []); + + const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => { + return items.map((item) => { + const cached = peqFiltersCacheRef.current[item.name]; + return cached?.length ? { ...item, filters: cached } : item; + }); + }, []); + const syncPeqCatalogFromState = useCallback( ( remote: { peq?: PeqCatalogItem[]; peqSelect?: number }, devicePeqSelect?: number, ) => { - const items = remote.peq ?? []; + const items = mergeRemotePeqCatalog(remote.peq ?? []); setPeqItems(items as typeof peqItems); setHeadphoneModels(items.map((item) => item.name)); if (items.length === 0) { @@ -1072,12 +914,23 @@ export default function EQPage() { ); setHeadphoneIdx(nextIdx); }, - [], + [mergeRemotePeqCatalog], ); const applyPeqStateToUI = ( - remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }, + 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); @@ -1109,14 +962,16 @@ export default function EQPage() { try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); + forgetPeqFiltersForPreset(target.name); applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } + forgetPeqFiltersForPreset(target.name); await api.removePeq([target.name]); const latest = await api.getPeqState(); - applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + applyPeqStateToUI(latest); toast.success(eqUi.toastDeleted); } catch { toast.error(eqUi.toastDeleteFail); @@ -1376,17 +1231,18 @@ export default function EQPage() { try { const loadedPeq = await api.getPeqState(); if (loadedPeq.peq && loadedPeq.peq.length > 0) { - setHeadphoneModels(loadedPeq.peq.map((h) => h.name)); - setPeqItems(loadedPeq.peq); + const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq); + setHeadphoneModels(mergedPeq.map((h) => h.name)); + setPeqItems(mergedPeq); const nextIdx = Math.min( Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), - loadedPeq.peq.length - 1, + mergedPeq.length - 1, ); setHeadphoneIdx(nextIdx); lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect); // 初始化当前选中的耳机曲线 - const currentPeq = loadedPeq.peq[nextIdx]; + const currentPeq = mergedPeq[nextIdx]; if (currentPeq) { // 修复 filters 数据(兼容不同的字段名) const fixedFilters = normalizeFiltersFromPeq(currentPeq); @@ -1434,18 +1290,16 @@ export default function EQPage() { } } loadHeadphones(); - }, [api, isDemoMode, loadRawCurveForPeq]); + }, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]); - /** 仅耳机/型号/filters 变化时变;preamp、autoPre 变化不触发,避免拖总增益时反复请求 modelCurve */ - const modelCurveReloadKey = useMemo(() => { + /** 切换耳机型号时变;不含 filters,避免编辑写回 peqItems 后反复加载 bands 并触发 peqChange 循环 */ + const headphoneSwitchKey = useMemo(() => { const peq = peqItems[headphoneIdx]; - if (!peq) return ""; - const filtersKey = - typeof peq.filters === "string" ? peq.filters : JSON.stringify(peq.filters ?? []); - return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? "", filtersKey].join("\u0001"); + if (!peq) return String(headphoneIdx); + return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001"); }, [headphoneIdx, peqItems]); - // 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写) + // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) useEffect(() => { const peq = peqItems[headphoneIdx]; if (!peq) return; @@ -1467,50 +1321,137 @@ export default function EQPage() { return () => { cancelled = true; }; - }, [modelCurveReloadKey, loadRawCurveForPeq]); + }, [headphoneSwitchKey, 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; + type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode"; - if (peqSyncTimerRef.current !== null) { - window.clearTimeout(peqSyncTimerRef.current); - } - - peqSyncTimerRef.current = window.setTimeout(() => { + const persistFiltersToPeqItem = useCallback( + (catalogIdx: number, filtersSource: typeof DEFAULT_BANDS) => { const filters = filtersSource.map(bandToPeqFilter); - const body: PeqPresetBody = { - name: peq.name, - filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }; - upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload).catch(() => { - toast.error("EQ 保存失败"); - }); - }, delay); - }; + setPeqItems((prev) => + prev.map((item, i) => { + if (i !== catalogIdx) return item; + rememberPeqFiltersForPreset(item.name, filters); + return { ...item, filters }; + }), + ); + }, + [rememberPeqFiltersForPreset], + ); - // A/B 切换与曲线编辑防抖同步 → peqApply(仅试听,不写预设) + const handleSelectHeadphone = useCallback( + (idx: number) => { + if (idx === headphoneIdx) { + setIsHeadphoneMenuOpen(false); + return; + } + const leaving = peqItems[headphoneIdx]; + persistFiltersToPeqItem(headphoneIdx, bandsByMode.A); + if (leaving?.name) { + rememberPeqFiltersForPreset(leaving.name, bandsByMode.A.map(bandToPeqFilter)); + } + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + peqSyncTimerRef.current = null; + } + setHeadphoneIdx(idx); + updateSetting({ peqSelect: idx }); + setIsHeadphoneMenuOpen(false); + }, + [ + bandsByMode.A, + headphoneIdx, + peqItems, + persistFiltersToPeqItem, + rememberPeqFiltersForPreset, + updateSetting, + ], + ); + + 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) { + rememberPeqFiltersForPreset(peq.name, filters); + } + const submit = usePeqChange + ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) + : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); + submit().catch(() => { + toast.error("EQ 保存失败"); + }); + }, delay); + }, + [api, headphoneIdx, isDemoMode, rememberPeqFiltersForPreset, upgradePeqApply, upgradePeqChange], + ); + + /** 点击 A/B 切换试听:固定 peqApply */ + 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 useEffect(() => { if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { skipPeqAutoSyncRef.current = false; return; } - schedulePeqSync(peqItems[headphoneIdx], bands, 320); + 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, peqItems, api, isDemoMode, upgradePeqApply]); + }, [bands, abMode, headphoneIdx, schedulePeqSync]); useEffect(() => { const onPointerDown = (event: PointerEvent) => { @@ -1630,6 +1571,7 @@ export default function EQPage() { skipPeqAutoSyncRef.current = true; setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); + rememberPeqFiltersForPreset(peq.name, filters); setPeqItems((prev) => prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })), ); @@ -1657,6 +1599,7 @@ export default function EQPage() { eqUi.toastApplyBSuccess, headphoneIdx, peqItems, + rememberPeqFiltersForPreset, upgradePeqChange, ]); @@ -1751,7 +1694,7 @@ export default function EQPage() { }); return next; }); - schedulePeqSync(updatedPeq, bands, 0); + schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx); }; return ( @@ -1859,11 +1802,7 @@ export default function EQPage() { }`} style={active ? { background: "#00FFF6" } : undefined} title={model} - onClick={() => { - setHeadphoneIdx(idx); - updateSetting({ peqSelect: idx }); - setIsHeadphoneMenuOpen(false); - }} + onClick={() => handleSelectHeadphone(idx)} > {model} @@ -1898,8 +1837,8 @@ export default function EQPage() { rawCurve={currentRawCurve} selectedBand={selectedBand} abMode={abMode} - onAbToggle={setAbMode} - onCopyMode={copyModeParams} + onAbToggle={handleAbToggle} + onCopyAndSwitchTo={handleCopyAndSwitchTo} onApplyB={() => void handleApplyB()} onSaveB={openSaveBDialog} onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index d2b1890..3c2c5d1 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -137,7 +137,7 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool // ── List row ── function ListRow({ - icon, label, value, onClick, toggle, checked, onToggle, + icon, label, value, onClick, toggle, checked, onToggle, thumbnail, }: { icon: React.ReactNode; label: string; @@ -146,6 +146,7 @@ function ListRow({ toggle?: boolean; checked?: boolean; onToggle?: (v: boolean) => void; + thumbnail?: string; }) { return (
@@ -160,6 +161,13 @@ function ListRow({ {})} /> ) : (
+ {thumbnail && ( + {value + )} {value && {value}}
@@ -217,6 +225,8 @@ export default function Home() { const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); const isDragging = useRef(false); + const volumeSliderRef = useRef(null); + const volumeSliderDragAllowedRef = useRef(false); const knobDraggingRef = useRef(false); const knobActivePointerIdRef = useRef(null); const knobStartAngleRef = useRef(0); @@ -233,6 +243,16 @@ export default function Home() { knobLastVolRef.current = localVol; }, [localVol]); + const isPointerOnVolumeSliderThumb = useCallback((clientX: number) => { + const el = volumeSliderRef.current; + if (!el) return false; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0) return false; + const thumbCenterX = rect.left + (localVol / 200) * rect.width; + const hitRadius = Math.max(28, rect.width * 0.05); + return Math.abs(clientX - thumbCenterX) <= hitRadius; + }, [localVol]); + const handlePowerOff = useCallback(async () => { if (powerActionRef.current) return; powerActionRef.current = true; @@ -662,29 +682,51 @@ export default function Home() { boxShadow: "0 0 8px rgba(0,255,246,0.5)" }} /> { if (volumePassthroughActive) return; + if (!volumeSliderDragAllowedRef.current) { + e.target.value = String(localVol); + return; + } const v = Number(e.target.value); setLocalVol(v); }} - onPointerDown={() => { + onPointerDown={(e) => { if (volumePassthroughActive) return; + const onThumb = isPointerOnVolumeSliderThumb(e.clientX); + volumeSliderDragAllowedRef.current = onThumb; + if (!onThumb) { + e.preventDefault(); + return; + } isDragging.current = true; }} onPointerUp={(e) => { + const allowed = volumeSliderDragAllowedRef.current; + volumeSliderDragAllowedRef.current = false; isDragging.current = false; + if (!allowed || volumePassthroughActive) return; applyVolume(Number(e.currentTarget.value)); }} onPointerCancel={(e) => { + const allowed = volumeSliderDragAllowedRef.current; + volumeSliderDragAllowedRef.current = false; isDragging.current = false; - applyVolume(Number(e.currentTarget.value)); + if (!volumePassthroughActive && allowed) { + applyVolume(Number(e.currentTarget.value)); + } }} onBlur={(e) => { + const allowed = volumeSliderDragAllowedRef.current; + volumeSliderDragAllowedRef.current = false; isDragging.current = false; - applyVolume(Number(e.currentTarget.value)); + if (!volumePassthroughActive && allowed) { + applyVolume(Number(e.currentTarget.value)); + } }} onKeyUp={(e) => { const k = e.key; @@ -916,6 +958,7 @@ export default function Home() { icon={} label={homeText.vu ?? "VU表"} value={vuLabel} + thumbnail={`${import.meta.env.BASE_URL}vu/vu${(ds.vu ?? 0) + 1}.png`} onClick={() => setLocation("/vu")} /> void; + disabled?: boolean; +}) { + const fillPct = Math.max( + 0, + Math.min(100, ((value - min) / (max - min)) * 100), + ); + return ( +
+
+ onChange(Number(e.target.value))} + /> +
+ ); +} diff --git a/client/src/pages/eq/components/IOSToggle.tsx b/client/src/pages/eq/components/IOSToggle.tsx new file mode 100644 index 0000000..b10e075 --- /dev/null +++ b/client/src/pages/eq/components/IOSToggle.tsx @@ -0,0 +1,20 @@ +export function IOSToggle({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} diff --git a/client/src/pages/eq/constants.ts b/client/src/pages/eq/constants.ts new file mode 100644 index 0000000..823a25c --- /dev/null +++ b/client/src/pages/eq/constants.ts @@ -0,0 +1,147 @@ +import type { CSSProperties } from "react"; +import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; +import localeZh from "@/locales/data-zh.json"; + +/* ── Filter types ── */ +export const FILTER_TYPES = [ + "LPF", + "HPF", + "BPF", + "NOTCH", + "PEAK", + "LSHELF", + "HSHELF", + "APF", +]; + +/* ── Band parameter ranges ── */ +export const BAND_FREQ_MIN = 20; +export const BAND_FREQ_MAX = 20000; +export const BAND_GAIN_MIN = -15; +export const BAND_GAIN_MAX = 15; +export const BAND_Q_MIN = 0.1; +export const BAND_Q_MAX = 10; + +export type BandParamKind = "freq" | "gain" | "q"; + +/* ── Band param value box style ── */ +export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = { + background: "rgba(44,44,46,0.9)", + border: "1px solid rgba(255,255,255,0.08)", +}; + +/* ── EQ UI locale type ── */ +export type PeqEqUi = NonNullable< + NonNullable<(typeof localeZh)["peq"]>["eqUi"] +>; + +/* ── Default bands matching reference image ── */ +export 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 }, +]; + +/* ── PEQ catalog item type ── */ +export type PeqCatalogItem = NonNullable[number]; + +/* ── Flat preset filters ── */ +export 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 }, +]; + +/* ── Catalog target type & data ── */ +export type CatalogTarget = { + name: string; + bassBoost: { fc: number; q: number; gain: number }; + ear: "in" | "over" | "all"; +}; + +export 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", + }, +]; diff --git a/client/src/pages/eq/utils.ts b/client/src/pages/eq/utils.ts new file mode 100644 index 0000000..50d6667 --- /dev/null +++ b/client/src/pages/eq/utils.ts @@ -0,0 +1,176 @@ +import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; +import { getFilterShortName, getFilterType } from "@/lib/peqAudio"; +import { + BAND_FREQ_MIN, + BAND_FREQ_MAX, + BAND_GAIN_MIN, + BAND_GAIN_MAX, + BAND_Q_MIN, + BAND_Q_MAX, + type BandParamKind, + type PeqCatalogItem, +} from "./constants"; + +/* ── Template interpolation ── */ +export 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; +} + +/* ── Band frequency display formatting ── */ +export function formatBandFreqDisplay(freq: number) { + return freq >= 1000 + ? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` + : `${freq} Hz`; +} + +/* ── Band param value for input field ── */ +export function formatBandParamForInput( + kind: BandParamKind, + band: { freq: number; gain: number; q: number }, +) { + switch (kind) { + case "freq": + return String(band.freq); + case "gain": + return band.gain.toFixed(1); + case "q": + return band.q.toFixed(2); + } +} + +/* ── Parse band param input with validation ── */ +export function parseBandParamInput( + kind: BandParamKind, + raw: string, + messages: { invalid: string; outOfRange: string }, +): { ok: true; value: number } | { ok: false; message: string } { + const trimmed = raw.trim(); + if (!trimmed) return { ok: false, message: messages.invalid }; + + if (kind === "freq") { + let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, ""); + const kHz = /k(hz)?$/.test(s); + if (kHz) s = s.replace(/k(hz)?$/, ""); + const num = Number.parseFloat(s); + if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; + const hz = Math.round(kHz ? num * 1000 : num); + if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) { + return { ok: false, message: messages.outOfRange }; + } + return { ok: true, value: hz }; + } + + if (kind === "gain") { + const s = trimmed.replace(/\s*dB\s*$/i, "").trim(); + const num = Number.parseFloat(s); + if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; + const gain = Number(num.toFixed(1)); + if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) { + return { ok: false, message: messages.outOfRange }; + } + return { ok: true, value: gain }; + } + + const num = Number.parseFloat(trimmed); + if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; + const q = Number(num.toFixed(2)); + if (q < BAND_Q_MIN || q > BAND_Q_MAX) { + return { ok: false, message: messages.outOfRange }; + } + return { ok: true, value: q }; +} + +/* ── Normalize filter type from string or number ── */ +export 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); +} + +/* ── Clone bands array ── */ +export function cloneBands( + source: Array<{ + freq: number; + gain: number; + q: number; + type: string; + enabled: boolean; + }>, +) { + return source.map((band) => ({ ...band })); +} + +/* ── Frequency label for band grid ── */ +export function freqLabel(f: number) { + return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; +} + +/* ── Build PEQ catalog sync key ── */ +export function buildPeqCatalogSyncKey( + remote: Pick | null | undefined, + devicePeqSelect?: number, +): string { + const items = remote?.peq; + if (!items?.length) return items ? "empty" : ""; + const select = devicePeqSelect ?? remote?.peqSelect ?? 0; + return `${select}|${items.map((p) => p.name).join("\u0001")}`; +} + +/* ── Generate unique preset name ── */ +export 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}`; +} + +/* ── Convert UI band → device PeqFilter ── */ +export 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), + }; +} diff --git a/vite.config.ts b/vite.config.ts index b582cf5..f882a0b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -170,7 +170,7 @@ const plugins = [ tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), - vitePluginManusDebugCollector(), + // vitePluginManusDebugCollector(), // 已禁用:不再注入 debug-collector.js,消除浏览器网络面板中定时出现的 __manus__/logs 请求 vitePluginExcludeManusFromDist(), ];