-
VU 表灵敏度
+
{dacText.sensitivity ?? "VU 表灵敏度"}
{localVuSens >= 0 ? `+${localVuSens}` : localVuSens} dB
@@ -136,23 +177,41 @@ export default function AudioPage() {
{/* ── 输出特性 ── */}
- goSelect("选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")} />
- goSelect("选择耳机增益", GAIN_OPTIONS, gainIdx, "analogGain")} />
- goSelect("选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")} />
+ goSelect(dacText.filters?.label ?? "选择滤波特性", FILTER_OPTIONS, filterIdx, "filterCharacteristic")}
+ />
+ goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
+ />
+ goSelect(dacText.volumeStep ?? "选择音量幅度", STEP_OPTIONS, stepIdx, "soundStep")}
+ />
{/* ── 系统与接口 ── */}
- goSelect("选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")} />
- goSelect("选择 DAC 增益", DAC_GAIN_OPTIONS, dacGainIdx, "dacGain")} />
- goSelect("选择 DAC 阻抗", DAC_IMP_OPTIONS, dacImpIdx, "dacImpedance")} />
+ goSelect(dacText.xlr?.label ?? "选择 XLR 极性", XLR_OPTIONS, xlrIdx, "xlr")}
+ />
+ goSelect(dacText.mutePolar?.label ?? "选择 IIS 静音电平", MUTE_POLAR_OPTIONS, mutePolarIdx, "mutePolar")}
+ />
+ goSelect(dacText.IISMode?.label ?? "选择 IIS 模式", IIS_MODE_OPTIONS, iisModeIdx, "IISMode")}
+ />
diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx
index 0dd798d..e7ed90a 100644
--- a/client/src/pages/EQPage.tsx
+++ b/client/src/pages/EQPage.tsx
@@ -11,20 +11,22 @@
7. Total gain card: 总增益 value + AUTO toggle + slider
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
-import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react";
+import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, X } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useMemo, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import BottomNav from "@/components/BottomNav";
import { toast } from "sonner";
-import { navigateToSelect } from "./SelectPage";
-import { decodeCustomBase64 } from "@/lib/luxsinApi";
+import { decodeCustomBase64, type PeqFilter, type PeqChangePayload } from "@/lib/luxsinApi";
import * as echarts from "echarts";
import {
getSectionsMatrix,
visualizeResponse,
getChartOps,
getFilterType,
+ getFilterShortName,
+ computePeqMagnitudeDb,
+ getPeqLogSpacedFreqs,
} from "@/lib/peqAudio";
/* ── iOS Toggle ── */
@@ -38,59 +40,183 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool
}
/* ── Cyan Slider ── */
-function CyanSlider({ value, min, max, step = 1, onChange }: {
- value: number; min: number; max: number; step?: number; onChange: (v: number) => void;
+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 (
-
+
+ style={{
+ width: `${fillPct}%`,
+ background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
+ boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)",
+ }} />
onChange(Number(e.target.value))} />
);
}
/* ── Constants ── */
-const FILTER_TYPES = ["PEAK", "LSHELF", "HSHELF", "NOTCH", "LPF", "HPF"];
+const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"];
+
+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, abMode, onAbToggle,
+ bands, selectedBand, abMode, onAbToggle, onBandDrag, onBandSelect,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
+ selectedBand: number;
abMode: "A" | "B";
onAbToggle: (m: "A" | "B") => void;
+ onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
+ onBandSelect: (idx: number) => void;
}) {
- const W = 340, H = 140;
+ const H = 140;
+ const chartContainerRef = useRef
(null);
+ const svgRef = useRef(null);
+ const draggingBandRef = useRef(null);
+ const curvePathRef = 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 / 15) * (H / 2 - 10);
+ 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 FS = 48000;
const curve = useMemo(() => {
- const freqs = Array.from({ length: 120 }, (_, i) => 20 * Math.pow(1000, i / 119));
- return freqs.map((f) => {
- let g = 0;
- bands.forEach((b) => {
- if (!b.enabled) return;
- const ratio = f / b.freq;
- const lr = Math.log10(ratio);
- g += b.gain * Math.exp(-(lr * lr) * b.q * 2);
- });
+ 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]);
+ }, [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]);
+
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
- const gainLabels = [15, 9, 3, 0, -3, -9, -15];
+ const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
return (
@@ -105,7 +231,7 @@ function FreqChart({
Raw
@@ -141,8 +267,21 @@ function FreqChart({