多语言,EQ 频响曲线 Y 轴可调,滤波器类型新增 Bypass

This commit is contained in:
eafonyang
2026-11-03 11:22:56 +08:00
parent 45197947cf
commit d9e05d34ad
11 changed files with 2428 additions and 93 deletions
+7
View File
@@ -2,6 +2,13 @@
> This document records feature updates and bug fixes for the Luxsin X9 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. Requires firmware version 2.0.12.0 (build 2012) or later.
- 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
+6
View File
@@ -15,6 +15,8 @@ export const FIRMWARE_FEATURE_MIN_VERSION = {
peqPresetRenameAndSort: 2008,
/** Audio 页面耳机最大音量和耳机启动音量设置。 */
hpVolumeSettings: 2011,
/** EQ 单个滤波器的 Bypass 开关(旁通时下发 type=10)。 */
peqBandBypass: 2012,
} as const;
export type FirmwareFeatureKey = keyof typeof FIRMWARE_FEATURE_MIN_VERSION;
@@ -38,3 +40,7 @@ export function supportsPeqPresetRenameAndSort(firmwareVersion: number): boolean
export function supportsHpVolumeSettings(firmwareVersion: number): boolean {
return isFirmwareFeatureAvailable("hpVolumeSettings", firmwareVersion);
}
export function supportsPeqBandBypass(firmwareVersion: number): boolean {
return isFirmwareFeatureAvailable("peqBandBypass", firmwareVersion);
}
+9 -2
View File
@@ -14,6 +14,11 @@ export const TYPE_NOTCH = 3;
export const TYPE_ALLPASS = 7;
/** First-order low-pass (subwoofer LPF curve). */
export const LPF_1ST = 8;
/**
* 旁通标记:设备协议用 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 {
@@ -352,6 +357,8 @@ export type PeqBandForResponse = {
freq: number;
q: number;
type: string | number;
/** 旁通:该带不参与级联响应(曲线贡献 0 dB),type 仍保留原滤波器类型。 */
bypassed?: boolean;
};
/**
@@ -363,7 +370,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);
@@ -384,7 +391,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
+11 -4
View File
@@ -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 {
+79 -19
View File
@@ -37,7 +37,10 @@ import {
type PeqState,
buildPeqPresetBody,
} from "@/lib/luxsinApi";
import { supportsPeqPresetRenameAndSort } from "@/config/firmwareFeatures";
import {
supportsPeqPresetRenameAndSort,
supportsPeqBandBypass,
} from "@/config/firmwareFeatures";
import { getFilterType, getFilterShortName } from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
@@ -74,6 +77,7 @@ import {
buildPeqCatalogSyncKey,
getUniquePresetName,
bandToPeqFilter,
isBypassFilterType,
fetchEqSyncPeq,
importedFiltersToPeqFilters,
} from "./eq/utils";
@@ -127,6 +131,8 @@ export default function EQPage() {
[deviceState?.version],
);
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
/** 单个滤波器 Bypass 开关:仅固件 >= 2012 开放,低版本完全不渲染。 */
const peqBandBypassSupported = supportsPeqBandBypass(firmwareVersion);
const peqCardLabels = useMemo(() => {
const pack = resolveLocalePack(effectiveLanguage);
@@ -372,12 +378,15 @@ export default function EQPage() {
}
return filters.map((f: any) => {
const freq = f.fc || f.freq || f.frequency || 1000;
// 设备用 type=10 表示旁通,原类型已丢失;回退 PEAK 供重新打开时使用
const bypassed = isBypassFilterType(f.type);
return {
freq: Number(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,
};
});
};
@@ -435,6 +444,7 @@ export default function EQPage() {
freq: number;
gain: number;
q: number;
bypassed: boolean;
}>(10);
for (let i = 1; i <= 10; i++) {
@@ -475,6 +485,7 @@ export default function EQPage() {
freq: Number(freq.toFixed(2)),
gain: Number(gain.toFixed(2)),
q: Number(q.toFixed(2)),
bypassed: false,
};
}
@@ -1326,6 +1337,8 @@ export default function EQPage() {
const band =
bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
/** 低固件不渲染开关,此时也不禁用选择器,避免设备回读 type=10 后无法改回。 */
const bandBypassed = peqBandBypassSupported && !!band.bypassed;
const handleApplyB = useCallback(async () => {
const item = peqItems[headphoneIdx];
@@ -1837,36 +1850,48 @@ 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">
<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={cn(
"relative w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
"relative w-[136px] shrink-0 rounded-[12px] overflow-visible backdrop-blur-md transition-all",
isFilterMenuOpen && "z-30"
)}
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
@@ -1877,8 +1902,34 @@ export default function EQPage() {
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{peqBandBypassSupported && (
<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}
@@ -1890,12 +1941,15 @@ export default function EQPage() {
}`}
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);
}}
>
@@ -1917,7 +1971,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"
>
@@ -1929,6 +1984,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 =>
@@ -1946,7 +2002,8 @@ export default function EQPage() {
<button
type="button"
onClick={() => openBandParamDialog("gain")}
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"
>
@@ -1958,6 +2015,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)}
@@ -1973,7 +2031,8 @@ export default function EQPage() {
<button
type="button"
onClick={() => openBandParamDialog("q")}
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"
>
@@ -1985,6 +2044,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)}
+125 -46
View File
@@ -9,6 +9,24 @@ import type { PeqEqUi } from "../constants";
import { eqInterp } from "../utils";
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 = "x9-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;
}
/* ── Legend indicator ── */
function LegendCurveIndicator({
loading,
@@ -48,7 +66,15 @@ export function FreqChart({
onBandSelect,
eqUi,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
bands: Array<{
freq: number;
gain: number;
q: number;
type: string;
enabled: boolean;
/** 旁通的带贡献 0 dB,曲线与手柄纵坐标自动排除它 */
bypassed?: boolean;
}>;
rawCurve: number[] | null;
rawCurveLoading?: boolean;
selectedBand: number;
@@ -76,6 +102,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;
@@ -92,7 +128,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), logMax = Math.log10(20000);
@@ -158,7 +194,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;
@@ -226,7 +262,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">
@@ -268,6 +317,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 */}
@@ -342,13 +412,19 @@ 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={`gain-${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>
))}
@@ -359,46 +435,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) => (
<g
@@ -412,7 +490,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
+20 -20
View File
@@ -43,30 +43,30 @@ export type PeqEqUi = NonNullable<
/* ── Default bands matching reference image ── */
export const DEFAULT_BANDS = [
{ 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: typeof DEFAULT_BANDS = [
{ 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 },
];
/* ── PEQ catalog item type ── */
@@ -9,6 +9,7 @@ export type PeqUndoBand = {
q: number;
type: string;
enabled: boolean;
bypassed: boolean;
};
export type PeqUndoSnapshot = {
+10 -2
View File
@@ -1,6 +1,6 @@
import type { PeqFilter, PeqState } from "@/lib/luxsinApi";
import { normalizePeqFiltersForSubmit } from "@/lib/luxsinApi";
import { getFilterShortName, getFilterType } from "@/lib/peqAudio";
import { getFilterShortName, getFilterType, TYPE_BYPASS } from "@/lib/peqAudio";
import {
BAND_FREQ_MIN,
BAND_FREQ_MAX,
@@ -140,6 +140,7 @@ export function cloneBands(
q: number;
type: string;
enabled: boolean;
bypassed: boolean;
}>,
) {
return source.map((band) => ({ ...band }));
@@ -219,11 +220,18 @@ 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;
}