diff --git a/client/public/changelog.md b/client/public/changelog.md index b55354f..c6e39ca 100644 --- a/client/public/changelog.md +++ b/client/public/changelog.md @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index bf81589..3a41676 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( @@ -73,6 +94,7 @@ function App() { +
diff --git a/client/src/components/HearingCompensationChart.tsx b/client/src/components/HearingCompensationChart.tsx new file mode 100644 index 0000000..ef55262 --- /dev/null +++ b/client/src/components/HearingCompensationChart.tsx @@ -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(null); + const chartRef = useRef(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 ( +
+ ); +} diff --git a/client/src/config/firmwareFeatures.ts b/client/src/config/firmwareFeatures.ts index b54f236..3e908e5 100644 --- a/client/src/config/firmwareFeatures.ts +++ b/client/src/config/firmwareFeatures.ts @@ -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; diff --git a/client/src/lib/hearingCurve.ts b/client/src/lib/hearingCurve.ts new file mode 100644 index 0000000..afa79f3 --- /dev/null +++ b/client/src/lib/hearingCurve.ts @@ -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(n - 1); + for (let i = 0; i < n - 1; i++) h[i] = xs[i + 1]! - xs[i]!; + + const alpha = new Array(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(n).fill(1); + const mu = new Array(n).fill(0); + const z = new Array(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(n).fill(0); + const b = new Array(n - 1).fill(0); + const d = new Array(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; +} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 0a9b38e..ca30d03 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -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; + 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", diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 6f4868b..b1b9ad5 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -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", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index f050cce..e1fd510 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -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", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index f17b365..e73fddb 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -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", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index 40dd47f..1171c83 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -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", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 3f26afe..3acb49d 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -82,6 +82,7 @@ "pageTitle": "音效", "selectStyleTitle": "選擇音效風格", "selectCrossfeedTitle": "選擇交叉回授模式", + "selectHearingTitle": "選擇聽力補償配置", "lowBass": "低頻", "enterBass": "中頻", "highBass": "高頻", @@ -172,6 +173,12 @@ "Custom" ] }, + "hearing": { + "label": "聽力補償", + "emptyProfile": "無可用配置", + "left": "左耳", + "right": "右耳" + }, "subwoofer": { "label": "重低音輸出", "freq": "低通頻率", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 2b1491e..401d766 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -82,6 +82,7 @@ "pageTitle": "音效", "selectStyleTitle": "选择音效风格", "selectCrossfeedTitle": "选择交叉反馈模式", + "selectHearingTitle": "选择听力补偿配置", "lowBass": "低音", "enterBass": "中音", "highBass": "高音", @@ -172,6 +173,12 @@ "Custom" ] }, + "hearing": { + "label": "听力补偿", + "emptyProfile": "无可用配置", + "left": "左耳", + "right": "右耳" + }, "subwoofer": { "label": "低音炮输出", "freq": "低通频率", diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx index 176f386..c249192 100644 --- a/client/src/pages/EffectsPage.tsx +++ b/client/src/pages/EffectsPage.tsx @@ -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 (
@@ -439,6 +458,45 @@ export default function EffectsPage() {
)}
+ + {/* ── 听力补偿 ── */} + {showHearingCompensation && ( +
+
+ {effectText.hearing?.label ?? "听力补偿"} + updateSetting({ hearing_enable: v ? 1 : 0 })} /> +
+ + {hearingOn && hearingProfiles[hearingIdx] && ( + + )} +
+ )}
diff --git a/client/src/pages/SelectPage.tsx b/client/src/pages/SelectPage.tsx index fc7f49b..ea70c37 100644 --- a/client/src/pages/SelectPage.tsx +++ b/client/src/pages/SelectPage.tsx @@ -41,6 +41,7 @@ const iisModeImageUrl = (index: number) => const KEY_TO_SETTING: Record = { 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 }; } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bcfd4ff..2c80e0d 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -6,6 +6,10 @@ set -e +# AWS CLI v2 默认把命令输出送入分页器(less),create-invalidation 结束后会停在 +# (END) 提示符等待按键,导致脚本无法返回 shell。禁用分页器直接打印输出。 +export AWS_PAGER="" + show_help() { cat <