From ce55be295ab8a60ad39c576cea679d8976bb254c Mon Sep 17 00:00:00 2001 From: yangy Date: Thu, 28 May 2026 15:18:17 +0800 Subject: [PATCH] Update Vite configuration to deduplicate React and React-DOM; enhance DeviceContext with syncPeqCatalog method for improved PEQ state management; refactor LuxsinAPI to streamline PEQ filter updates; replace range input with ThumbOnlyRangeSlider in Home component for better user experience. --- .../src/components/ThumbOnlyRangeSlider.tsx | 145 +++ client/src/contexts/DeviceContext.tsx | 10 +- client/src/lib/luxsinApi.ts | 25 +- client/src/pages/EQPage.tsx | 897 ++++-------------- client/src/pages/Home.tsx | 42 +- .../src/pages/eq/components/EqPrimitives.tsx | 50 + client/src/pages/eq/components/FreqChart.tsx | 424 +++++++++ client/src/pages/eq/eqConstants.ts | 61 ++ client/src/pages/eq/eqFormatters.ts | 111 +++ client/src/pages/eq/peqMappers.ts | 41 + client/src/pages/eq/types.ts | 22 + vite.config.ts | 34 +- 12 files changed, 1090 insertions(+), 772 deletions(-) create mode 100644 client/src/components/ThumbOnlyRangeSlider.tsx create mode 100644 client/src/pages/eq/components/EqPrimitives.tsx create mode 100644 client/src/pages/eq/components/FreqChart.tsx create mode 100644 client/src/pages/eq/eqConstants.ts create mode 100644 client/src/pages/eq/eqFormatters.ts create mode 100644 client/src/pages/eq/peqMappers.ts create mode 100644 client/src/pages/eq/types.ts diff --git a/client/src/components/ThumbOnlyRangeSlider.tsx b/client/src/components/ThumbOnlyRangeSlider.tsx new file mode 100644 index 0000000..d6beabe --- /dev/null +++ b/client/src/components/ThumbOnlyRangeSlider.tsx @@ -0,0 +1,145 @@ +import { cn } from "@/lib/utils"; +import { useRef, type CSSProperties } from "react"; + +/** Matches .cyan-slider thumb (~17.6px); generous hit area for touch. */ +const THUMB_HIT_RADIUS_PX = 24; + +function valueFromClientX(input: HTMLInputElement, clientX: number): number { + const rect = input.getBoundingClientRect(); + const min = Number(input.min); + const max = Number(input.max); + if (rect.width <= 0) return Number(input.value); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.round(min + ratio * (max - min)); +} + +function isPointerOnThumb(input: HTMLInputElement, clientX: number): boolean { + const rect = input.getBoundingClientRect(); + const min = Number(input.min); + const max = Number(input.max); + const value = Number(input.value); + const span = max - min; + const ratio = span > 0 ? (value - min) / span : 0; + const thumbCenterX = rect.left + ratio * rect.width; + return Math.abs(clientX - thumbCenterX) <= THUMB_HIT_RADIUS_PX; +} + +const RELEASE_KEYS = [ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", +] as const; + +export interface ThumbOnlyRangeSliderProps { + value: number; + min: number; + max: number; + disabled?: boolean; + className?: string; + style?: CSSProperties; + onChange: (value: number) => void; + /** Fires when the user releases the thumb after a drag or keyboard adjust. */ + onRelease?: (value: number) => void; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +/** + * Range slider that ignores track clicks/taps — only the thumb can be dragged. + * Prevents accidental jumps to high volume when missing the thumb. + */ +export default function ThumbOnlyRangeSlider({ + value, + min, + max, + disabled = false, + className, + style, + onChange, + onRelease, + onDragStart, + onDragEnd, +}: ThumbOnlyRangeSliderProps) { + const draggingRef = useRef(false); + + const emitRelease = (el: EventTarget | null) => { + if (disabled) return; + if (!(el instanceof HTMLInputElement)) return; + onRelease?.(Number(el.value)); + onDragEnd?.(); + }; + + return ( + { + if (disabled || draggingRef.current) return; + onChange(Number(e.target.value)); + }} + onPointerDown={(e) => { + if (disabled) return; + const input = e.currentTarget; + if (!isPointerOnThumb(input, e.clientX)) { + e.preventDefault(); + return; + } + e.preventDefault(); + draggingRef.current = true; + onDragStart?.(); + input.setPointerCapture(e.pointerId); + onChange(valueFromClientX(input, e.clientX)); + }} + onPointerMove={(e) => { + if (disabled || !draggingRef.current) return; + onChange(valueFromClientX(e.currentTarget, e.clientX)); + }} + onPointerUp={(e) => { + if (!draggingRef.current) return; + draggingRef.current = false; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // ignore + } + emitRelease(e.currentTarget); + }} + onPointerCancel={(e) => { + if (!draggingRef.current) return; + draggingRef.current = false; + emitRelease(e.currentTarget); + }} + onLostPointerCapture={(e) => { + if (!draggingRef.current) return; + draggingRef.current = false; + emitRelease(e.currentTarget); + }} + onBlur={(e) => { + if (draggingRef.current) { + draggingRef.current = false; + emitRelease(e.currentTarget); + return; + } + if (!disabled) { + onRelease?.(Number(e.currentTarget.value)); + } + }} + onKeyUp={(e) => { + if (disabled) return; + if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) { + emitRelease(e.currentTarget); + } + }} + /> + ); +} diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 9e0daac..d60e2f5 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; + /** 与设备 syncPeq 结果对齐全局 peqState,避免 EQ 页本地列表与轮询缓存不一致 */ + 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(); @@ -176,6 +182,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { }, []); const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => { + console.log("[peqChange]", payload.peqChange); if (isDemoMode) { applyPeqFiltersToState(payload.peqChange.filters); return; @@ -186,6 +193,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { }, [api, isDemoMode, applyPeqFiltersToState]); const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => { + console.log("[peqApply]", payload.peqApply); if (isDemoMode) { applyPeqFiltersToState(payload.peqApply.filters); return; @@ -263,7 +271,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/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index f46052e..2878921 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -248,13 +248,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`. */ @@ -267,24 +261,27 @@ export class LuxsinAPI { 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); + const encoded = encodeCustomBase64(JSON.stringify(body)); + const form = new URLSearchParams(); + form.set("json", encoded); 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(), }); } /** Remove one or more headphone PEQ profiles. */ async removePeq(names: string[]): Promise { - const payload = JSON.stringify({ peqRemove: names }); - const encoded = encodeCustomBase64(payload); + const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names })); + const form = new URLSearchParams(); + form.set("json", encoded); 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(), }); } diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index ee38fa1..ba40dad 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -15,7 +15,6 @@ import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } f import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; -import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation"; import BottomNav from "@/components/BottomNav"; import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; @@ -35,651 +34,49 @@ import { type PeqState, } from "@/lib/luxsinApi"; import * as echarts from "echarts"; -import { - getSectionsMatrix, - visualizeResponse, - getChartOps, - getFilterType, - getFilterShortName, - buildPeqSvgCurveData, - sampleCombinedPeqMagnitudeDb, -} from "@/lib/peqAudio"; +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"; - -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({ - bands, - rawCurve, - selectedBand, - abMode, - onAbToggle, - onCopyMode, - onApplyB, - onSaveB, - onBandDrag, - onBandSelect, - eqUi, -}: { - bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>; - rawCurve: number[] | null; - selectedBand: number; - abMode: "A" | "B"; - onAbToggle: (m: "A" | "B") => void; - onCopyMode: (from: "A" | "B", to: "A" | "B") => void; - onApplyB: () => void; - onSaveB: () => void; - onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; - onBandSelect: (idx: number) => void; - eqUi: PeqEqUi; -}) { - /** 频响图 SVG 高度(viewBox 与 CSS 一致) */ - const H = 400; - const chartContainerRef = useRef(null); - const svgRef = useRef(null); - const draggingBandRef = useRef(null); - const curvePathRef = useRef(null); - const rawPathRef = useRef(null); - const equalizedPathRef = useRef(null); - const [chartWidth, setChartWidth] = useState(340); - const W = chartWidth; - const [isBandDragging, setIsBandDragging] = useState(false); - - useEffect(() => { - const node = chartContainerRef.current; - if (!node) return; - - const updateWidth = () => { - const nextWidth = Math.round(node.clientWidth); - if (nextWidth > 0) setChartWidth(nextWidth); - }; - - updateWidth(); - const observer = new ResizeObserver(updateWidth); - observer.observe(node); - return () => observer.disconnect(); - }, []); - - const Y_DB_MAX = 20; - - const freqToX = (f: number) => { - const logMin = Math.log10(20), logMax = Math.log10(20000); - return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W; - }; - const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10); - const xToFreq = (x: number) => { - const logMin = Math.log10(20), logMax = Math.log10(20000); - const clampedX = Math.max(0, Math.min(W, x)); - return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin)); - }; - const yToGain = (y: number) => { - const clampedY = Math.max(10, Math.min(H - 10, y)); - return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX; - }; - - const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => { - const svg = svgRef.current; - if (!svg || W <= 0) return; - const rect = svg.getBoundingClientRect(); - if (!rect.width || !rect.height) return; - - const x = ((clientX - rect.left) / rect.width) * W; - const y = ((clientY - rect.top) / rect.height) * H; - const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x)))); - const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1)); - onBandDrag(idx, { freq, gain }); - }; - - const handleBandPointerDown = (idx: number, e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - draggingBandRef.current = idx; - setIsBandDragging(true); - onBandSelect(idx); - updateBandFromPointer(idx, e.clientX, e.clientY); - }; - - const handleSvgPointerMove = (e: React.PointerEvent) => { - const idx = draggingBandRef.current; - if (idx === null) return; - e.preventDefault(); - updateBandFromPointer(idx, e.clientX, e.clientY); - }; - - const stopDragging = () => { - draggingBandRef.current = null; - setIsBandDragging(false); - }; - - const curveData = useMemo( - () => - buildPeqSvgCurveData({ - bands, - width: W, - height: H, - fs: 48000, - yDbMax: Y_DB_MAX, - minFreq: 20, - maxFreq: 20000, - paddingY: 10, - }), - [bands, W, H], - ); - const pathD = curveData.pathD; - const fillD = curveData.fillD; - - /** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */ - const combinedMagDbAtHandles = useMemo( - () => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)), - [bands], - ); - - const [curveVisibilityByMode, setCurveVisibilityByMode] = useState< - Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }> - >({ - A: { eq: true, raw: true, equalized: true }, - B: { eq: true, raw: false, equalized: false }, - }); - const showEq = curveVisibilityByMode[abMode].eq; - const showRaw = curveVisibilityByMode[abMode].raw; - const showEqualized = curveVisibilityByMode[abMode].equalized; - - const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => { - setCurveVisibilityByMode((prev) => ({ - ...prev, - [abMode]: { - ...prev[abMode], - [key]: !prev[abMode][key], - }, - })); - }; - - const rawPathD = useMemo(() => { - if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return ""; - return curveData.points - .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`) - .join(" "); - }, [rawCurve, curveData.points]); - const equalizedPathD = useMemo(() => { - if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return ""; - return curveData.points - .map((p, i) => { - const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0)); - return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`; - }) - .join(" "); - }, [rawCurve, curveData.points]); - const hasRawCurve = !!rawPathD; - const hasEqualizedCurve = !!equalizedPathD; - - // Intro draw once; EQ param edits update path in place without replaying the stroke animation. - const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const }; - useStrokeDrawAnimation(curvePathRef, pathD, { - ...strokeAnimOpts, - enabled: !isBandDragging, - }); - useStrokeDrawAnimation(rawPathRef, rawPathD, { - ...strokeAnimOpts, - enabled: !isBandDragging && !!rawPathD, - }); - useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, { - ...strokeAnimOpts, - enabled: !isBandDragging && !!equalizedPathD, - }); - - const targetMode: "A" | "B" = abMode === "A" ? "B" : "A"; - const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode }); - - const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; - const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20]; - - return ( -
- {/* Legend row */} -
- - - -
- - {/* A/B + DIFF controls */} -
-
- {/* A/B toggle pill */} -
- {(["A", "B"] as const).map((m) => ( - - ))} -
- -
-
- - -
-
- - {/* SVG chart */} -
- - {/* dB grid */} - {gainLabels.map((g) => ( - - - - {g > 0 ? `+${g}` : g} - - - ))} - {/* Freq grid */} - {freqLabels.map((f) => ( - - ))} - {/* Zero line */} - - {/* Fill */} - {showEq && } - {/* EQ curve */} - {showEq && ( - - )} - {/* Raw */} - {rawPathD && showRaw && ( - - )} - {/* Equalized = EQ + Raw */} - {equalizedPathD && showEqualized && ( - - )} - {/* Band nodes with index */} - {showEq && bands.map((band, i) => ( - handleBandPointerDown(i, e)} - > - {(() => { - const isSelected = i === selectedBand; - const outerR = isSelected ? 8.8 : 7; - const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)"; - const strokeColor = isSelected ? "#FFED00" : "#FFED00"; - const textColor = isSelected ? "#000000" : "#FFED00"; - const handleY = gainToY(combinedMagDbAtHandles[i] ?? 0); - return ( - <> - - - - {i + 1} - - - ); - })()} - - ))} - {/* Freq axis labels */} - {freqLabels.map((f) => ( - - {f >= 1000 ? `${f / 1000}k` : f} - - ))} - -
-
- ); -} - -/* ── Default bands matching reference image ── */ -const DEFAULT_BANDS = [ - { freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true }, - { freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true }, - { freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true }, - { freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true }, - { freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true }, - { freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true }, - { freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true }, - { freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true }, - { freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true }, - { freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true }, -]; - -function cloneBands( - source: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>, -) { - return source.map((band) => ({ ...band })); -} - -function freqLabel(f: number) { - return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; -} - -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), - }; -} +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, + formatBandParamForInput, + freqLabel, + normalizeFilterType, + parseBandParamInput, +} from "./eq/eqFormatters"; +import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, getUniquePresetName } from "./eq/peqMappers"; +import type { BandParamKind, PeqCatalogItem, PeqEqUi } from "./eq/types"; /* ── 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; @@ -756,6 +153,10 @@ export default function EQPage() { const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); const skipPeqAutoSyncRef = useRef(false); + /** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */ + const skipBandsSyncFromAbToggleRef = useRef(false); + /** 本地编辑过的预设 filters(按名称);syncPeq 拉取不会覆盖 */ + const peqFiltersCacheRef = useRef>({}); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; @@ -791,8 +192,11 @@ export default function EQPage() { }, []); const currentPeq = peqItems[headphoneIdx]; - const preampValue = Number(currentPeq?.preamp ?? 0); - const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; + 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(); @@ -1034,18 +438,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); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); @@ -1054,12 +460,27 @@ export default function EQPage() { } }; + const rememberPeqFiltersForPreset = useCallback( + (presetName: string | undefined, filters: PeqFilter[]) => { + if (!presetName || filters.length === 0) return; + peqFiltersCacheRef.current[presetName] = filters; + }, + [], + ); + + 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,16 +493,66 @@ export default function EQPage() { ); setHeadphoneIdx(nextIdx); }, - [], + [mergeRemotePeqCatalog], + ); + + /** 将当前 A 曲线写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */ + const persistFiltersToPeqItem = useCallback( + (catalogIdx: number, filtersSource: typeof DEFAULT_BANDS) => { + const filters = filtersSource.map(bandToPeqFilter); + setPeqItems((prev) => + prev.map((item, i) => { + if (i !== catalogIdx) return item; + rememberPeqFiltersForPreset(item.name, filters); + return { ...item, filters }; + }), + ); + }, + [rememberPeqFiltersForPreset], + ); + + const handleSelectHeadphone = useCallback( + (idx: number) => { + if (idx === headphoneIdx) { + setIsHeadphoneMenuOpen(false); + return; + } + const leaving = peqItems[headphoneIdx]; + const leavingFilters = bandsByMode.A.map(bandToPeqFilter); + rememberPeqFiltersForPreset(leaving?.name, leavingFilters); + persistFiltersToPeqItem(headphoneIdx, bandsByMode.A); + 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 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); - const items = remote.peq ?? []; + const items = mergeRemotePeqCatalog((remote.peq ?? []) as PeqCatalogItem[]); if (items.length === 0) { setBandsForBothModes(DEFAULT_BANDS); setSelectedBandByMode({ A: 0, B: 0 }); @@ -1109,14 +580,16 @@ export default function EQPage() { try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); + delete peqFiltersCacheRef.current[target.name]; applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } + delete peqFiltersCacheRef.current[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,6 +849,7 @@ export default function EQPage() { 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( @@ -1436,16 +910,14 @@ export default function EQPage() { loadHeadphones(); }, [api, isDemoMode, loadRawCurveForPeq]); - /** 仅耳机/型号/filters 变化时变;preamp、autoPre 变化不触发,避免拖总增益时反复请求 modelCurve */ - const modelCurveReloadKey = useMemo(() => { + /** 切换耳机型号时变;不含 filters,避免编辑时写回 peqItems 把 B 试听曲线重置 */ + 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 +939,94 @@ 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); - } + 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; - 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, - }; - upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload).catch(() => { - toast.error("EQ 保存失败"); - }); - }, delay); - }; + if (peqSyncTimerRef.current !== null) { + window.clearTimeout(peqSyncTimerRef.current); + } - // A/B 切换与曲线编辑防抖同步 → peqApply(仅试听,不写预设) + 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) { + persistFiltersToPeqItem(idxAtSchedule, filtersSource); + } + const submit = usePeqChange + ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) + : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); + submit().catch(() => { + toast.error("EQ 保存失败"); + }); + }, delay); + }, + [api, headphoneIdx, isDemoMode, persistFiltersToPeqItem, 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; } - 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, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]); useEffect(() => { const onPointerDown = (event: PointerEvent) => { @@ -1630,6 +1146,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 })), ); @@ -1751,7 +1268,7 @@ export default function EQPage() { }); return next; }); - schedulePeqSync(updatedPeq, bands, 0); + schedulePeqSync(updatedPeq, bands, abMode, 0); }; return ( @@ -1859,11 +1376,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 +1411,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 03ce0c2..5b64a88 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -12,6 +12,7 @@ import { useDevice } from "@/contexts/DeviceContext"; import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; import BottomNav from "@/components/BottomNav"; +import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider"; import { AlertDialog, AlertDialogAction, @@ -646,54 +647,25 @@ export default function Home() { boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)", }} /> - { + onChange={(v) => { if (volumeControlsLocked) return; - const v = Number(e.target.value); setLocalVol(v); }} - onPointerDown={() => { + onDragStart={() => { if (volumeControlsLocked) return; isDragging.current = true; }} - onPointerUp={(e) => { + onDragEnd={() => { isDragging.current = false; - if (volumeControlsLocked) return; - setVolume(Number(e.currentTarget.value)); }} - onPointerCancel={(e) => { - isDragging.current = false; + onRelease={(v) => { if (volumeControlsLocked) return; - setVolume(Number(e.currentTarget.value)); - }} - onBlur={(e) => { - isDragging.current = false; - if (volumeControlsLocked) { - return; - } - setVolume(Number(e.currentTarget.value)); - }} - onKeyUp={(e) => { - if (volumeControlsLocked) return; - const k = e.key; - if ( - k === "ArrowLeft" || - k === "ArrowRight" || - k === "ArrowUp" || - k === "ArrowDown" || - k === "Home" || - k === "End" || - k === "PageUp" || - k === "PageDown" - ) { - setVolume(Number(e.currentTarget.value)); - } + setVolume(v); }} />
diff --git a/client/src/pages/eq/components/EqPrimitives.tsx b/client/src/pages/eq/components/EqPrimitives.tsx new file mode 100644 index 0000000..b101d95 --- /dev/null +++ b/client/src/pages/eq/components/EqPrimitives.tsx @@ -0,0 +1,50 @@ +export function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +export 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))} + /> +
+ ); +} diff --git a/client/src/pages/eq/components/FreqChart.tsx b/client/src/pages/eq/components/FreqChart.tsx new file mode 100644 index 0000000..a3fd9fe --- /dev/null +++ b/client/src/pages/eq/components/FreqChart.tsx @@ -0,0 +1,424 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation"; +import { buildPeqSvgCurveData, sampleCombinedPeqMagnitudeDb } from "@/lib/peqAudio"; +import { eqInterp } from "../eqFormatters"; +import type { EqBand, PeqEqUi } from "../types"; + +export type FreqChartProps = { + bands: EqBand[]; + rawCurve: number[] | null; + selectedBand: number; + abMode: "A" | "B"; + onAbToggle: (m: "A" | "B") => void; + onCopyAndSwitchTo: (to: "A" | "B") => void; + onApplyB: () => void; + onSaveB: () => void; + onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; + onBandSelect: (idx: number) => void; + eqUi: PeqEqUi; +}; + +export function FreqChart({ + bands, + rawCurve, + selectedBand, + abMode, + onAbToggle, + onCopyAndSwitchTo, + onApplyB, + onSaveB, + onBandDrag, + onBandSelect, + eqUi, +}: FreqChartProps) { + /** 频响图 SVG 高度(viewBox 与 CSS 一致) */ + const H = 400; + const chartContainerRef = useRef(null); + const svgRef = useRef(null); + const draggingBandRef = useRef(null); + const curvePathRef = useRef(null); + const rawPathRef = useRef(null); + const equalizedPathRef = useRef(null); + const [chartWidth, setChartWidth] = useState(340); + const W = chartWidth; + const [isBandDragging, setIsBandDragging] = useState(false); + + useEffect(() => { + const node = chartContainerRef.current; + if (!node) return; + + const updateWidth = () => { + const nextWidth = Math.round(node.clientWidth); + if (nextWidth > 0) setChartWidth(nextWidth); + }; + + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const Y_DB_MAX = 20; + + const freqToX = (f: number) => { + const logMin = Math.log10(20), + logMax = Math.log10(20000); + return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W; + }; + const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10); + const xToFreq = (x: number) => { + const logMin = Math.log10(20), + logMax = Math.log10(20000); + const clampedX = Math.max(0, Math.min(W, x)); + return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin)); + }; + const yToGain = (y: number) => { + const clampedY = Math.max(10, Math.min(H - 10, y)); + return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX; + }; + + const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg || W <= 0) return; + const rect = svg.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + const x = ((clientX - rect.left) / rect.width) * W; + const y = ((clientY - rect.top) / rect.height) * H; + const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x)))); + const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1)); + onBandDrag(idx, { freq, gain }); + }; + + const handleBandPointerDown = (idx: number, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + draggingBandRef.current = idx; + setIsBandDragging(true); + onBandSelect(idx); + updateBandFromPointer(idx, e.clientX, e.clientY); + }; + + const handleSvgPointerMove = (e: React.PointerEvent) => { + const idx = draggingBandRef.current; + if (idx === null) return; + e.preventDefault(); + updateBandFromPointer(idx, e.clientX, e.clientY); + }; + + const stopDragging = () => { + draggingBandRef.current = null; + setIsBandDragging(false); + }; + + const curveData = useMemo( + () => + buildPeqSvgCurveData({ + bands, + width: W, + height: H, + fs: 48000, + yDbMax: Y_DB_MAX, + minFreq: 20, + maxFreq: 20000, + paddingY: 10, + }), + [bands, W, H], + ); + const pathD = curveData.pathD; + const fillD = curveData.fillD; + + /** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */ + const combinedMagDbAtHandles = useMemo( + () => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)), + [bands], + ); + + const [curveVisibilityByMode, setCurveVisibilityByMode] = useState< + Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }> + >({ + A: { eq: true, raw: true, equalized: true }, + B: { eq: true, raw: false, equalized: false }, + }); + const showEq = curveVisibilityByMode[abMode].eq; + const showRaw = curveVisibilityByMode[abMode].raw; + const showEqualized = curveVisibilityByMode[abMode].equalized; + + const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => { + setCurveVisibilityByMode((prev) => ({ + ...prev, + [abMode]: { + ...prev[abMode], + [key]: !prev[abMode][key], + }, + })); + }; + + const rawPathD = useMemo(() => { + if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return ""; + return curveData.points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`) + .join(" "); + }, [rawCurve, curveData.points]); + const equalizedPathD = useMemo(() => { + if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return ""; + return curveData.points + .map((p, i) => { + const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0)); + return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(" "); + }, [rawCurve, curveData.points]); + const hasRawCurve = !!rawPathD; + const hasEqualizedCurve = !!equalizedPathD; + + // Intro draw once; EQ param edits update path in place without replaying the stroke animation. + const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const }; + useStrokeDrawAnimation(curvePathRef, pathD, { + ...strokeAnimOpts, + enabled: !isBandDragging, + }); + useStrokeDrawAnimation(rawPathRef, rawPathD, { + ...strokeAnimOpts, + enabled: !isBandDragging && !!rawPathD, + }); + useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, { + ...strokeAnimOpts, + enabled: !isBandDragging && !!equalizedPathD, + }); + + const targetMode: "A" | "B" = abMode === "A" ? "B" : "A"; + const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode }); + + const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; + const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20]; + + return ( +
+ {/* Legend row */} +
+ + + +
+ + {/* A/B + DIFF controls */} +
+
+ {/* A/B toggle pill */} +
+ {(["A", "B"] as const).map((m) => ( + + ))} +
+ +
+
+ + +
+
+ + {/* SVG chart */} +
+ + {/* dB grid */} + {gainLabels.map((g) => ( + + + + {g > 0 ? `+${g}` : g} + + + ))} + {/* Freq grid */} + {freqLabels.map((f) => ( + + ))} + {/* Zero line */} + + {/* Fill */} + {showEq && } + {/* EQ curve */} + {showEq && ( + + )} + {/* Raw */} + {rawPathD && showRaw && ( + + )} + {/* Equalized = EQ + Raw */} + {equalizedPathD && showEqualized && ( + + )} + {/* Band nodes with index */} + {showEq && + bands.map((band, i) => ( + handleBandPointerDown(i, e)}> + {(() => { + const isSelected = i === selectedBand; + const outerR = isSelected ? 8.8 : 7; + const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)"; + const strokeColor = isSelected ? "#FFED00" : "#FFED00"; + const textColor = isSelected ? "#000000" : "#FFED00"; + const handleY = gainToY(combinedMagDbAtHandles[i] ?? 0); + return ( + <> + + + + {i + 1} + + + ); + })()} + + ))} + {/* Freq axis labels */} + {freqLabels.map((f) => ( + + {f >= 1000 ? `${f / 1000}k` : f} + + ))} + +
+
+ ); +} diff --git a/client/src/pages/eq/eqConstants.ts b/client/src/pages/eq/eqConstants.ts new file mode 100644 index 0000000..7cfb5c5 --- /dev/null +++ b/client/src/pages/eq/eqConstants.ts @@ -0,0 +1,61 @@ +import type { CSSProperties } from "react"; +import type { PeqFilter } from "@/lib/luxsinApi"; +import type { CatalogTarget, EqBand } from "./types"; + +export const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"]; + +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 const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = { + background: "rgba(44,44,46,0.9)", + border: "1px solid rgba(255,255,255,0.08)", +}; + +/** Default bands matching reference image */ +export const DEFAULT_BANDS: EqBand[] = [ + { 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 }, +]; + +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 }, +]; + +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/eqFormatters.ts b/client/src/pages/eq/eqFormatters.ts new file mode 100644 index 0000000..fc4c41c --- /dev/null +++ b/client/src/pages/eq/eqFormatters.ts @@ -0,0 +1,111 @@ +import { getFilterShortName } from "@/lib/peqAudio"; +import { + BAND_FREQ_MAX, + BAND_FREQ_MIN, + BAND_GAIN_MAX, + BAND_GAIN_MIN, + BAND_Q_MAX, + BAND_Q_MIN, +} from "./eqConstants"; +import type { BandParamKind } from "./types"; + +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; +} + +export function formatBandFreqDisplay(freq: number) { + return freq >= 1000 + ? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` + : `${freq} Hz`; +} + +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); + } +} + +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 }; +} + +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); +} + +export function freqLabel(f: number) { + return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; +} diff --git a/client/src/pages/eq/peqMappers.ts b/client/src/pages/eq/peqMappers.ts new file mode 100644 index 0000000..4a64d78 --- /dev/null +++ b/client/src/pages/eq/peqMappers.ts @@ -0,0 +1,41 @@ +import { getFilterType } from "@/lib/peqAudio"; +import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; +import type { EqBand } from "./types"; + +export function cloneBands(source: EqBand[]) { + return source.map((band) => ({ ...band })); +} + +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")}`; +} + +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}`; +} + +/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */ +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/client/src/pages/eq/types.ts b/client/src/pages/eq/types.ts new file mode 100644 index 0000000..a7288ef --- /dev/null +++ b/client/src/pages/eq/types.ts @@ -0,0 +1,22 @@ +import type { PeqState } from "@/lib/luxsinApi"; +import type localeZh from "@/locales/data-zh.json"; + +export type PeqEqUi = NonNullable["eqUi"]>; + +export type BandParamKind = "freq" | "gain" | "q"; + +export type EqBand = { + freq: number; + gain: number; + q: number; + type: string; + enabled: boolean; +}; + +export type PeqCatalogItem = NonNullable[number]; + +export type CatalogTarget = { + name: string; + bassBoost: { fc: number; q: number; gain: number }; + ear: "in" | "over" | "all"; +}; diff --git a/vite.config.ts b/vite.config.ts index b83d250..247be50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -183,6 +183,7 @@ export default defineConfig({ "@shared": path.resolve(import.meta.dirname, "shared"), "@assets": path.resolve(import.meta.dirname, "attached_assets"), }, + dedupe: ["react", "react-dom"], }, envDir: path.resolve(import.meta.dirname), root: path.resolve(import.meta.dirname, "client"), @@ -207,36 +208,9 @@ export default defineConfig({ output: { manualChunks(id) { if (!id.includes("node_modules")) return; - - if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) { - return "react-vendor"; - } - if (id.includes("node_modules/wouter/")) { - return "router-vendor"; - } - if (id.includes("node_modules/echarts/")) return "echarts-vendor"; - if (id.includes("node_modules/recharts/")) return "recharts-vendor"; - if (id.includes("node_modules/framer-motion/")) return "motion-vendor"; - if (id.includes("node_modules/@radix-ui/")) return "radix-vendor"; - - // streamdown + shiki + mermaid (lazy-loaded with AIPage) - if ( - id.includes("streamdown") || - id.includes("@shikijs/") || - id.includes("/shiki/") || - id.includes("mermaid") - ) { - return "ai-markdown-vendor"; - } - - if ( - id.includes("node_modules/lucide-react/") || - id.includes("node_modules/class-variance-authority/") || - id.includes("node_modules/clsx/") || - id.includes("node_modules/tailwind-merge/") - ) { - return "ui-vendor"; - } + // Only split echarts (lazy EQ page). Do not manually chunk react, radix, lucide, + // or ai-markdown — forced splits hoist shared helpers and break React.forwardRef. + if (id.includes("/echarts/")) return "echarts-vendor"; }, }, },