多语言,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.
|
> 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
|
## v2026.09.22
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export const TYPE_HIGHPASS = 1;
|
|||||||
export const TYPE_BANDPASS = 2;
|
export const TYPE_BANDPASS = 2;
|
||||||
export const TYPE_NOTCH = 3;
|
export const TYPE_NOTCH = 3;
|
||||||
export const TYPE_ALLPASS = 7;
|
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)
|
// Filter type mapping (device uses numeric type; pass-through when already a number)
|
||||||
export function getFilterType(typeName: string | number): number {
|
export function getFilterType(typeName: string | number): number {
|
||||||
@@ -340,6 +345,8 @@ export type PeqBandForResponse = {
|
|||||||
freq: number;
|
freq: number;
|
||||||
q: number;
|
q: number;
|
||||||
type: string | number;
|
type: string | number;
|
||||||
|
/** 旁通:该带不参与级联响应(曲线贡献 0 dB),type 仍保留原滤波器类型。 */
|
||||||
|
bypassed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,7 +358,7 @@ export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number):
|
|||||||
const list: Coeff[] = [];
|
const list: Coeff[] = [];
|
||||||
bands.forEach((b) => {
|
bands.forEach((b) => {
|
||||||
if (!b.enabled) return;
|
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) {
|
if (list.length === 0) {
|
||||||
return f.map(() => 0);
|
return f.map(() => 0);
|
||||||
@@ -372,7 +379,7 @@ export function sampleCombinedPeqMagnitudeDb(
|
|||||||
const list: Coeff[] = [];
|
const list: Coeff[] = [];
|
||||||
bands.forEach((b) => {
|
bands.forEach((b) => {
|
||||||
if (!b.enabled) return;
|
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;
|
if (list.length === 0) return 0;
|
||||||
const out = getFreqznList(list, fs, [fc]);
|
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 localeVi from "./data-vi.json";
|
||||||
import localePt from "./data-pt.json";
|
import localePt from "./data-pt.json";
|
||||||
import localePl from "./data-pl.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=
|
* 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;
|
export const DEFAULT_DEVICE_LANGUAGE = 0;
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ const LOCALE_PACKS = [
|
|||||||
localeVi,
|
localeVi,
|
||||||
localePt,
|
localePt,
|
||||||
localePl,
|
localePl,
|
||||||
|
localeRu,
|
||||||
|
localeJa,
|
||||||
] as LocalePack[];
|
] 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 = [
|
export const LANGUAGE_NATIVE_LABELS = [
|
||||||
"English",
|
"English",
|
||||||
@@ -52,6 +57,8 @@ export const LANGUAGE_NATIVE_LABELS = [
|
|||||||
"Tiếng Việt",
|
"Tiếng Việt",
|
||||||
"Português (Portugal)",
|
"Português (Portugal)",
|
||||||
"Polski",
|
"Polski",
|
||||||
|
"Русский",
|
||||||
|
"日本語",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 是否为设备支持的语言(0/1/2),只有这些才会下发设备。 */
|
/** 是否为设备支持的语言(0/1/2),只有这些才会下发设备。 */
|
||||||
@@ -69,7 +76,7 @@ export function resolveDeviceLanguage(language: number | undefined): number {
|
|||||||
return language ?? DEFAULT_DEVICE_LANGUAGE;
|
return language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 前端语言覆盖(仅非设备语言 3..9,持久化到 localStorage,绝不下发设备) ── */
|
/* ── 前端语言覆盖(仅非设备语言 3..11,持久化到 localStorage,绝不下发设备) ── */
|
||||||
const UI_LANGUAGE_OVERRIDE_KEY = "luxsin.uiLanguageOverride";
|
const UI_LANGUAGE_OVERRIDE_KEY = "luxsin.uiLanguageOverride";
|
||||||
|
|
||||||
function readStoredOverride(): number | null {
|
function readStoredOverride(): number | null {
|
||||||
@@ -91,7 +98,7 @@ export function getUiLanguageOverride(): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置前端语言覆盖。传入仅前端语言(3..9)时生效并持久化;
|
* 设置前端语言覆盖。传入仅前端语言(3..11)时生效并持久化;
|
||||||
* 传入设备语言(0/1/2)或 null 时清除覆盖,恢复跟随设备语言。
|
* 传入设备语言(0/1/2)或 null 时清除覆盖,恢复跟随设备语言。
|
||||||
*/
|
*/
|
||||||
export function setUiLanguageOverride(index: number | null): number | null {
|
export function setUiLanguageOverride(index: number | null): number | null {
|
||||||
|
|||||||
+70
-18
@@ -83,6 +83,7 @@ import {
|
|||||||
fetchEqSyncPeq,
|
fetchEqSyncPeq,
|
||||||
getUniquePresetName,
|
getUniquePresetName,
|
||||||
importedFiltersToPeqFilters,
|
importedFiltersToPeqFilters,
|
||||||
|
isBypassFilterType,
|
||||||
} from "./eq/peqMappers";
|
} from "./eq/peqMappers";
|
||||||
import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types";
|
import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types";
|
||||||
|
|
||||||
@@ -355,12 +356,15 @@ export default function EQPage() {
|
|||||||
return filters.map((f: any) => {
|
return filters.map((f: any) => {
|
||||||
const rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000;
|
const rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000;
|
||||||
const freq = normalizeBandFreqHz(rawFreq) ?? 1000;
|
const freq = normalizeBandFreqHz(rawFreq) ?? 1000;
|
||||||
|
// 设备用 type=10 表示旁通,原类型已丢失;回退 PEAK 供重新打开时使用
|
||||||
|
const bypassed = isBypassFilterType(f.type);
|
||||||
return {
|
return {
|
||||||
freq: Math.round(freq),
|
freq: Math.round(freq),
|
||||||
gain: Number(f.gain || 0),
|
gain: Number(f.gain || 0),
|
||||||
q: Number(f.q || 1),
|
q: Number(f.q || 1),
|
||||||
type: normalizeFilterType(f.type),
|
type: bypassed ? "PEAK" : normalizeFilterType(f.type),
|
||||||
enabled: f.enabled !== undefined ? f.enabled : true,
|
enabled: f.enabled !== undefined ? f.enabled : true,
|
||||||
|
bypassed,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -415,6 +419,7 @@ export default function EQPage() {
|
|||||||
freq: number;
|
freq: number;
|
||||||
gain: number;
|
gain: number;
|
||||||
q: number;
|
q: number;
|
||||||
|
bypassed: boolean;
|
||||||
}>(10);
|
}>(10);
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
@@ -449,6 +454,7 @@ export default function EQPage() {
|
|||||||
freq: Number(freq.toFixed(2)),
|
freq: Number(freq.toFixed(2)),
|
||||||
gain: Number(gain.toFixed(2)),
|
gain: Number(gain.toFixed(2)),
|
||||||
q: Number(q.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 band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
|
||||||
|
const bandBypassed = !!band.bypassed;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBandSliderPreview(null);
|
setBandSliderPreview(null);
|
||||||
@@ -1682,29 +1689,44 @@ export default function EQPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Band detail card ── */}
|
{/* ── Band detail card ── */}
|
||||||
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
|
<div
|
||||||
{/* Filter type selector */}
|
className={cn(
|
||||||
<div className="flex items-center justify-between">
|
"ios-list-group p-4 space-y-5 relative overflow-visible",
|
||||||
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
|
// 下拉展开时抬升整卡层级,避免末项被固定底部导航(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
|
<div
|
||||||
ref={filterMenuRef}
|
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={{
|
style={{
|
||||||
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
|
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)",
|
border: bandBypassed
|
||||||
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
|
? "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
|
<button
|
||||||
type="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)}
|
onClick={() => setIsFilterMenuOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
{normalizeFilterType(band.type)}
|
{bandBypassed ? "BYPASS" : normalizeFilterType(band.type)}
|
||||||
</button>
|
</button>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={13}
|
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 && (
|
{isFilterMenuOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -1715,23 +1737,47 @@ export default function EQPage() {
|
|||||||
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
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) => {
|
{FILTER_TYPES.map((type) => {
|
||||||
const active = normalizeFilterType(band.type) === type;
|
const active = !bandBypassed && normalizeFilterType(band.type) === type;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full px-3 py-2 text-left text-[13px] transition-colors ${
|
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}
|
style={active ? { background: "#00FFF6" } : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (normalizeFilterType(band.type) === type) {
|
if (!band.bypassed && normalizeFilterType(band.type) === type) {
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
captureBefore(takeUndoSnapshot());
|
captureBefore(takeUndoSnapshot());
|
||||||
updateBand(selectedBand, { type });
|
updateBand(selectedBand, { type, bypassed: false });
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1751,7 +1797,8 @@ export default function EQPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openBandParamDialog("freq")}
|
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}
|
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||||
translate="no"
|
translate="no"
|
||||||
>
|
>
|
||||||
@@ -1763,6 +1810,7 @@ export default function EQPage() {
|
|||||||
min={Math.log10(BAND_FREQ_MIN)}
|
min={Math.log10(BAND_FREQ_MIN)}
|
||||||
max={Math.log10(BAND_FREQ_MAX)}
|
max={Math.log10(BAND_FREQ_MAX)}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
|
disabled={bandBypassed}
|
||||||
onInteractionStart={() => beginBandSliderDrag("freq")}
|
onInteractionStart={() => beginBandSliderDrag("freq")}
|
||||||
onInteractionEnd={() => endBandSliderDrag("freq")}
|
onInteractionEnd={() => endBandSliderDrag("freq")}
|
||||||
onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))}
|
onChange={(v) => handleBandSliderChange("freq", Math.round(Math.pow(10, v)))}
|
||||||
@@ -1776,7 +1824,8 @@ export default function EQPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openBandParamDialog("gain")}
|
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}
|
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||||
>
|
>
|
||||||
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
|
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
|
||||||
@@ -1787,6 +1836,7 @@ export default function EQPage() {
|
|||||||
min={BAND_GAIN_MIN}
|
min={BAND_GAIN_MIN}
|
||||||
max={BAND_GAIN_MAX}
|
max={BAND_GAIN_MAX}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
|
disabled={bandBypassed}
|
||||||
onInteractionStart={() => beginBandSliderDrag("gain")}
|
onInteractionStart={() => beginBandSliderDrag("gain")}
|
||||||
onInteractionEnd={() => endBandSliderDrag("gain")}
|
onInteractionEnd={() => endBandSliderDrag("gain")}
|
||||||
onChange={(v) => handleBandSliderChange("gain", v)}
|
onChange={(v) => handleBandSliderChange("gain", v)}
|
||||||
@@ -1800,7 +1850,8 @@ export default function EQPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openBandParamDialog("q")}
|
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}
|
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||||
>
|
>
|
||||||
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
|
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
|
||||||
@@ -1811,6 +1862,7 @@ export default function EQPage() {
|
|||||||
min={BAND_Q_MIN}
|
min={BAND_Q_MIN}
|
||||||
max={BAND_Q_MAX}
|
max={BAND_Q_MAX}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
|
disabled={bandBypassed}
|
||||||
onInteractionStart={() => beginBandSliderDrag("q")}
|
onInteractionStart={() => beginBandSliderDrag("q")}
|
||||||
onInteractionEnd={() => endBandSliderDrag("q")}
|
onInteractionEnd={() => endBandSliderDrag("q")}
|
||||||
onChange={(v) => handleBandSliderChange("q", v)}
|
onChange={(v) => handleBandSliderChange("q", v)}
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ import { eqInterp } from "../eqFormatters";
|
|||||||
import type { EqBand, PeqEqUi } from "../types";
|
import type { EqBand, PeqEqUi } from "../types";
|
||||||
import { PeqUndoButton } from "./PeqUndoButton";
|
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({
|
function LegendCurveIndicator({
|
||||||
loading,
|
loading,
|
||||||
color,
|
color,
|
||||||
@@ -73,6 +91,16 @@ export function FreqChart({
|
|||||||
const [chartWidth, setChartWidth] = useState(340);
|
const [chartWidth, setChartWidth] = useState(340);
|
||||||
const W = chartWidth;
|
const W = chartWidth;
|
||||||
const [isBandDragging, setIsBandDragging] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const node = chartContainerRef.current;
|
const node = chartContainerRef.current;
|
||||||
@@ -89,7 +117,7 @@ export function FreqChart({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const Y_DB_MAX = 20;
|
const Y_DB_MAX = yDbMax;
|
||||||
|
|
||||||
const freqToX = (f: number) => {
|
const freqToX = (f: number) => {
|
||||||
const logMin = Math.log10(20),
|
const logMin = Math.log10(20),
|
||||||
@@ -157,7 +185,7 @@ export function FreqChart({
|
|||||||
maxFreq: 20000,
|
maxFreq: 20000,
|
||||||
paddingY: 10,
|
paddingY: 10,
|
||||||
}),
|
}),
|
||||||
[bands, W, H],
|
[bands, W, H, Y_DB_MAX],
|
||||||
);
|
);
|
||||||
const pathD = curveData.pathD;
|
const pathD = curveData.pathD;
|
||||||
const fillD = curveData.fillD;
|
const fillD = curveData.fillD;
|
||||||
@@ -225,7 +253,20 @@ export function FreqChart({
|
|||||||
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
|
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
|
||||||
|
|
||||||
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
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 (
|
return (
|
||||||
<div className="ios-list-group p-3 mb-3">
|
<div className="ios-list-group p-3 mb-3">
|
||||||
@@ -269,6 +310,27 @@ export function FreqChart({
|
|||||||
Equalized
|
Equalized
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* A/B + DIFF controls */}
|
{/* A/B + DIFF controls */}
|
||||||
@@ -354,12 +416,18 @@ export function FreqChart({
|
|||||||
onPointerCancel={stopDragging}
|
onPointerCancel={stopDragging}
|
||||||
onPointerLeave={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 */}
|
{/* dB grid */}
|
||||||
{gainLabels.map((g) => (
|
{gainLabels.map((g) => (
|
||||||
<g key={g}>
|
<g key={g}>
|
||||||
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)} stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
|
<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)">
|
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
|
||||||
{g > 0 ? `+${g}` : g}
|
{formatGainLabel(g)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
@@ -377,46 +445,48 @@ export function FreqChart({
|
|||||||
))}
|
))}
|
||||||
{/* Zero line */}
|
{/* Zero line */}
|
||||||
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
|
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
|
||||||
{/* Fill */}
|
<g clipPath="url(#freq-chart-plot-clip)">
|
||||||
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
|
{/* Fill */}
|
||||||
{/* EQ curve */}
|
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
|
||||||
{showEq && (
|
{/* EQ curve */}
|
||||||
<path
|
{showEq && (
|
||||||
ref={curvePathRef}
|
<path
|
||||||
d={pathD}
|
ref={curvePathRef}
|
||||||
fill="none"
|
d={pathD}
|
||||||
stroke="#FFED00"
|
fill="none"
|
||||||
strokeWidth="2"
|
stroke="#FFED00"
|
||||||
strokeLinecap="round"
|
strokeWidth="2"
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
/>
|
strokeLinejoin="round"
|
||||||
)}
|
/>
|
||||||
{/* Raw */}
|
)}
|
||||||
{rawPathD && showRaw && (
|
{/* Raw */}
|
||||||
<path
|
{rawPathD && showRaw && (
|
||||||
ref={rawPathRef}
|
<path
|
||||||
d={rawPathD}
|
ref={rawPathRef}
|
||||||
fill="none"
|
d={rawPathD}
|
||||||
stroke="#ffffff"
|
fill="none"
|
||||||
strokeWidth="1.6"
|
stroke="#ffffff"
|
||||||
strokeLinecap="round"
|
strokeWidth="1.6"
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
opacity="0.9"
|
strokeLinejoin="round"
|
||||||
/>
|
opacity="0.9"
|
||||||
)}
|
/>
|
||||||
{/* Equalized = EQ + Raw */}
|
)}
|
||||||
{equalizedPathD && showEqualized && (
|
{/* Equalized = EQ + Raw */}
|
||||||
<path
|
{equalizedPathD && showEqualized && (
|
||||||
ref={equalizedPathRef}
|
<path
|
||||||
d={equalizedPathD}
|
ref={equalizedPathRef}
|
||||||
fill="none"
|
d={equalizedPathD}
|
||||||
stroke="#23d2fe"
|
fill="none"
|
||||||
strokeWidth="1.8"
|
stroke="#23d2fe"
|
||||||
strokeLinecap="round"
|
strokeWidth="1.8"
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
opacity="0.95"
|
strokeLinejoin="round"
|
||||||
/>
|
opacity="0.95"
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
{/* Band nodes with index */}
|
{/* Band nodes with index */}
|
||||||
{showEq &&
|
{showEq &&
|
||||||
bands.map((band, i) => (
|
bands.map((band, i) => (
|
||||||
@@ -427,7 +497,8 @@ export function FreqChart({
|
|||||||
const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)";
|
const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)";
|
||||||
const strokeColor = isSelected ? "#FFED00" : "#FFED00";
|
const strokeColor = isSelected ? "#FFED00" : "#FFED00";
|
||||||
const textColor = isSelected ? "#000000" : "#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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<circle cx={freqToX(band.freq)} cy={handleY} r="14" fill="transparent" />
|
<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 */
|
/** Default bands matching reference image */
|
||||||
export const DEFAULT_BANDS: EqBand[] = [
|
export const DEFAULT_BANDS: EqBand[] = [
|
||||||
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", 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 },
|
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true },
|
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true, bypassed: false },
|
||||||
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true },
|
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true, bypassed: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Flat bands:无预设/未加载时展示的平直曲线(10 个 gain=0 的 PEAK) */
|
/** Flat bands:无预设/未加载时展示的平直曲线(10 个 gain=0 的 PEAK) */
|
||||||
export const FLAT_BANDS: EqBand[] = [
|
export const FLAT_BANDS: EqBand[] = [
|
||||||
{ freq: 80, 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 },
|
{ freq: 150, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 350, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 350, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 750, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 750, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 1500, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 1500, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 3000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 3000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 6000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 6000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 10000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 10000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 14000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 14000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
{ freq: 18000, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
{ freq: 18000, gain: 0, q: 1.41, type: "PEAK", enabled: true, bypassed: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
export const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type PeqUndoBand = {
|
|||||||
q: number;
|
q: number;
|
||||||
type: string;
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
bypassed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PeqUndoSnapshot = {
|
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 { normalizePeqFiltersForSubmit, type PeqFilter, type PeqState } from "@/lib/luxsinApi";
|
||||||
import type { EqBand } from "./types";
|
import type { EqBand } from "./types";
|
||||||
import { clampPeqPresetName } from "./peqPresetName";
|
import { clampPeqPresetName } from "./peqPresetName";
|
||||||
@@ -48,15 +48,22 @@ export function bandToPeqFilter(b: {
|
|||||||
gain: number;
|
gain: number;
|
||||||
q: number;
|
q: number;
|
||||||
type: string | number;
|
type: string | number;
|
||||||
|
bypassed?: boolean;
|
||||||
}): PeqFilter {
|
}): PeqFilter {
|
||||||
return {
|
return {
|
||||||
fc: b.freq,
|
fc: b.freq,
|
||||||
gain: b.gain,
|
gain: b.gain,
|
||||||
q: b.q,
|
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[] {
|
export function importedFiltersToPeqFilters(filters: unknown): PeqFilter[] {
|
||||||
return normalizePeqFiltersForSubmit(filters).map((f) => {
|
return normalizePeqFiltersForSubmit(filters).map((f) => {
|
||||||
const item = f as unknown as Record<string, unknown>;
|
const item = f as unknown as Record<string, unknown>;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type EqBand = {
|
|||||||
q: number;
|
q: number;
|
||||||
type: string;
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** 旁通:提交时该带固定下发 type=10,type 仍保留原滤波器类型供重新打开时恢复 */
|
||||||
|
bypassed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user