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];