From 0aa856ed849f4990783a9ce0498ff6428784a63f Mon Sep 17 00:00:00 2001 From: yangy Date: Mon, 20 Apr 2026 17:58:11 +0800 Subject: [PATCH] Enhance PEQ functionality: Update PeqBandForResponse type to allow numeric filter types, and introduce buildPeqSvgCurveData function for reusable SVG curve data generation. Refactor EQPage to utilize new curve data for improved frequency response visualization and animation handling. --- client/src/lib/peqAudio.ts | 56 ++++++++++++++++++- client/src/lib/useStrokeDrawAnimation.ts | 52 +++++++++++++++++ client/src/pages/EQPage.tsx | 71 ++++++++---------------- 3 files changed, 130 insertions(+), 49 deletions(-) create mode 100644 client/src/lib/useStrokeDrawAnimation.ts diff --git a/client/src/lib/peqAudio.ts b/client/src/lib/peqAudio.ts index 5c3b1e3..5b96360 100644 --- a/client/src/lib/peqAudio.ts +++ b/client/src/lib/peqAudio.ts @@ -339,7 +339,7 @@ export type PeqBandForResponse = { gain: number; freq: number; q: number; - type: string; + type: string | number; }; /** @@ -359,6 +359,60 @@ export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number): return getFreqznList(list, fs, f); } +export type PeqSvgCurveData = { + points: Array<{ x: number; y: number; gainDb: number; freq: number }>; + pathD: string; + fillD: string; +}; + +/** + * Build reusable SVG curve data (`points`, `pathD`, `fillD`) for PEQ response rendering. + * Any page can call this and render with SVG ``. + */ +export function buildPeqSvgCurveData(args: { + bands: PeqBandForResponse[]; + width: number; + height: number; + fs?: number; + yDbMax?: number; + minFreq?: number; + maxFreq?: number; + paddingY?: number; +}): PeqSvgCurveData { + const { + bands, + width, + height, + fs = 48000, + yDbMax = 20, + minFreq = 20, + maxFreq = 20000, + paddingY = 10, + } = args; + + const safeW = Math.max(1, width); + const safeH = Math.max(1, height); + + const logMin = Math.log10(minFreq); + const logMax = Math.log10(maxFreq); + const freqToX = (f: number) => + ((Math.log10(Math.max(minFreq, Math.min(maxFreq, f))) - logMin) / (logMax - logMin)) * safeW; + const gainToY = (g: number) => safeH / 2 - (g / yDbMax) * (safeH / 2 - paddingY); + + const freqs = getPeqLogSpacedFreqs(); + const db = computePeqMagnitudeDb(bands, fs); + const points = freqs.map((f, i) => { + const gainDb = db[i] ?? 0; + return { x: freqToX(f), y: gainToY(gainDb), gainDb, freq: f }; + }); + + const pathD = points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(" "); + const fillD = pathD ? `${pathD} L ${safeW} ${safeH / 2} L 0 ${safeH / 2} Z` : ""; + return { points, pathD, fillD }; +} + /** * Get ECharts options for frequency response chart */ diff --git a/client/src/lib/useStrokeDrawAnimation.ts b/client/src/lib/useStrokeDrawAnimation.ts new file mode 100644 index 0000000..03c5d7d --- /dev/null +++ b/client/src/lib/useStrokeDrawAnimation.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +/** + * Reusable left-to-right SVG stroke draw animation. + * Pass `enabled=false` to disable and clear dash styles (e.g. while dragging). + */ +export function useStrokeDrawAnimation( + pathRef: React.RefObject, + pathD: string, + options?: { + enabled?: boolean; + durationMs?: number; + easing?: string; + }, +) { + const enabled = options?.enabled ?? true; + const durationMs = options?.durationMs ?? 1350; + const easing = options?.easing ?? "cubic-bezier(0.33, 1, 0.68, 1)"; + + useEffect(() => { + const path = pathRef.current; + if (!path || !pathD) return; + + if (!enabled) { + path.style.strokeDasharray = ""; + path.style.strokeDashoffset = ""; + path.style.transition = ""; + return; + } + + const length = path.getTotalLength(); + if (!Number.isFinite(length) || length <= 0) return; + + path.style.strokeDasharray = `${length}`; + path.style.strokeDashoffset = `${length}`; + path.style.transition = "none"; + + let cancelled = false; + const id = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (cancelled) return; + path.style.transition = `stroke-dashoffset ${durationMs}ms ${easing}`; + path.style.strokeDashoffset = "0"; + }); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(id); + }; + }, [pathRef, pathD, enabled, durationMs, easing]); +} diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index b9a5f67..62ac563 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -15,6 +15,7 @@ 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 { toast } from "sonner"; import { @@ -36,8 +37,7 @@ import { getChartOps, getFilterType, getFilterShortName, - computePeqMagnitudeDb, - getPeqLogSpacedFreqs, + buildPeqSvgCurveData, } from "@/lib/peqAudio"; /* ── iOS Toggle ── */ @@ -178,53 +178,28 @@ function FreqChart({ setIsBandDragging(false); }; - const FS = 48000; + const curveData = useMemo( + () => + buildPeqSvgCurveData({ + bands, + width: W, + height: H, + fs: 48000, + yDbMax: Y_DB_MAX, + minFreq: 20, + maxFreq: 20000, + paddingY: 10, + }), + [bands, W], + ); + const pathD = curveData.pathD; + const fillD = curveData.fillD; - const curve = useMemo(() => { - const freqs = getPeqLogSpacedFreqs(); - const db = computePeqMagnitudeDb(bands, FS); - return freqs.map((f, i) => { - const g = db[i] ?? 0; - return { x: freqToX(f), y: gainToY(g), g }; - }); - }, [bands, W]); - - const pathD = curve.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); - const fillD = `${pathD} L ${W} ${H / 2} L 0 ${H / 2} Z`; - - // Left-to-right stroke draw animation (skip while dragging a band) - useEffect(() => { - const path = curvePathRef.current; - if (!path || !pathD) return; - - if (isBandDragging) { - path.style.strokeDasharray = ""; - path.style.strokeDashoffset = ""; - path.style.transition = ""; - return; - } - - const length = path.getTotalLength(); - if (!Number.isFinite(length) || length <= 0) return; - - path.style.strokeDasharray = `${length}`; - path.style.strokeDashoffset = `${length}`; - path.style.transition = "none"; - - let cancelled = false; - const id1 = requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (cancelled) return; - path.style.transition = "stroke-dashoffset 1.35s cubic-bezier(0.33, 1, 0.68, 1)"; - path.style.strokeDashoffset = "0"; - }); - }); - - return () => { - cancelled = true; - cancelAnimationFrame(id1); - }; - }, [pathD, W, isBandDragging]); + // Reusable left-to-right stroke draw animation (skip while dragging a band). + useStrokeDrawAnimation(curvePathRef, pathD, { + enabled: !isBandDragging, + durationMs: 1350, + }); const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];