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.

This commit is contained in:
yangy
2026-05-28 15:18:17 +08:00
parent e6b7dcc89c
commit ce55be295a
12 changed files with 1090 additions and 772 deletions
@@ -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 (
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
className={cn("cyan-slider relative z-10", className)}
style={{ touchAction: "none", ...style }}
onChange={(e) => {
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);
}
}}
/>
);
}
+9 -1
View File
@@ -31,6 +31,8 @@ interface DeviceContextType {
updatePeq: (filters: PeqFilter[]) => Promise<void>;
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
/** 与设备 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}
+11 -14
View File
@@ -248,13 +248,7 @@ export class LuxsinAPI {
}
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
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=<custom-base64>` — matches legacy axios `upgradePeq`. */
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
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<void> {
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(),
});
}
File diff suppressed because it is too large Load Diff
+7 -35
View File
@@ -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)",
}}
/>
<input
type="range"
<ThumbOnlyRangeSlider
min={0}
max={200}
value={localVol}
disabled={volumeControlsLocked}
className="cyan-slider relative z-10"
onChange={(e) => {
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);
}}
/>
</div>
@@ -0,0 +1,50 @@
export function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="ios-toggle-track">
<span className="ios-toggle-thumb" />
</span>
</label>
);
}
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 (
<div className={`relative flex items-center w-full mt-2 ${disabled ? "opacity-60" : ""}`}>
<div
className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{
width: `${fillPct}%`,
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)",
}}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
className="cyan-slider relative z-10"
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
@@ -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<HTMLDivElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const draggingBandRef = useRef<number | null>(null);
const curvePathRef = useRef<SVGPathElement | null>(null);
const rawPathRef = useRef<SVGPathElement | null>(null);
const equalizedPathRef = useRef<SVGPathElement | null>(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<SVGGElement>) => {
e.preventDefault();
e.stopPropagation();
draggingBandRef.current = idx;
setIsBandDragging(true);
onBandSelect(idx);
updateBandFromPointer(idx, e.clientX, e.clientY);
};
const handleSvgPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
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 (
<div className="ios-list-group p-3 mb-3">
{/* Legend row */}
<div className="flex items-center gap-3 mb-2 px-1">
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity"
onClick={() => toggleCurveVisibility("eq")}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#FFED00" }} />
<span className="text-[10px]" style={{ color: "#FFED00", opacity: showEq ? 1 : 0.35 }}>
Equalizer
</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("raw")}
disabled={!hasRawCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#ffffff" }} />
<span className="text-[10px]" style={{ color: "#ffffff", opacity: showRaw ? 1 : 0.35 }}>
Raw
</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("equalized")}
disabled={!hasEqualizedCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#23d2fe" }} />
<span className="text-[10px]" style={{ color: "#23d2fe", opacity: showEqualized ? 1 : 0.35 }}>
Equalized
</span>
</button>
</div>
{/* A/B + DIFF controls */}
<div className="flex w-full items-center gap-2 mb-2 px-1">
<div className="flex min-w-0 items-center gap-2">
{/* A/B toggle pill */}
<div
className="flex items-center rounded-[8px] overflow-hidden"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
>
{(["A", "B"] as const).map((m) => (
<button
key={m}
className="px-3 py-1 text-[12px] font-semibold transition-all duration-150"
style={
abMode === m
? {
background: "#00FFF6",
color: "#000",
borderRadius: 6,
}
: { color: "rgba(255,255,255,0.4)" }
}
onClick={() => onAbToggle(m)}
>
{m}
</button>
))}
</div>
<button
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={() => {
onCopyAndSwitchTo(targetMode);
setCurveVisibilityByMode((prev) => ({
...prev,
[targetMode]: { ...prev[abMode] },
}));
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
}}
>
{copyButtonText}
</button>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<button
type="button"
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={() => onApplyB()}
>
{eqUi.chartApplyB}
</button>
<button
type="button"
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={() => onSaveB()}
>
{eqUi.chartSaveB}
</button>
</div>
</div>
{/* SVG chart */}
<div
ref={chartContainerRef}
className="rounded-[10px] overflow-hidden"
style={{ background: "rgba(10,12,10,0.7)" }}
>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: H, touchAction: "none" }}
onPointerMove={handleSvgPointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onPointerLeave={stopDragging}
>
{/* dB grid */}
{gainLabels.map((g) => (
<g key={g}>
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)} stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
{g > 0 ? `+${g}` : g}
</text>
</g>
))}
{/* Freq grid */}
{freqLabels.map((f) => (
<line
key={f}
x1={freqToX(f)}
y1="0"
x2={freqToX(f)}
y2={H - 12}
stroke="rgba(255,255,255,0.05)"
strokeWidth="1"
/>
))}
{/* Zero line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
{/* Fill */}
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
{/* EQ curve */}
{showEq && (
<path
ref={curvePathRef}
d={pathD}
fill="none"
stroke="#FFED00"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Raw */}
{rawPathD && showRaw && (
<path
ref={rawPathRef}
d={rawPathD}
fill="none"
stroke="#ffffff"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.9"
/>
)}
{/* Equalized = EQ + Raw */}
{equalizedPathD && showEqualized && (
<path
ref={equalizedPathRef}
d={equalizedPathD}
fill="none"
stroke="#23d2fe"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.95"
/>
)}
{/* Band nodes with index */}
{showEq &&
bands.map((band, i) => (
<g key={i} style={{ cursor: "grab" }} onPointerDown={(e) => 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 (
<>
<circle cx={freqToX(band.freq)} cy={handleY} r="14" fill="transparent" />
<circle
cx={freqToX(band.freq)}
cy={handleY}
r={outerR}
fill={fillColor}
stroke={strokeColor}
strokeWidth={isSelected ? 1.8 : 1.5}
/>
<text
x={freqToX(band.freq)}
y={handleY + 3.5}
textAnchor="middle"
fontSize="7"
fill={textColor}
fontWeight="bold"
pointerEvents="none"
>
{i + 1}
</text>
</>
);
})()}
</g>
))}
{/* Freq axis labels */}
{freqLabels.map((f) => (
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle" fontSize="7" fill="rgba(255,255,255,0.25)">
{f >= 1000 ? `${f / 1000}k` : f}
</text>
))}
</svg>
</div>
</div>
);
}
+61
View File
@@ -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" },
];
+111
View File
@@ -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, string | number>): 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}`;
}
+41
View File
@@ -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<PeqState, "peq" | "peqSelect"> | 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),
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { PeqState } from "@/lib/luxsinApi";
import type localeZh from "@/locales/data-zh.json";
export type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
export type BandParamKind = "freq" | "gain" | "q";
export type EqBand = {
freq: number;
gain: number;
q: number;
type: string;
enabled: boolean;
};
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
export type CatalogTarget = {
name: string;
bassBoost: { fc: number; q: number; gain: number };
ear: "in" | "over" | "all";
};