多语言,EQ 频响曲线 Y 轴可调,滤波器类型新增 Bypass
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
|
||||
> This document records feature updates and bug fixes for the Luxsin X8 controller. Entries are listed in reverse chronological order — the newest release appears first.
|
||||
|
||||
## v2026.11.03
|
||||
|
||||
### New Features
|
||||
- EQ frequency-response chart: the Y-axis (gain) range is now adjustable. Use the segmented control beside the legend to switch between ±6 / ±12 / ±15 / ±20 dB; your choice is remembered next time.
|
||||
- EQ filter type: added "BYPASS" as the first option in the filter-type menu. Selecting it bypasses the band and locks its FREQ / GAIN / Q controls, while the underlying filter values stay unchanged.
|
||||
- Added more interface languages, including Russian and Japanese. These languages apply to the app UI only and are not sent to the device.
|
||||
|
||||
## v2026.09.22
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -12,6 +12,11 @@ export const TYPE_HIGHPASS = 1;
|
||||
export const TYPE_BANDPASS = 2;
|
||||
export const TYPE_NOTCH = 3;
|
||||
export const TYPE_ALLPASS = 7;
|
||||
/**
|
||||
* 旁通标记:设备协议用 type + 10 表示该滤波器被 bypass(见 getSectionsMatrix)。
|
||||
* UI 固定下发 10,系数落入 default 分支→unity(0 dB 直通)。
|
||||
*/
|
||||
export const TYPE_BYPASS = 10;
|
||||
|
||||
// Filter type mapping (device uses numeric type; pass-through when already a number)
|
||||
export function getFilterType(typeName: string | number): number {
|
||||
@@ -340,6 +345,8 @@ export type PeqBandForResponse = {
|
||||
freq: number;
|
||||
q: number;
|
||||
type: string | number;
|
||||
/** 旁通:该带不参与级联响应(曲线贡献 0 dB),type 仍保留原滤波器类型。 */
|
||||
bypassed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -351,7 +358,7 @@ export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number):
|
||||
const list: Coeff[] = [];
|
||||
bands.forEach((b) => {
|
||||
if (!b.enabled) return;
|
||||
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), false, fs));
|
||||
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), !!b.bypassed, fs));
|
||||
});
|
||||
if (list.length === 0) {
|
||||
return f.map(() => 0);
|
||||
@@ -372,7 +379,7 @@ export function sampleCombinedPeqMagnitudeDb(
|
||||
const list: Coeff[] = [];
|
||||
bands.forEach((b) => {
|
||||
if (!b.enabled) return;
|
||||
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), false, fs));
|
||||
list.push(getSectionsMatrix(b.gain, b.freq, b.q, getFilterType(b.type), !!b.bypassed, fs));
|
||||
});
|
||||
if (list.length === 0) return 0;
|
||||
const out = getFreqznList(list, fs, [fc]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,14 @@ import localeKo from "./data-ko.json";
|
||||
import localeVi from "./data-vi.json";
|
||||
import localePt from "./data-pt.json";
|
||||
import localePl from "./data-pl.json";
|
||||
import localeRu from "./data-ru.json";
|
||||
import localeJa from "./data-ja.json";
|
||||
|
||||
/**
|
||||
* 语言索引映射:
|
||||
* 0=en, 1=zh-HK, 2=zh-CN → 设备固件支持,选择后会下发 dev/info.cgi?action=setting&language=
|
||||
* 3=de, 4=es, 5=fr, 6=ko, 7=vi, 8=pt, 9=pl → 设备不支持,仅在 controller 前端做界面语言切换,绝不下发设备
|
||||
* 3=de, 4=es, 5=fr, 6=ko, 7=vi, 8=pt, 9=pl,
|
||||
* 10=ru, 11=ja → 设备不支持,仅在 controller 前端做界面语言切换,绝不下发设备
|
||||
*/
|
||||
export const DEFAULT_DEVICE_LANGUAGE = 0;
|
||||
|
||||
@@ -32,6 +35,8 @@ const LOCALE_PACKS = [
|
||||
localeVi,
|
||||
localePt,
|
||||
localePl,
|
||||
localeRu,
|
||||
localeJa,
|
||||
] as LocalePack[];
|
||||
|
||||
/** 语言选择器可展示的全部语言数量(含仅前端语言)。 */
|
||||
@@ -39,7 +44,7 @@ export const LANGUAGE_COUNT = LOCALE_PACKS.length;
|
||||
|
||||
/**
|
||||
* 语言选择器固定显示的「母语名称」,与当前界面语言无关。
|
||||
* 数组下标即语言索引(0=en … 9=pl),选择时直接作为语言值使用。
|
||||
* 数组下标即语言索引(0=en … 11=ja),选择时直接作为语言值使用。
|
||||
*/
|
||||
export const LANGUAGE_NATIVE_LABELS = [
|
||||
"English",
|
||||
@@ -52,6 +57,8 @@ export const LANGUAGE_NATIVE_LABELS = [
|
||||
"Tiếng Việt",
|
||||
"Português (Portugal)",
|
||||
"Polski",
|
||||
"Русский",
|
||||
"日本語",
|
||||
];
|
||||
|
||||
/** 是否为设备支持的语言(0/1/2),只有这些才会下发设备。 */
|
||||
@@ -69,7 +76,7 @@ export function resolveDeviceLanguage(language: number | undefined): number {
|
||||
return language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||
}
|
||||
|
||||
/* ── 前端语言覆盖(仅非设备语言 3..9,持久化到 localStorage,绝不下发设备) ── */
|
||||
/* ── 前端语言覆盖(仅非设备语言 3..11,持久化到 localStorage,绝不下发设备) ── */
|
||||
const UI_LANGUAGE_OVERRIDE_KEY = "luxsin.uiLanguageOverride";
|
||||
|
||||
function readStoredOverride(): number | null {
|
||||
@@ -91,7 +98,7 @@ export function getUiLanguageOverride(): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置前端语言覆盖。传入仅前端语言(3..9)时生效并持久化;
|
||||
* 设置前端语言覆盖。传入仅前端语言(3..11)时生效并持久化;
|
||||
* 传入设备语言(0/1/2)或 null 时清除覆盖,恢复跟随设备语言。
|
||||
*/
|
||||
export function setUiLanguageOverride(index: number | null): number | null {
|
||||
|
||||
+70
-18
@@ -83,6 +83,7 @@ import {
|
||||
fetchEqSyncPeq,
|
||||
getUniquePresetName,
|
||||
importedFiltersToPeqFilters,
|
||||
isBypassFilterType,
|
||||
} from "./eq/peqMappers";
|
||||
import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types";
|
||||
|
||||
@@ -355,12 +356,15 @@ export default function EQPage() {
|
||||
return filters.map((f: any) => {
|
||||
const rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000;
|
||||
const freq = normalizeBandFreqHz(rawFreq) ?? 1000;
|
||||
// 设备用 type=10 表示旁通,原类型已丢失;回退 PEAK 供重新打开时使用
|
||||
const bypassed = isBypassFilterType(f.type);
|
||||
return {
|
||||
freq: Math.round(freq),
|
||||
gain: Number(f.gain || 0),
|
||||
q: Number(f.q || 1),
|
||||
type: normalizeFilterType(f.type),
|
||||
type: bypassed ? "PEAK" : normalizeFilterType(f.type),
|
||||
enabled: f.enabled !== undefined ? f.enabled : true,
|
||||
bypassed,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -415,6 +419,7 @@ export default function EQPage() {
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
bypassed: boolean;
|
||||
}>(10);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
@@ -449,6 +454,7 @@ export default function EQPage() {
|
||||
freq: Number(freq.toFixed(2)),
|
||||
gain: Number(gain.toFixed(2)),
|
||||
q: Number(q.toFixed(2)),
|
||||
bypassed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1243,6 +1249,7 @@ export default function EQPage() {
|
||||
}, []);
|
||||
|
||||
const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
|
||||
const bandBypassed = !!band.bypassed;
|
||||
|
||||
useEffect(() => {
|
||||
setBandSliderPreview(null);
|
||||
@@ -1682,29 +1689,44 @@ export default function EQPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Band detail card ── */}
|
||||
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
|
||||
{/* Filter type selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"ios-list-group p-4 space-y-5 relative overflow-visible",
|
||||
// 下拉展开时抬升整卡层级,避免末项被固定底部导航(z-50)压住
|
||||
isFilterMenuOpen ? "z-[60]" : "z-30"
|
||||
)}
|
||||
>
|
||||
{/* Filter type selector:BYPASS 作为下拉首个可选类型 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[14px] text-white/50 font-medium shrink-0">
|
||||
{peqCardLabels.filters?.label ?? "滤波器"}
|
||||
</span>
|
||||
<div
|
||||
ref={filterMenuRef}
|
||||
className="relative z-40 w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||||
className="relative z-40 w-[136px] shrink-0 rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
|
||||
border: "1px solid rgba(0,255,246,0.52)",
|
||||
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
|
||||
border: bandBypassed
|
||||
? "1px solid rgba(255,255,255,0.18)"
|
||||
: "1px solid rgba(0,255,246,0.52)",
|
||||
boxShadow: bandBypassed
|
||||
? "none"
|
||||
: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px]"
|
||||
className={cn(
|
||||
"w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px]",
|
||||
bandBypassed ? "text-white/50" : "text-[#E9FBFF]"
|
||||
)}
|
||||
onClick={() => setIsFilterMenuOpen((v) => !v)}
|
||||
>
|
||||
{normalizeFilterType(band.type)}
|
||||
{bandBypassed ? "BYPASS" : normalizeFilterType(band.type)}
|
||||
</button>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isFilterMenuOpen ? "rotate-180" : ""}`}
|
||||
className={`absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform ${bandBypassed ? "text-white/40" : "text-[#00FFF6]"} ${isFilterMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{isFilterMenuOpen && (
|
||||
<div
|
||||
@@ -1715,23 +1737,47 @@ export default function EQPage() {
|
||||
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
||||
}}
|
||||
>
|
||||
{/* 本机型无固件版本门槛,Bypass 始终可用 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-[13px] transition-colors border-b border-white/10 ${
|
||||
bandBypassed
|
||||
? "text-black font-semibold"
|
||||
: "text-white/90 hover:bg-white/10"
|
||||
}`}
|
||||
style={bandBypassed ? { background: "#00FFF6" } : undefined}
|
||||
onClick={() => {
|
||||
if (band.bypassed) {
|
||||
setIsFilterMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
captureBefore(takeUndoSnapshot());
|
||||
// 选中 BYPASS:该带旁通,提交时固定下发 type=10
|
||||
updateBand(selectedBand, { bypassed: true });
|
||||
setIsFilterMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
BYPASS
|
||||
</button>
|
||||
{FILTER_TYPES.map((type) => {
|
||||
const active = normalizeFilterType(band.type) === type;
|
||||
const active = !bandBypassed && normalizeFilterType(band.type) === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-[13px] transition-colors ${
|
||||
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
|
||||
active
|
||||
? "text-black font-semibold"
|
||||
: "text-white/90 hover:bg-white/10"
|
||||
}`}
|
||||
style={active ? { background: "#00FFF6" } : undefined}
|
||||
onClick={() => {
|
||||
if (normalizeFilterType(band.type) === type) {
|
||||
if (!band.bypassed && normalizeFilterType(band.type) === type) {
|
||||
setIsFilterMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
captureBefore(takeUndoSnapshot());
|
||||
updateBand(selectedBand, { type });
|
||||
updateBand(selectedBand, { type, bypassed: false });
|
||||
setIsFilterMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -1751,7 +1797,8 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("freq")}
|
||||
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
disabled={bandBypassed}
|
||||
className={`notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] transition-transform ${bandBypassed ? "text-white/40 cursor-not-allowed" : "text-white active:scale-95"}`}
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
translate="no"
|
||||
>
|
||||
@@ -1763,6 +1810,7 @@ export default function EQPage() {
|
||||
min={Math.log10(BAND_FREQ_MIN)}
|
||||
max={Math.log10(BAND_FREQ_MAX)}
|
||||
step={0.005}
|
||||
disabled={bandBypassed}
|
||||
onInteractionStart={() => beginBandSliderDrag("freq")}
|
||||
onInteractionEnd={() => endBandSliderDrag("freq")}
|
||||
onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))}
|
||||
@@ -1776,7 +1824,8 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("gain")}
|
||||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
disabled={bandBypassed}
|
||||
className={`text-[13px] font-semibold px-2.5 py-1 rounded-[8px] transition-transform ${bandBypassed ? "text-white/40 cursor-not-allowed" : "text-white active:scale-95"}`}
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
>
|
||||
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
|
||||
@@ -1787,6 +1836,7 @@ export default function EQPage() {
|
||||
min={BAND_GAIN_MIN}
|
||||
max={BAND_GAIN_MAX}
|
||||
step={0.1}
|
||||
disabled={bandBypassed}
|
||||
onInteractionStart={() => beginBandSliderDrag("gain")}
|
||||
onInteractionEnd={() => endBandSliderDrag("gain")}
|
||||
onChange={(v) => handleBandSliderChange("gain", v)}
|
||||
@@ -1800,7 +1850,8 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("q")}
|
||||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
disabled={bandBypassed}
|
||||
className={`text-[13px] font-semibold px-2.5 py-1 rounded-[8px] transition-transform ${bandBypassed ? "text-white/40 cursor-not-allowed" : "text-white active:scale-95"}`}
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
>
|
||||
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
|
||||
@@ -1811,6 +1862,7 @@ export default function EQPage() {
|
||||
min={BAND_Q_MIN}
|
||||
max={BAND_Q_MAX}
|
||||
step={0.01}
|
||||
disabled={bandBypassed}
|
||||
onInteractionStart={() => beginBandSliderDrag("q")}
|
||||
onInteractionEnd={() => endBandSliderDrag("q")}
|
||||
onChange={(v) => handleBandSliderChange("q", v)}
|
||||
|
||||
@@ -6,6 +6,24 @@ import { eqInterp } from "../eqFormatters";
|
||||
import type { EqBand, PeqEqUi } from "../types";
|
||||
import { PeqUndoButton } from "./PeqUndoButton";
|
||||
|
||||
/* ── Y 轴刻度范围(±dB)可选项与持久化 ── */
|
||||
const Y_RANGE_OPTIONS = [6, 12, 15, 20] as const;
|
||||
type YRangeDb = (typeof Y_RANGE_OPTIONS)[number];
|
||||
const DEFAULT_Y_RANGE_DB: YRangeDb = 12;
|
||||
const Y_RANGE_STORAGE_KEY = "x8-peq-chart-y-db-max";
|
||||
/** 每档范围的网格整数步长(均可整除边界,确保标签为整数) */
|
||||
const Y_RANGE_STEP: Record<YRangeDb, number> = { 6: 2, 12: 3, 15: 5, 20: 5 };
|
||||
|
||||
function loadYRangeDb(): YRangeDb {
|
||||
try {
|
||||
const raw = Number(localStorage.getItem(Y_RANGE_STORAGE_KEY));
|
||||
if (Y_RANGE_OPTIONS.includes(raw as YRangeDb)) return raw as YRangeDb;
|
||||
} catch {
|
||||
/* localStorage 不可用时回退默认值 */
|
||||
}
|
||||
return DEFAULT_Y_RANGE_DB;
|
||||
}
|
||||
|
||||
function LegendCurveIndicator({
|
||||
loading,
|
||||
color,
|
||||
@@ -73,6 +91,16 @@ export function FreqChart({
|
||||
const [chartWidth, setChartWidth] = useState(340);
|
||||
const W = chartWidth;
|
||||
const [isBandDragging, setIsBandDragging] = useState(false);
|
||||
const [yDbMax, setYDbMax] = useState<YRangeDb>(loadYRangeDb);
|
||||
|
||||
const handleYRangeChange = (range: YRangeDb) => {
|
||||
setYDbMax(range);
|
||||
try {
|
||||
localStorage.setItem(Y_RANGE_STORAGE_KEY, String(range));
|
||||
} catch {
|
||||
/* 忽略持久化失败,仅影响下次进入页面的默认值 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const node = chartContainerRef.current;
|
||||
@@ -89,7 +117,7 @@ export function FreqChart({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const Y_DB_MAX = 20;
|
||||
const Y_DB_MAX = yDbMax;
|
||||
|
||||
const freqToX = (f: number) => {
|
||||
const logMin = Math.log10(20),
|
||||
@@ -157,7 +185,7 @@ export function FreqChart({
|
||||
maxFreq: 20000,
|
||||
paddingY: 10,
|
||||
}),
|
||||
[bands, W, H],
|
||||
[bands, W, H, Y_DB_MAX],
|
||||
);
|
||||
const pathD = curveData.pathD;
|
||||
const fillD = curveData.fillD;
|
||||
@@ -225,7 +253,20 @@ export function FreqChart({
|
||||
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];
|
||||
// 按当前范围的整数步长生成网格标签(如 ±12 → 12,9,6,3,0,-3,-6,-9,-12)
|
||||
const gainLabels = useMemo(() => {
|
||||
const step = Y_RANGE_STEP[Y_DB_MAX];
|
||||
const intervals = Math.round((2 * Y_DB_MAX) / step);
|
||||
const labels: number[] = [];
|
||||
for (let i = 0; i <= intervals; i++) {
|
||||
labels.push(Number((Y_DB_MAX - i * step).toFixed(2)));
|
||||
}
|
||||
return labels;
|
||||
}, [Y_DB_MAX]);
|
||||
const formatGainLabel = (g: number) => {
|
||||
const v = Number(g.toFixed(2));
|
||||
return v > 0 ? `+${v}` : String(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ios-list-group p-3 mb-3">
|
||||
@@ -269,6 +310,27 @@ export function FreqChart({
|
||||
Equalized
|
||||
</span>
|
||||
</button>
|
||||
{/* Y 轴刻度范围切换 */}
|
||||
<div
|
||||
className="ml-auto flex shrink-0 items-center overflow-hidden rounded-[8px]"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
{Y_RANGE_OPTIONS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className="px-2 py-1 text-[10px] transition-all duration-150"
|
||||
style={
|
||||
yDbMax === r
|
||||
? { background: "#00FFF6", color: "#000", fontWeight: 600 }
|
||||
: { color: "rgba(255,255,255,0.4)" }
|
||||
}
|
||||
onClick={() => handleYRangeChange(r)}
|
||||
>
|
||||
±{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A/B + DIFF controls */}
|
||||
@@ -354,12 +416,18 @@ export function FreqChart({
|
||||
onPointerCancel={stopDragging}
|
||||
onPointerLeave={stopDragging}
|
||||
>
|
||||
<defs>
|
||||
{/* 绘图区裁剪:超出当前 Y 轴刻度范围的曲线部分直接截断不显示 */}
|
||||
<clipPath id="freq-chart-plot-clip">
|
||||
<rect x="0" y="10" width={W} height={H - 20} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* 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}
|
||||
{formatGainLabel(g)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -377,46 +445,48 @@ export function FreqChart({
|
||||
))}
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
<g clipPath="url(#freq-chart-plot-clip)">
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
{/* Band nodes with index */}
|
||||
{showEq &&
|
||||
bands.map((band, i) => (
|
||||
@@ -427,7 +497,8 @@ export function FreqChart({
|
||||
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);
|
||||
// 级联响应超出当前刻度范围时,手柄钳制在绘图区边缘保持可见可拖
|
||||
const handleY = Math.max(10, Math.min(H - 10, gainToY(combinedMagDbAtHandles[i] ?? 0)));
|
||||
return (
|
||||
<>
|
||||
<circle cx={freqToX(band.freq)} cy={handleY} r="14" fill="transparent" />
|
||||
|
||||
@@ -24,30 +24,30 @@ export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = {
|
||||
|
||||
/** 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 },
|
||||
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true, bypassed: false },
|
||||
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true, bypassed: false },
|
||||
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true, bypassed: false },
|
||||
];
|
||||
|
||||
/** Flat bands:无预设/未加载时展示的平直曲线(10 个 gain=0 的 PEAK) */
|
||||
export const FLAT_BANDS: EqBand[] = [
|
||||
{ freq: 80, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 150, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 350, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 750, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 1500, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 3000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 6000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 14000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 18000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 80, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 150, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 350, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 750, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 1500, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 3000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 6000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 14000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
{ freq: 18000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||
];
|
||||
|
||||
export const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
||||
|
||||
@@ -9,6 +9,7 @@ export type PeqUndoBand = {
|
||||
q: number;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
bypassed: boolean;
|
||||
};
|
||||
|
||||
export type PeqUndoSnapshot = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilterType } from "@/lib/peqAudio";
|
||||
import { getFilterType, TYPE_BYPASS } from "@/lib/peqAudio";
|
||||
import { normalizePeqFiltersForSubmit, type PeqFilter, type PeqState } from "@/lib/luxsinApi";
|
||||
import type { EqBand } from "./types";
|
||||
import { clampPeqPresetName } from "./peqPresetName";
|
||||
@@ -48,15 +48,22 @@ export function bandToPeqFilter(b: {
|
||||
gain: number;
|
||||
q: number;
|
||||
type: string | number;
|
||||
bypassed?: boolean;
|
||||
}): PeqFilter {
|
||||
return {
|
||||
fc: b.freq,
|
||||
gain: b.gain,
|
||||
q: b.q,
|
||||
type: getFilterType(b.type),
|
||||
// 旁通时固定下发 type=10;原类型保留在 UI 侧,重新打开时直接恢复
|
||||
type: b.bypassed ? TYPE_BYPASS : getFilterType(b.type),
|
||||
};
|
||||
}
|
||||
|
||||
/** 设备回读的 type=10 即旁通标记(原类型已丢失,UI 侧回退 PEAK)。 */
|
||||
export function isBypassFilterType(type: unknown): boolean {
|
||||
return Number(type) === TYPE_BYPASS;
|
||||
}
|
||||
|
||||
export function importedFiltersToPeqFilters(filters: unknown): PeqFilter[] {
|
||||
return normalizePeqFiltersForSubmit(filters).map((f) => {
|
||||
const item = f as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -11,6 +11,8 @@ export type EqBand = {
|
||||
q: number;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
/** 旁通:提交时该带固定下发 type=10,type 仍保留原滤波器类型供重新打开时恢复 */
|
||||
bypassed: boolean;
|
||||
};
|
||||
|
||||
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
||||
|
||||
Reference in New Issue
Block a user