修复&优化听力补偿模块功能

This commit is contained in:
eafonyang
2026-09-09 18:26:28 +08:00
parent ab5d85841b
commit abd9033b49
15 changed files with 507 additions and 3 deletions
+5
View File
@@ -2,6 +2,11 @@
> 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.09.09
### New Features
- Added Hearing Compensation on the Effects page: enable the feature, pick one of the hearing profiles stored on the device, and view the left/right ear compensation curves as a smooth chart. Requires firmware version 1.0.30.0 or later.
## v2026.08.17
### Changes
+25 -3
View File
@@ -1,7 +1,7 @@
import { lazy } from "react";
import { lazy, useEffect, useRef } from "react";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Route, Switch, Router } from "wouter";
import { Route, Switch, Router, useLocation } from "wouter";
import { useHashLocation } from "wouter/use-hash-location";
import ErrorBoundary from "./components/ErrorBoundary";
import PageSuspense from "./components/PageSuspense";
@@ -16,7 +16,7 @@ import BluetoothPage from "./pages/BluetoothPage";
import SystemPage from "./pages/SystemPage";
import IOPage from "./pages/IOPage";
import VUPage from "./pages/VUPage";
import SelectPage from "./pages/SelectPage";
import SelectPage, { consumeSelectReturnScroll } from "./pages/SelectPage";
import CommunityPage from "./pages/CommunityPage";
import ChangelogPage from "./pages/ChangelogPage";
import { AIDrawerProvider } from "./contexts/AIDrawerContext";
@@ -42,6 +42,27 @@ function EQPageRoute() {
);
}
/**
* Restores the calling page's scroll position when returning from /select.
* The select page is short, so the browser clamps window scroll to 0 while on
* it; navigateToSelect() captures the offset before navigating and we re-apply
* it once the calling page (e.g. Effects) has remounted.
*/
function SelectScrollRestore() {
const [location] = useLocation();
const prevRef = useRef(location);
useEffect(() => {
const prev = prevRef.current;
prevRef.current = location;
if (prev !== "/select" || location === "/select") return;
const y = consumeSelectReturnScroll();
if (y == null || y <= 0) return;
// Wait a frame so the returning page has rendered and the document has height.
requestAnimationFrame(() => window.scrollTo(0, y));
}, [location]);
return null;
}
function AppRouter() {
return (
<Switch>
@@ -73,6 +94,7 @@ function App() {
<Toaster />
<Router hook={useHashLocation}>
<AIDrawerProvider>
<SelectScrollRestore />
<div className={appShellOuterClass}>
<div className={appShellColumnClass}>
<DesktopAIShell>
@@ -0,0 +1,186 @@
import { useEffect, useMemo, useRef } from "react";
import * as echarts from "echarts";
import {
buildHearingSplineSeries,
resolveHearingFreqLabels,
} from "@/lib/hearingCurve";
const LEFT_COLOR = "rgb(0, 255, 246)";
const RIGHT_COLOR = "#FF5A36";
type HearingCompensationChartProps = {
left: number[];
right: number[];
leftLabel?: string;
rightLabel?: string;
className?: string;
};
export default function HearingCompensationChart({
left,
right,
leftLabel = "Left",
rightLabel = "Right",
className,
}: HearingCompensationChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(null);
const leftSeries = useMemo(() => buildHearingSplineSeries(left), [left]);
const rightSeries = useMemo(() => buildHearingSplineSeries(right), [right]);
// X axis adapts to the control-point count: 7-band (legacy) or 10-band (adds 3k/5k/6k).
const pointCount = Math.max(left.length, right.length);
const freqLabels = useMemo(() => resolveHearingFreqLabels(pointCount), [pointCount]);
const xMax = Math.max(0, pointCount - 1);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const option: echarts.EChartsOption = {
animation: false,
legend: {
show: true,
top: 8,
data: [
{
name: leftLabel,
icon: "circle",
textStyle: { color: LEFT_COLOR },
itemStyle: { color: LEFT_COLOR, borderColor: LEFT_COLOR },
},
{
name: rightLabel,
icon: "circle",
textStyle: { color: RIGHT_COLOR },
itemStyle: { color: RIGHT_COLOR, borderColor: RIGHT_COLOR },
},
],
textStyle: { fontSize: 13 },
},
grid: {
left: 48,
right: 36,
top: 48,
bottom: 28,
},
graphic: [
{
type: "text",
right: 10,
bottom: 6,
style: {
text: "Hz",
fill: "#999",
fontSize: 14,
},
z: 100,
},
],
xAxis: {
type: "value",
min: 0,
max: xMax,
interval: 1,
axisLabel: {
formatter: (value: number) => {
const idx = Math.round(value);
if (Math.abs(value - idx) > 1e-6) return "";
return freqLabels[idx] ?? "";
},
fontSize: 14,
color: "#999",
},
splitLine: {
show: true,
lineStyle: { type: "solid", color: "#3A4348" },
},
axisLine: {
lineStyle: { color: "#3A4348" },
},
},
yAxis: {
type: "value",
min: -80,
// Top raised to +20 dB: levels above 70 map to positive dB and were
// clipped at the old max of 0, leaving gaps at the top of the curve.
max: 20,
interval: 10,
name: "dB",
nameLocation: "end",
nameGap: 8,
nameTextStyle: {
color: "#999",
fontSize: 14,
align: "right",
padding: [0, 8, 0, 0],
},
axisLabel: {
fontSize: 14,
color: "#999",
},
splitLine: {
show: true,
lineStyle: { type: "solid", color: "#3A4348" },
},
axisLine: {
lineStyle: { color: "#3A4348" },
},
},
series: [
{
name: leftLabel,
type: "line",
data: leftSeries,
showSymbol: false,
smooth: false,
lineStyle: { color: LEFT_COLOR, width: 2 },
itemStyle: { color: LEFT_COLOR },
},
{
name: rightLabel,
type: "line",
data: rightSeries,
showSymbol: false,
smooth: false,
lineStyle: { color: RIGHT_COLOR, width: 2 },
itemStyle: { color: RIGHT_COLOR },
},
],
};
if (!chartRef.current) {
chartRef.current = echarts.init(el, undefined, { renderer: "canvas" });
}
chartRef.current.setOption(option, true);
const onResize = () => chartRef.current?.resize();
const ro = new ResizeObserver(onResize);
ro.observe(el);
window.addEventListener("resize", onResize);
return () => {
ro.disconnect();
window.removeEventListener("resize", onResize);
};
}, [leftSeries, rightSeries, leftLabel, rightLabel, freqLabels, xMax]);
useEffect(() => {
return () => {
chartRef.current?.dispose();
chartRef.current = null;
};
}, []);
return (
<div
ref={containerRef}
className={
className
?? "w-full h-[min(52vw,240px)] min-h-[180px] md:h-[320px] lg:h-[380px] rounded-lg bg-black/30"
}
aria-hidden
/>
);
}
+2
View File
@@ -11,6 +11,8 @@ export const FIRMWARE_FEATURE_MIN_VERSION = {
ambientLed: 28,
/** PEQ preset rename (peqRename) and reorder (peqSoft) in manage dialog. */
peqPresetRenameAndSort: 29,
/** Hearing compensation section on the Effects page. */
hearingCompensation: 30,
} as const;
export type FirmwareFeatureKey = keyof typeof FIRMWARE_FEATURE_MIN_VERSION;
+128
View File
@@ -0,0 +1,128 @@
/** Hearing compensation curve helpers: map levels → dB and cubic-spline smooth. */
/** 7-band control points (legacy): 125/250/500/1k/2k/4k/8k. */
export const HEARING_FREQ_LABELS = ["125", "250", "500", "1k", "2k", "4k", "8k"] as const;
/** 10-band control points: adds 3k/5k/6k → 125/250/500/1k/2k/3k/4k/5k/6k/8k (ascending). */
export const HEARING_FREQ_LABELS_10 = [
"125", "250", "500", "1k", "2k", "3k", "4k", "5k", "6k", "8k",
] as const;
/**
* Resolve the X-axis frequency labels for a given control-point `count`.
* 10 points → the extended 3k/5k/6k set; any other count → the legacy 7-band
* set. Keeps the chart compatible with both 7-point and 10-point hearing data.
*/
export function resolveHearingFreqLabels(count: number): readonly string[] {
return count === HEARING_FREQ_LABELS_10.length ? HEARING_FREQ_LABELS_10 : HEARING_FREQ_LABELS;
}
/** Y = n + (-70). Values outside [-80, 0] are kept as-is (axis still -80…0). */
export function mapHearingLevelToDb(n: number): number {
return n - 70;
}
/**
* Natural cubic spline for monotonically spaced `xs`.
* Returns an interpolator defined on [xs[0], xs[n-1]] (extrapolates flat at ends).
*/
export function createNaturalCubicSpline(
xs: number[],
ys: number[],
): (x: number) => number {
const n = xs.length;
if (n === 0) return () => 0;
if (n === 1) return () => ys[0]!;
if (n === 2) {
const x0 = xs[0]!;
const x1 = xs[1]!;
const y0 = ys[0]!;
const y1 = ys[1]!;
return (x: number) => {
if (x1 === x0) return y0;
const t = (x - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
};
}
const h = new Array<number>(n - 1);
for (let i = 0; i < n - 1; i++) h[i] = xs[i + 1]! - xs[i]!;
const alpha = new Array<number>(n).fill(0);
for (let i = 1; i < n - 1; i++) {
alpha[i] =
(3 / h[i]!) * (ys[i + 1]! - ys[i]!) -
(3 / h[i - 1]!) * (ys[i]! - ys[i - 1]!);
}
const l = new Array<number>(n).fill(1);
const mu = new Array<number>(n).fill(0);
const z = new Array<number>(n).fill(0);
for (let i = 1; i < n - 1; i++) {
l[i] = 2 * (xs[i + 1]! - xs[i - 1]!) - h[i - 1]! * mu[i - 1]!;
mu[i] = h[i]! / l[i]!;
z[i] = (alpha[i]! - h[i - 1]! * z[i - 1]!) / l[i]!;
}
const c = new Array<number>(n).fill(0);
const b = new Array<number>(n - 1).fill(0);
const d = new Array<number>(n - 1).fill(0);
for (let j = n - 2; j >= 0; j--) {
c[j] = z[j]! - mu[j]! * c[j + 1]!;
b[j] =
(ys[j + 1]! - ys[j]!) / h[j]! -
(h[j]! * (c[j + 1]! + 2 * c[j]!)) / 3;
d[j] = (c[j + 1]! - c[j]!) / (3 * h[j]!);
}
return (x: number) => {
let i = 0;
if (x <= xs[0]!) {
i = 0;
} else if (x >= xs[n - 1]!) {
i = n - 2;
} else {
let lo = 0;
let hi = n - 2;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (x < xs[mid]!) {
hi = mid - 1;
} else if (x > xs[mid + 1]!) {
lo = mid + 1;
} else {
i = mid;
break;
}
}
if (x > xs[i + 1]!) i = Math.min(n - 2, lo);
}
const dx = x - xs[i]!;
return ys[i]! + b[i]! * dx + c[i]! * dx * dx + d[i]! * dx * dx * dx;
};
}
/**
* Build densely sampled [xIndex, dB] pairs for a smooth ECharts line.
* X uses equal spacing 0…N-1 where N = `levels.length`, so it adapts to both
* 7-point and 10-point hearing data (see `resolveHearingFreqLabels`).
*/
export function buildHearingSplineSeries(
levels: number[],
samplesPerSegment = 16,
): [number, number][] {
const xs = levels.map((_, i) => i);
const ys = levels.map(mapHearingLevelToDb);
const spline = createNaturalCubicSpline(xs, ys);
const maxX = xs[xs.length - 1] ?? 0;
const steps = Math.max(1, maxX * samplesPerSegment);
const points: [number, number][] = [];
for (let s = 0; s <= steps; s++) {
const x = (s / steps) * maxX;
points.push([x, spline(x)]);
}
return points;
}
+43
View File
@@ -119,6 +119,9 @@ export interface DeviceState {
loudness_bass_gain: number;
loudness_treble_gain: number;
loudness_threshold_gain: number;
hearing_enable: number;
hearing_select: number;
hearing_data: HearingProfile[] | string;
bt_status: number;
bt_srcname: string;
bt_title: string;
@@ -130,6 +133,37 @@ export interface DeviceState {
led_blue: number;
}
export interface HearingProfile {
n: string;
l: number[];
r: number[];
}
/** Parse `hearing_data` from syncData (array or JSON string). */
export function parseHearingData(raw: unknown): HearingProfile[] {
if (raw == null || raw === "") return [];
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const n = typeof row.n === "string" ? row.n.trim() : String(row.n ?? "").trim();
if (!n) return null;
const l = Array.isArray(row.l) ? row.l.map((v) => Number(v)) : [];
const r = Array.isArray(row.r) ? row.r.map((v) => Number(v)) : [];
return { n, l, r };
})
.filter((item): item is HearingProfile => item !== null);
}
export interface PeqFilter {
fc: number;
gain: number;
@@ -513,6 +547,15 @@ export const MOCK_DEVICE_STATE: DeviceState = {
loudness_bass_gain: 3,
loudness_treble_gain: 2,
loudness_threshold_gain: 60,
hearing_enable: 0,
hearing_select: 0,
hearing_data: [
{
n: "testing",
l: [1.7, 0.8, 0.5, 0.5, 0.5, 0.5, 0.4],
r: [0.4, 0.4, 0.3, 0.3, 0.2, 0.4, 0.3],
},
],
bt_status: 1,
bt_srcname: "iPhone 15 Pro",
bt_title: "Bohemian Rhapsody",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "Effekte",
"selectStyleTitle": "Klangstil auswählen",
"selectCrossfeedTitle": "Crossfeed-Voreinstellung auswählen",
"selectHearingTitle": "Hörkompensationsprofil auswählen",
"lowBass": "Bass",
"enterBass": "Mitten",
"highBass": "Höhen",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "Hörkompensation",
"emptyProfile": "Kein Profil verfügbar",
"left": "Links",
"right": "Rechts"
},
"subwoofer": {
"label": "Subwoofer",
"freq": "Trennfrequenz",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "Effects",
"selectStyleTitle": "Select sound style",
"selectCrossfeedTitle": "Select crossfeed preset",
"selectHearingTitle": "Select hearing compensation profile",
"lowBass": "Bass",
"enterBass": "Mid",
"highBass": "Treble",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "Hearing compensation",
"emptyProfile": "No profile available",
"left": "Left",
"right": "Right"
},
"subwoofer": {
"label": "Subwoofer",
"freq": "Cut off frequency",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "Efectos",
"selectStyleTitle": "Seleccionar estilo de sonido",
"selectCrossfeedTitle": "Seleccionar ajuste de crossfeed",
"selectHearingTitle": "Seleccionar perfil de compensación auditiva",
"lowBass": "Graves",
"enterBass": "Medios",
"highBass": "Agudos",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "Compensación auditiva",
"emptyProfile": "No hay perfil disponible",
"left": "Izquierda",
"right": "Derecha"
},
"subwoofer": {
"label": "Subwoofer",
"freq": "Frecuencia de corte",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "Effets",
"selectStyleTitle": "Sélectionner le style sonore",
"selectCrossfeedTitle": "Sélectionner le préréglage crossfeed",
"selectHearingTitle": "Sélectionner le profil de compensation auditive",
"lowBass": "Basses",
"enterBass": "Médiums",
"highBass": "Aigus",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "Compensation auditive",
"emptyProfile": "Aucun profil disponible",
"left": "Gauche",
"right": "Droite"
},
"subwoofer": {
"label": "Caisson de basses",
"freq": "Fréquence de coupure",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "音效",
"selectStyleTitle": "選擇音效風格",
"selectCrossfeedTitle": "選擇交叉回授模式",
"selectHearingTitle": "選擇聽力補償配置",
"lowBass": "低頻",
"enterBass": "中頻",
"highBass": "高頻",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "聽力補償",
"emptyProfile": "無可用配置",
"left": "左耳",
"right": "右耳"
},
"subwoofer": {
"label": "重低音輸出",
"freq": "低通頻率",
+7
View File
@@ -82,6 +82,7 @@
"pageTitle": "音效",
"selectStyleTitle": "选择音效风格",
"selectCrossfeedTitle": "选择交叉反馈模式",
"selectHearingTitle": "选择听力补偿配置",
"lowBass": "低音",
"enterBass": "中音",
"highBass": "高音",
@@ -172,6 +173,12 @@
"Custom"
]
},
"hearing": {
"label": "听力补偿",
"emptyProfile": "无可用配置",
"left": "左耳",
"right": "右耳"
},
"subwoofer": {
"label": "低音炮输出",
"freq": "低通频率",
+58
View File
@@ -11,8 +11,11 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useEffect, useRef, useMemo } from "react";
import BottomNav from "@/components/BottomNav";
import HearingCompensationChart from "@/components/HearingCompensationChart";
import { FeatureGate } from "@/components/FeatureGate";
import { navigateToSelect } from "./SelectPage";
import { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi";
import { isFirmwareFeatureAvailable } from "@/config/firmwareFeatures";
import { cn } from "@/lib/utils";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
@@ -121,6 +124,16 @@ export default function EffectsPage() {
const widthVal = ds?.width_value ?? 50;
const crossfeedOn = (ds?.crossfeed_enable ?? 0) === 1;
const crossfeedVal = ds?.crossfeed_value ?? 0;
const hearingOn = (ds?.hearing_enable ?? 0) === 1;
const hearingSelect = ds?.hearing_select ?? 0;
const hearingProfiles = useMemo(
() => parseHearingData(ds?.hearing_data),
[ds?.hearing_data],
);
const hearingLabels = useMemo(
() => hearingProfiles.map((profile) => profile.n),
[hearingProfiles],
);
const sceneOn = (ds?.effect_enable ?? 0) === 1;
const sceneVal = ds?.effect_value ?? 0;
const colorOn = (ds?.color_enable ?? 0) === 1;
@@ -171,6 +184,12 @@ export default function EffectsPage() {
const sceneDisplay = sceneLabels[sceneIdx] ?? "";
const crossIdx = clampPickIndex(crossfeedVal, crossfeedLabels.length);
const crossfeedDisplay = crossfeedLabels[crossIdx] ?? "";
const hearingIdx = clampPickIndex(hearingSelect, hearingLabels.length);
const hearingDisplay =
hearingLabels[hearingIdx] ?? effectText.hearing?.emptyProfile ?? "—";
const firmwareVersion = parseFirmwareVersion(ds?.version);
// X8: the hearing compensation section requires firmware build >= 30.
const showHearingCompensation = isFirmwareFeatureAvailable("hearingCompensation", firmwareVersion);
return (
<div className="min-h-screen bg-black">
@@ -439,6 +458,45 @@ export default function EffectsPage() {
</div>
)}
</div>
{/* ── 听力补偿 ── */}
{showHearingCompensation && (
<div className="ios-list-group p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-[16px] font-medium text-white">{effectText.hearing?.label ?? "听力补偿"}</span>
<IOSToggle checked={hearingOn} onChange={(v) => updateSetting({ hearing_enable: v ? 1 : 0 })} />
</div>
<button
type="button"
disabled={!hearingOn || hearingLabels.length === 0}
className={cn(
"flex items-center justify-between w-full transition-opacity",
hearingOn && hearingLabels.length > 0 ? "active:opacity-70" : "opacity-45 cursor-not-allowed",
)}
onClick={() => {
if (!hearingOn || hearingLabels.length === 0) return;
goSelect(
effectText.selectHearingTitle ?? effectText.hearing?.label ?? "听力补偿",
hearingLabels,
hearingSelect,
"hearing_select",
);
}}
>
<span className="text-[14px] text-white/45">{hearingDisplay}</span>
<ChevronRight size={16} className="ios-chevron" />
</button>
{hearingOn && hearingProfiles[hearingIdx] && (
<HearingCompensationChart
className="mt-3 w-full h-[min(52vw,240px)] min-h-[180px] md:h-[320px] lg:h-[380px] rounded-lg bg-black/30"
left={hearingProfiles[hearingIdx].l}
right={hearingProfiles[hearingIdx].r}
leftLabel={effectText.hearing?.left ?? "Left"}
rightLabel={effectText.hearing?.right ?? "Right"}
/>
)}
</div>
)}
</FeatureGate>
</div>
+14
View File
@@ -41,6 +41,7 @@ const iisModeImageUrl = (index: number) =>
const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
hearing_select: "hearing_select",
language: "language",
analogGain: "analogGain",
soundStep: "soundStep",
@@ -82,8 +83,21 @@ export function consumeSelectPageResult() {
return result;
}
// Scroll position of the calling page, captured before navigating to /select.
// The select page is short, so the browser clamps window scroll to 0 while on
// it; without restoring, the calling page (e.g. Effects) reopens at the top.
let selectReturnScroll: number | null = null;
/** Consume the stored scroll position (once). Returns null when absent. */
export function consumeSelectReturnScroll(): number | null {
const y = selectReturnScroll;
selectReturnScroll = null;
return y;
}
// Helper function to set select page state
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
selectReturnScroll = window.scrollY;
selectPageState = { title, options, selected, back, key };
}
+4
View File
@@ -6,6 +6,10 @@
set -e
# AWS CLI v2 默认把命令输出送入分页器(less)create-invalidation 结束后会停在
# (END) 提示符等待按键,导致脚本无法返回 shell。禁用分页器直接打印输出。
export AWS_PAGER=""
show_help() {
cat <<EOF
用法: ./scripts/deploy.sh [环境] [选项]