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:
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ interface DeviceContextType {
|
|||||||
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
||||||
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
||||||
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
||||||
|
/** 与设备 syncPeq 结果对齐全局 peqState,避免 EQ 页本地列表与轮询缓存不一致 */
|
||||||
|
syncPeqCatalog: (state: PeqState) => void;
|
||||||
// Optimistic state updaters
|
// Optimistic state updaters
|
||||||
setVolume: (v: number) => void;
|
setVolume: (v: number) => void;
|
||||||
setInput: (v: number) => void;
|
setInput: (v: number) => void;
|
||||||
@@ -72,6 +74,10 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setPeqState(peq);
|
setPeqState(peq);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const syncPeqCatalog = useCallback((state: PeqState) => {
|
||||||
|
setPeqState(state);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
|
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
|
||||||
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
|
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
|
||||||
const targetIp = (connectIp ?? ip).trim();
|
const targetIp = (connectIp ?? ip).trim();
|
||||||
@@ -176,6 +182,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
|
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
|
||||||
|
console.log("[peqChange]", payload.peqChange);
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
applyPeqFiltersToState(payload.peqChange.filters);
|
applyPeqFiltersToState(payload.peqChange.filters);
|
||||||
return;
|
return;
|
||||||
@@ -186,6 +193,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [api, isDemoMode, applyPeqFiltersToState]);
|
}, [api, isDemoMode, applyPeqFiltersToState]);
|
||||||
|
|
||||||
const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => {
|
const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => {
|
||||||
|
console.log("[peqApply]", payload.peqApply);
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
applyPeqFiltersToState(payload.peqApply.filters);
|
applyPeqFiltersToState(payload.peqApply.filters);
|
||||||
return;
|
return;
|
||||||
@@ -263,7 +271,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||||
deviceState, peqState, lastUpdated, error,
|
deviceState, peqState, lastUpdated, error,
|
||||||
connect, disconnect, refresh, api,
|
connect, disconnect, refresh, api,
|
||||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply,
|
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
||||||
setVolume, setInput, setOutput, setBalance,
|
setVolume, setInput, setOutput, setBalance,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+11
-14
@@ -248,13 +248,7 @@ export class LuxsinAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
|
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
|
||||||
const payload = JSON.stringify({ peqChange: { filters } });
|
await this.postPeqJson({ peqChange: { filters } } as PeqChangePayload);
|
||||||
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)}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
||||||
@@ -267,24 +261,27 @@ export class LuxsinAPI {
|
|||||||
await this.postPeqJson(body);
|
await this.postPeqJson(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
|
||||||
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
||||||
const payload = JSON.stringify(body);
|
const encoded = encodeCustomBase64(JSON.stringify(body));
|
||||||
const encoded = encodeCustomBase64(payload);
|
const form = new URLSearchParams();
|
||||||
|
form.set("json", encoded);
|
||||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||||
body: `json=${encodeURIComponent(encoded)}`,
|
body: form.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove one or more headphone PEQ profiles. */
|
/** Remove one or more headphone PEQ profiles. */
|
||||||
async removePeq(names: string[]): Promise<void> {
|
async removePeq(names: string[]): Promise<void> {
|
||||||
const payload = JSON.stringify({ peqRemove: names });
|
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||||||
const encoded = encodeCustomBase64(payload);
|
const form = new URLSearchParams();
|
||||||
|
form.set("json", encoded);
|
||||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||||
body: `json=${encodeURIComponent(encoded)}`,
|
body: form.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+184
-671
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
import { useDevice } from "@/contexts/DeviceContext";
|
import { useDevice } from "@/contexts/DeviceContext";
|
||||||
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
|
import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -646,54 +647,25 @@ export default function Home() {
|
|||||||
boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
|
boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<ThumbOnlyRangeSlider
|
||||||
type="range"
|
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={200}
|
||||||
value={localVol}
|
value={localVol}
|
||||||
disabled={volumeControlsLocked}
|
disabled={volumeControlsLocked}
|
||||||
className="cyan-slider relative z-10"
|
onChange={(v) => {
|
||||||
onChange={(e) => {
|
|
||||||
if (volumeControlsLocked) return;
|
if (volumeControlsLocked) return;
|
||||||
const v = Number(e.target.value);
|
|
||||||
setLocalVol(v);
|
setLocalVol(v);
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onDragStart={() => {
|
||||||
if (volumeControlsLocked) return;
|
if (volumeControlsLocked) return;
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onDragEnd={() => {
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
if (volumeControlsLocked) return;
|
|
||||||
setVolume(Number(e.currentTarget.value));
|
|
||||||
}}
|
}}
|
||||||
onPointerCancel={(e) => {
|
onRelease={(v) => {
|
||||||
isDragging.current = false;
|
|
||||||
if (volumeControlsLocked) return;
|
if (volumeControlsLocked) return;
|
||||||
setVolume(Number(e.currentTarget.value));
|
setVolume(v);
|
||||||
}}
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
];
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
+4
-30
@@ -183,6 +183,7 @@ export default defineConfig({
|
|||||||
"@shared": path.resolve(import.meta.dirname, "shared"),
|
"@shared": path.resolve(import.meta.dirname, "shared"),
|
||||||
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
||||||
},
|
},
|
||||||
|
dedupe: ["react", "react-dom"],
|
||||||
},
|
},
|
||||||
envDir: path.resolve(import.meta.dirname),
|
envDir: path.resolve(import.meta.dirname),
|
||||||
root: path.resolve(import.meta.dirname, "client"),
|
root: path.resolve(import.meta.dirname, "client"),
|
||||||
@@ -207,36 +208,9 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (!id.includes("node_modules")) return;
|
if (!id.includes("node_modules")) return;
|
||||||
|
// Only split echarts (lazy EQ page). Do not manually chunk react, radix, lucide,
|
||||||
if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) {
|
// or ai-markdown — forced splits hoist shared helpers and break React.forwardRef.
|
||||||
return "react-vendor";
|
if (id.includes("/echarts/")) return "echarts-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";
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user