From 47ea7d7b56a7f9eb1c2b3f57ca8c1103fce00253 Mon Sep 17 00:00:00 2001 From: yangy Date: Tue, 26 May 2026 18:46:41 +0800 Subject: [PATCH] =?UTF-8?q?x9=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/i.html | 2 +- client/index.html | 2 +- client/src/components/SubwooferLpfChart.tsx | 70 +++ client/src/lib/luxsinApi.ts | 35 +- client/src/lib/peqAudio.ts | 85 ++-- client/src/lib/subwooferLpf.ts | 66 +++ client/src/locales/data-en.json | 51 ++- client/src/locales/data-zh-HK.json | 59 ++- client/src/locales/data-zh.json | 57 ++- client/src/pages/AudioPage.tsx | 121 ++++-- client/src/pages/EffectsPage.tsx | 366 ++++++++++++++++ client/src/pages/Home.tsx | 122 +++++- client/src/pages/SelectPage.tsx | 455 ++++++++++++++++++-- 13 files changed, 1370 insertions(+), 121 deletions(-) create mode 100644 client/src/components/SubwooferLpfChart.tsx create mode 100644 client/src/lib/subwooferLpf.ts diff --git a/client/i.html b/client/i.html index ed884c3..ede4cfc 100644 --- a/client/i.html +++ b/client/i.html @@ -3,7 +3,7 @@ - Luxsin X8 Controller + Luxsin X9 Controller
diff --git a/client/index.html b/client/index.html index ed884c3..ede4cfc 100644 --- a/client/index.html +++ b/client/index.html @@ -3,7 +3,7 @@ - Luxsin X8 Controller + Luxsin X9 Controller
diff --git a/client/src/components/SubwooferLpfChart.tsx b/client/src/components/SubwooferLpfChart.tsx new file mode 100644 index 0000000..13ff8f9 --- /dev/null +++ b/client/src/components/SubwooferLpfChart.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts"; +import { getChartOps } from "@/lib/peqAudio"; +import { buildSubwooferLpfResponse } from "@/lib/subwooferLpf"; + +type SubwooferLpfChartProps = { + cutoffHz: number; + rateIndex: number; + className?: string; +}; + +export default function SubwooferLpfChart({ + cutoffHz, + rateIndex, + className, +}: SubwooferLpfChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const dataSet = buildSubwooferLpfResponse(cutoffHz, rateIndex); + const ops = getChartOps(dataSet, 0, -30, "#FFED00", false); + const series0 = ops.series[0]; + if (series0 && "areaStyle" in series0) { + series0.areaStyle = { + opacity: 0.2, + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: "#578400" }, + { offset: 1, color: "#578400" }, + ]), + }; + } + + if (!chartRef.current) { + chartRef.current = echarts.init(el, undefined, { renderer: "canvas" }); + } + chartRef.current.setOption(ops, 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); + }; + }, [cutoffHz, rateIndex]); + + useEffect(() => { + return () => { + chartRef.current?.dispose(); + chartRef.current = null; + }; + }, []); + + return ( +
+ ); +} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 08d29a8..7a196a3 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -103,6 +103,13 @@ export interface DeviceState { subwoofer_value: number; subwoofer_rate: number; subwoofer_gain: number; + subwoofer_mix_type: number; + subwoofer_delay: number; + subwoofer_delay_main: number; + subwoofer_delay_r: number; + subwoofer_delay_main_r: number; + subwoofer_lpf_enable: number; + subwoofer_hpf_enable: number; loudness_enable: number; loudness_bass_gain: number; loudness_treble_gain: number; @@ -163,6 +170,9 @@ export interface PeqApplyPayload { // ============================================================ export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"]; export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"]; + +/** Device `output` index for headphone / headset. */ +export const OUTPUT_HEADSET_INDEX = 2; export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"]; export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"]; export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"]; @@ -213,7 +223,9 @@ export class LuxsinAPI { const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`); const text = await response.text(); const decoded = decodeCustomBase64(text.trim()); - return JSON.parse(decoded) as DeviceState; + const state = JSON.parse(decoded) as DeviceState; + console.log("[syncData] decoded", state); + return state; } catch (error) { this.maybeRedirectForHttpsCert(error); throw error; @@ -333,6 +345,20 @@ export function parseFirmwareVersion(version: unknown): number { return Number(parts[parts.length - 1]); } +/** Pre-out volume passthrough mode selected (0dB or -12dB). */ +export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean { + return dacVolumeDirect === 1 || dacVolumeDirect === 2; +} + +/** Lock home volume controls when passthrough is on, except output is headphone. */ +export function isHomeVolumeLockedByPassthrough( + dacVolumeDirect: number | undefined, + output: number | undefined, +): boolean { + if (!isVolumePassthroughActive(dacVolumeDirect)) return false; + return (output ?? -1) !== OUTPUT_HEADSET_INDEX; +} + /** Max `bootSound` index available for the current firmware. */ export function getBootSoundMaxIndex(firmwareVersion: number): number { return firmwareVersion >= 26 ? 10 : 6; @@ -401,6 +427,13 @@ export const MOCK_DEVICE_STATE: DeviceState = { subwoofer_value: 80, subwoofer_rate: 0, subwoofer_gain: 0, + subwoofer_mix_type: 0, + subwoofer_delay: 582, + subwoofer_delay_main: 571, + subwoofer_delay_r: 582, + subwoofer_delay_main_r: 571, + subwoofer_lpf_enable: 0, + subwoofer_hpf_enable: 0, loudness_enable: 0, loudness_bass_gain: 3, loudness_treble_gain: 2, diff --git a/client/src/lib/peqAudio.ts b/client/src/lib/peqAudio.ts index 26c2c7e..fb36a2e 100644 --- a/client/src/lib/peqAudio.ts +++ b/client/src/lib/peqAudio.ts @@ -12,6 +12,8 @@ export const TYPE_HIGHPASS = 1; export const TYPE_BANDPASS = 2; export const TYPE_NOTCH = 3; export const TYPE_ALLPASS = 7; +/** First-order low-pass (subwoofer LPF curve). */ +export const LPF_1ST = 8; // Filter type mapping (device uses numeric type; pass-through when already a number) export function getFilterType(typeName: string | number): number { @@ -257,6 +259,16 @@ export function getSectionsMatrix( a1 = b1; a2 = b0; break; + case LPF_1ST: { + const alpha1st = sin_w / (cos_w + 1); + b0 = alpha1st / (1 + alpha1st); + b1 = b0; + b2 = 0; + a0 = 1; + a1 = (alpha1st - 1) / (1 + alpha1st); + a2 = 0; + break; + } default: b0 = 1; b1 = 0; @@ -440,7 +452,8 @@ export function getChartOps( dataSet: [number[], number[]], yMax: number, yMin: number, - lineColor: string + lineColor: string, + withPeqLegend = true, ) { const labelsToShow = [0, 43, 85, 116, 160, 200, 233, 281, 320, 348]; const labelMap: { [key: number]: string } = { @@ -507,44 +520,52 @@ export function getChartOps( }, }, legend: { - show: true, + show: withPeqLegend, top: 15, - data: [ - { - name: 'Equalizer', - icon: 'circle', - textStyle: { color: lineColor }, - itemStyle: { - color: lineColor, - borderColor: lineColor, - }, - }, - { - name: 'Raw', - icon: 'circle', - textStyle: { color: '#ffffff' }, - itemStyle: { - color: '#ffffff', - borderColor: '#ffffff', - }, - }, - { - name: 'Equalized', - icon: 'circle', - textStyle: { color: '#23d2fe' }, - itemStyle: { - color: '#23d2fe', - borderColor: '#23d2fe', - }, - }, - ], + data: withPeqLegend + ? [ + { + name: 'Equalizer', + icon: 'circle', + textStyle: { color: lineColor }, + itemStyle: { + color: lineColor, + borderColor: lineColor, + }, + }, + { + name: 'Raw', + icon: 'circle', + textStyle: { color: '#ffffff' }, + itemStyle: { + color: '#ffffff', + borderColor: '#ffffff', + }, + }, + { + name: 'Equalized', + icon: 'circle', + textStyle: { color: '#23d2fe' }, + itemStyle: { + color: '#23d2fe', + borderColor: '#23d2fe', + }, + }, + ] + : [], textStyle: { fontSize: 13, }, }, + grid: { + left: 48, + right: 16, + top: withPeqLegend ? 40 : 12, + bottom: 28, + }, series: [ { - name: 'Equalizer', + name: withPeqLegend ? 'Equalizer' : 'LPF', data: dataSet[1], type: 'line' as const, showSymbol: false, diff --git a/client/src/lib/subwooferLpf.ts b/client/src/lib/subwooferLpf.ts new file mode 100644 index 0000000..238b370 --- /dev/null +++ b/client/src/lib/subwooferLpf.ts @@ -0,0 +1,66 @@ +/** + * Subwoofer low-pass frequency response — ported from olds/effect.js (painting, getLps). + * Uses peqAudio getSectionsMatrix + visualizeResponse (same as EQ page). + */ +import { + getSectionsMatrix, + LPF_1ST, + TYPE_LOWPASS, + visualizeResponse, +} from "@/lib/peqAudio"; + +export const SUBWOOFER_LPF_FS = 48000; +const SUBWOOFER_PARAM = [1, 1, 1.35, 1.28, 1.5, 1.4, 1.65, 1.55]; + +function pushSection(list: ReturnType[], fc: number, type: number) { + list.push(getSectionsMatrix(0, fc, 0.707, type, false, SUBWOOFER_LPF_FS)); +} + +/** + * Slope layout: [2nd-order count, 1st-order count] — see olds/effect.js getLps. + */ +export function getSubwooferLps(lpsIndex: number, lpsVal: number): [number, number] { + let count12 = 0; + let count6 = 1; + if (lpsIndex > 1) { + const tmp = lpsVal / 12; + if (!Number.isInteger(tmp)) { + count12 = Math.floor(tmp); + } else { + count12 = tmp; + count6 = 0; + } + } else if (lpsIndex === 1) { + count12 = 1; + count6 = 0; + } + return [count12, count6]; +} + +/** + * Build cascaded LPF coeffs and return log-frequency magnitude plot data. + */ +export function buildSubwooferLpfResponse( + fc: number, + lpsIndex: number, +): [number[], number[]] { + const lpsVal = (lpsIndex + 1) * 6; + const lpsArray = getSubwooferLps(lpsIndex, lpsVal); + const fcAdj = fc * SUBWOOFER_PARAM[lpsIndex]; + const list: ReturnType[] = []; + + if (lpsArray[0] === 0) { + pushSection(list, fcAdj, LPF_1ST); + } else { + for (let i = 0; i < lpsArray[0]; i += 1) { + pushSection(list, fcAdj, TYPE_LOWPASS); + } + if (lpsArray[1] > 0) { + for (let i = 0; i < lpsArray[1]; i += 1) { + pushSection(list, fcAdj, LPF_1ST); + } + } + } + + return visualizeResponse(list, SUBWOOFER_LPF_FS); +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 4b057cb..f4bf3d9 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -24,6 +24,10 @@ "volumeMaxConfirmTitle": "Set volume to maximum?", "volumeMaxConfirmDesc": "Headphone volume will be set to the highest level. Continue?", "volumeMaxConfirmOk": "Set to maximum", + "volumePassthroughHint": "Volume passthrough is on; volume cannot be adjusted", + "volumePassthroughBlocked": "Volume passthrough is active ({mode}). Headphone volume is fixed. Turn passthrough off in Audio settings.", + "volumePassthroughMode0dB": "0dB", + "volumePassthroughMode12dB": "-12dB", "powerOffToastDemo": "Demo mode: disconnected from the device.", "powerOffToastSent": "Shutdown command sent.", "doubleClickHint": "Double-click to enter", @@ -159,9 +163,23 @@ }, "subwoofer": { "label": "Subwoofer", - "freq": "Cut off frequency", - "attenuation": "Attenuation slope", + "cutOffFrequency": "Cut off frequency", + "subFullRange": "Sub Full Range", + "mainFullRange": "Main Full Range", + "attenuationSlope": "Attenuation slope", "gain": "Gain", + "selectAttenuationTitle": "Select attenuation slope", + "selectOutputTitle": "Select output type", + "rateOptions": [ + "6dB/oct", + "12dB/oct", + "18dB/oct", + "24dB/oct", + "30dB/oct", + "36dB/oct", + "42dB/oct", + "48dB/oct" + ], "output": { "label": "Output type", "options": [ @@ -174,6 +192,13 @@ "index": 1 } ] + }, + "delay": { + "label": "Delay", + "mainSpeaker": "Main speakers", + "subwoofer": "Subwoofer", + "leftChannel": "Left channel", + "rightChannel": "Right channel" } }, "enableConfirmTitle": "Effects is off", @@ -336,6 +361,28 @@ "sectionSystemInterface": "System & interface", "balance": "Left-right balance", "sensitivity": "VU Meter Sensitivity", + "analogInputGain": "Analog input gain", + "autoImpedanceDetection": "Auto Impedance Detection", + "dacVolumeDirect": { + "label": "Pre-out Volume passthrough", + "options": [ + { "index": 0, "label": "Off" }, + { "index": 1, "label": "0dB" }, + { "index": 2, "label": "-12dB" } + ], + "confirmTitle": "Notice", + "confirm0dB": "Passthrough mode will set the volume to maximum and volume cannot be adjusted.", + "confirm12dB": "Passthrough mode will set the volume to -12 dB and volume cannot be adjusted.", + "confirmOk": "Confirm", + "confirmCancel": "Cancel" + }, + "dacArc": { + "label": "ARC model", + "options": [ + { "index": 0, "label": "ARC" }, + { "index": 1, "label": "EARC" } + ] + }, "filters": { "label": "Filters", "options": [ diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 5aad87f..9ff29f6 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -24,6 +24,10 @@ "volumeMaxConfirmTitle": "確認將音量設為最大?", "volumeMaxConfirmDesc": "耳機音量將調至最大,是否繼續?", "volumeMaxConfirmOk": "設為最大", + "volumePassthroughHint": "目前為音量直通模式,音量不可調節", + "volumePassthroughBlocked": "目前為音量直通模式({mode}),無法調節耳機音量。請在音訊設定中關閉直通或改為「關閉」。", + "volumePassthroughMode0dB": "0dB", + "volumePassthroughMode12dB": "-12dB", "powerOffToastDemo": "演示模式:已中斷與裝置的連線", "powerOffToastSent": "已傳送關機指令", "doubleClickHint": "按兩下進入", @@ -100,16 +104,37 @@ ] }, "subwoofer": { - "label": "重低音輸出", - "freq": "低通頻率", - "attenuation": "衰減斜率", - "gain": "低音增強", + "label": "低音炮輸出", + "cutOffFrequency": "截止頻率", + "subFullRange": "低音炮全頻", + "mainFullRange": "主音箱全頻", + "attenuationSlope": "衰減斜率", + "gain": "增益", + "selectAttenuationTitle": "選擇衰減斜率", + "selectOutputTitle": "選擇輸出方式", + "rateOptions": [ + "6dB/oct", + "12dB/oct", + "18dB/oct", + "24dB/oct", + "30dB/oct", + "36dB/oct", + "42dB/oct", + "48dB/oct" + ], "output": { "label": "輸出方式", "options": [ - { "label": "單聲道", "index": 0 }, - { "label": "立體聲", "index": 1 } + { "label": "單聲道 Mono(Mix)", "index": 0 }, + { "label": "立體聲 Stereo", "index": 1 } ] + }, + "delay": { + "label": "延時", + "mainSpeaker": "主機箱", + "subwoofer": "低音炮", + "leftChannel": "左聲道", + "rightChannel": "右聲道" } }, "enableConfirmTitle": "音效未開啟", @@ -272,6 +297,28 @@ "sectionSystemInterface": "系統與介面", "balance": "左右平衡", "sensitivity": "VU表靈敏度", + "analogInputGain": "模擬輸入增益", + "autoImpedanceDetection": "自動檢測耳機阻抗", + "dacVolumeDirect": { + "label": "前級輸出音量直通模式", + "options": [ + { "index": 0, "label": "關閉" }, + { "index": 1, "label": "0dB" }, + { "index": 2, "label": "-12dB" } + ], + "confirmTitle": "提示", + "confirm0dB": "直通模式會將音量調到最大,並且不可調節", + "confirm12dB": "直通模式會將音量調到-12dB,並且不可調節", + "confirmOk": "確認", + "confirmCancel": "取消" + }, + "dacArc": { + "label": "ARC模式", + "options": [ + { "index": 0, "label": "ARC" }, + { "index": 1, "label": "EARC" } + ] + }, "filters": { "label": "濾波特性", "options": [ diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 4683976..cdbe79a 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -24,6 +24,10 @@ "volumeMaxConfirmTitle": "确认将音量设为最大?", "volumeMaxConfirmDesc": "耳机音量将调至最大,是否继续?", "volumeMaxConfirmOk": "设为最大", + "volumePassthroughHint": "当前为音量直通模式,音量不可调节", + "volumePassthroughBlocked": "当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。", + "volumePassthroughMode0dB": "0dB", + "volumePassthroughMode12dB": "-12dB", "powerOffToastDemo": "演示模式:已断开与设备的连接", "powerOffToastSent": "已发送关机指令", "doubleClickHint": "双击进入", @@ -101,15 +105,36 @@ }, "subwoofer": { "label": "低音炮输出", - "freq": "低通频率", - "attenuation": "衰减斜率", - "gain": "低音增强", + "cutOffFrequency": "截止频率", + "subFullRange": "低音炮全频", + "mainFullRange": "主音箱全频", + "attenuationSlope": "衰减斜率", + "gain": "增益", + "selectAttenuationTitle": "选择衰减斜率", + "selectOutputTitle": "选择输出方式", + "rateOptions": [ + "6dB/oct", + "12dB/oct", + "18dB/oct", + "24dB/oct", + "30dB/oct", + "36dB/oct", + "42dB/oct", + "48dB/oct" + ], "output": { "label": "输出方式", "options": [ - { "label": "单声道", "index": 0 }, - { "label": "立体声", "index": 1 } + { "label": "单声道 Mono(Mix)", "index": 0 }, + { "label": "立体声 Stereo", "index": 1 } ] + }, + "delay": { + "label": "延时", + "mainSpeaker": "主机箱", + "subwoofer": "低音炮", + "leftChannel": "左声道", + "rightChannel": "右声道" } }, "enableConfirmTitle": "音效未开启", @@ -272,6 +297,28 @@ "sectionSystemInterface": "系统与接口", "balance": "左右平衡", "sensitivity": "vu表灵敏度", + "analogInputGain": "模拟输入增益", + "autoImpedanceDetection": "自动检测耳机阻抗", + "dacVolumeDirect": { + "label": "前级输出音量直通模式", + "options": [ + { "index": 0, "label": "关闭" }, + { "index": 1, "label": "0dB" }, + { "index": 2, "label": "-12dB" } + ], + "confirmTitle": "提示", + "confirm0dB": "直通模式会将音量调到最大,并且不可调节", + "confirm12dB": "直通模式会将音量调到-12dB,并且不可调节", + "confirmOk": "确认", + "confirmCancel": "取消" + }, + "dacArc": { + "label": "ARC模式", + "options": [ + { "index": 0, "label": "ARC" }, + { "index": 1, "label": "EARC" } + ] + }, "filters": { "label": "滤波特性", "options": [ diff --git a/client/src/pages/AudioPage.tsx b/client/src/pages/AudioPage.tsx index b23cc41..62837da 100644 --- a/client/src/pages/AudioPage.tsx +++ b/client/src/pages/AudioPage.tsx @@ -2,9 +2,8 @@ AUDIO PAGE — Audio Settings Design: Reference luxsin_x8_音频设置.png Sections: - - 平衡与灵敏度: 左右平衡 slider, VU灵敏度 slider + - 平衡与灵敏度: 左右平衡, VU灵敏度, 模拟输入增益 sliders - 输出特性: 滤波特性, 耳机增益, 音量幅度 - - IIS设置: IIS静音电平, IIS模式 - 系统与接口: 开机音量, XLR极性 All list rows → navigate to /select page ============================================================ */ @@ -70,7 +69,31 @@ function SectionHeader({ title }: { title: string }) { ); } -function ListRow({ label, value, onClick }: { label: string; value: string; onClick?: () => void }) { +function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +function ListRow({ label, value, onClick, toggle, checked, onToggle }: { + label: string; + value?: string; + onClick?: () => void; + toggle?: boolean; + checked?: boolean; + onToggle?: (v: boolean) => void; +}) { + if (toggle) { + return ( +
+ {label} + {})} /> +
+ ); + } return (
+
+
+
+ {dacText.analogInputGain ?? "模拟输入增益"} + + {localAnalogGain >= 0 ? `+${localAnalogGain.toFixed(1)}` : localAnalogGain.toFixed(1)} dB + +
+ { + isAnalogGainDragging.current = true; + setLocalAnalogGain(v); + }} + onRelease={(v) => { + isAnalogGainDragging.current = false; + setLocalAnalogGain(v); + updateSetting({ analogGain: Math.round(v * 2) }); + }} + /> +
{/* ── 输出特性 ── */} @@ -292,6 +348,36 @@ export default function AudioPage() { value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"} onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")} /> + updateSetting({ dacImpedance: v ? 1 : 0 })} + /> + + goSelect( + dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式", + DAC_VOLUME_DIRECT_OPTIONS, + dacVolumeDirectIdx, + "dacVolumeDirect", + ) + } + /> + + goSelect( + dacText.dacArc?.label ?? "ARC模式", + DAC_ARC_OPTIONS, + dacArcIdx, + "dacArc", + ) + } + /> - {/* ── IIS 设置 ── */} - -
- goSelect(dacText.mutePolar?.label ?? "选择 IIS 静音电平", MUTE_POLAR_OPTIONS, mutePolarIdx, "mutePolar")} - /> - goSelect(dacText.IISMode?.label ?? "选择 IIS 模式", IIS_MODE_OPTIONS, iisModeIdx, "IISMode")} - /> -
- {/* ── 系统与接口 ── */}
diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx index c3d9f96..e823a42 100644 --- a/client/src/pages/EffectsPage.tsx +++ b/client/src/pages/EffectsPage.tsx @@ -11,8 +11,10 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useEffect, useRef, useMemo } from "react"; import BottomNav from "@/components/BottomNav"; +import SubwooferLpfChart from "@/components/SubwooferLpfChart"; import { FeatureGate } from "@/components/FeatureGate"; import { navigateToSelect } from "./SelectPage"; +import { parseFirmwareVersion } from "@/lib/luxsinApi"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; @@ -24,6 +26,115 @@ function clampPickIndex(i: number, len: number) { return Math.min(Math.max(i, 0), len - 1); } +const SUBWOOFER_DELAY_MS_DIVISOR = 48; +const SUBWOOFER_DELAY_CM_NUMERATOR = 34; +const SUBWOOFER_DELAY_MAX = 1920; + +function clampSubwooferDelayValue(v: number) { + return Math.round(Math.min(SUBWOOFER_DELAY_MAX, Math.max(0, v))); +} + +function subwooferDelayToMs(value: number) { + return value / SUBWOOFER_DELAY_MS_DIVISOR; +} + +function subwooferDelayToCm(value: number) { + return (value * SUBWOOFER_DELAY_CM_NUMERATOR) / SUBWOOFER_DELAY_MS_DIVISOR; +} + +function formatSubwooferDelayValue(value: number) { + const ms = subwooferDelayToMs(value); + const cm = subwooferDelayToCm(value); + return `${ms.toFixed(2)}ms(${cm.toFixed(1)}cm)`; +} + +type DelayChannel = "L" | "R"; + +function DelaySpeakerIcon({ direction }: { direction: "left" | "right" }) { + return ( + + + + + + ); +} + +function FullRangeToggleButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function DelayChannelToggle({ + channel, + onChange, + leftLabel, + rightLabel, +}: { + channel: DelayChannel; + onChange: (ch: DelayChannel) => void; + leftLabel: string; + rightLabel: string; +}) { + const btnClass = (active: boolean) => + `flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${ + active ? "bg-[#00FFF6]/20 text-[#00FFF6]" : "text-white/35 active:bg-white/10" + }`; + + return ( +
+ + +
+ ); +} + function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return (
+ + {/* ── 低音炮输出 ── */} +
+
+ {subwooferText?.label ?? "低音炮输出"} + updateSetting({ subwoofer_enable: v ? 1 : 0 })} /> +
+ + {subwooferOn && ( +
+
+ +
+ + {subwooferText?.cutOffFrequency ?? "截止频率"} + + {localSubFreq}Hz +
+ { + isDraggingSubFreq.current = true; + setLocalSubFreq(Math.round(v)); + }} + onRelease={(v) => { + isDraggingSubFreq.current = false; + const freq = Math.round(Math.min(300, Math.max(40, v))); + setLocalSubFreq(freq); + updateSetting({ subwoofer_value: freq }); + }} + /> + {showSubwooferFullRange && ( +
+ + updateSetting({ subwoofer_lpf_enable: subwooferLpfOn ? 0 : 1 }) + } + /> + + updateSetting({ subwoofer_hpf_enable: subwooferHpfOn ? 0 : 1 }) + } + /> +
+ )} +
+ +
+
+ + {subwooferText?.attenuationSlope ?? "衰减斜率"} + +
+ +
+ +
+
+ {subwooferText?.gain ?? "增益"} + + {localSubGain >= 0 ? `+${localSubGain}` : localSubGain} dB + +
+ { + isDraggingSubGain.current = true; + setLocalSubGain(Math.round(v)); + }} + onRelease={(v) => { + isDraggingSubGain.current = false; + const gain = Math.round(Math.min(15, Math.max(-15, v))); + setLocalSubGain(gain); + updateSetting({ subwoofer_gain: gain }); + }} + /> +
+ +
+
+ + {subwooferText?.output?.label ?? "输出方式"} + +
+ +
+ +
+
+ + {subwooferDelayText?.label ?? "延时"} + + +
+ +
+
+ + {subwooferDelayText?.mainSpeaker ?? "主机箱"} + + + {formatSubwooferDelayValue(localSubDelayMain)} + +
+ { + isDraggingSubDelayMain.current = true; + setLocalSubDelayMain(clampSubwooferDelayValue(v)); + }} + onRelease={(v) => { + isDraggingSubDelayMain.current = false; + const delayVal = clampSubwooferDelayValue(v); + setLocalSubDelayMain(delayVal); + updateSetting({ [subwooferDelayMainField]: delayVal }); + }} + /> +
+ +
+
+ + {subwooferDelayText?.subwoofer ?? "低音炮"} + + + {formatSubwooferDelayValue(localSubDelaySub)} + +
+ { + isDraggingSubDelaySub.current = true; + setLocalSubDelaySub(clampSubwooferDelayValue(v)); + }} + onRelease={(v) => { + isDraggingSubDelaySub.current = false; + const delayVal = clampSubwooferDelayValue(v); + setLocalSubDelaySub(delayVal); + updateSetting({ [subwooferDelaySubField]: delayVal }); + }} + /> +
+
+
+ )} +
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index f084df6..0f49950 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -22,7 +22,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi"; +import { INPUT_LABELS, isHomeVolumeLockedByPassthrough, OUTPUT_LABELS } from "@/lib/luxsinApi"; import { cn } from "@/lib/utils"; import { ChevronRight, @@ -378,6 +378,38 @@ export default function Home() { }; }, []); + const volumePassthroughActive = isHomeVolumeLockedByPassthrough( + deviceState?.dacVolumeDirect, + deviceState?.output, + ); + const volumePassthroughModeLabel = + deviceState?.dacVolumeDirect === 1 + ? (homeText.volumePassthroughMode0dB ?? "0dB") + : deviceState?.dacVolumeDirect === 2 + ? (homeText.volumePassthroughMode12dB ?? "-12dB") + : ""; + + const notifyVolumePassthroughBlocked = useCallback(() => { + const template = + homeText.volumePassthroughBlocked ?? + "当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。"; + toast.info(template.replace("{mode}", volumePassthroughModeLabel)); + }, [homeText, volumePassthroughModeLabel]); + + const blockVolumeAdjust = useCallback(() => { + if (!volumePassthroughActive) return false; + notifyVolumePassthroughBlocked(); + return true; + }, [volumePassthroughActive, notifyVolumePassthroughBlocked]); + + const applyVolume = useCallback( + (v: number) => { + if (blockVolumeAdjust()) return; + setVolume(v); + }, + [blockVolumeAdjust, setVolume], + ); + if (!isConnected) return ; const ds = deviceState!; @@ -413,6 +445,7 @@ export default function Home() { }; const beginKnobDrag = (e: React.PointerEvent) => { + if (blockVolumeAdjust()) return; const el = e.currentTarget; const rect = el.getBoundingClientRect(); knobDraggingRef.current = true; @@ -442,7 +475,7 @@ export default function Home() { knobDraggingRef.current = false; knobActivePointerIdRef.current = null; isDragging.current = false; - setVolume(knobLastVolRef.current); + applyVolume(knobLastVolRef.current); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { @@ -549,7 +582,7 @@ export default function Home() { {/* Right knob */} -
+
{/* ── Volume slider ── */} -
-
+
{ + if (volumePassthroughActive) { + e.preventDefault(); + e.stopPropagation(); + notifyVolumePassthroughBlocked(); + } + }} + > + {volumePassthroughActive && ( +

+ {homeText.volumePassthroughHint ?? "当前为音量直通模式,音量不可调节"} + {volumePassthroughModeLabel ? ` (${volumePassthroughModeLabel})` : ""} +

+ )} +
{/* dB value */} -
+
- + {volToDB(localVol)} +

{title}

+
+
+ +
+ {key === "IISMode" ? ( +
+ {options.map((opt, idx) => { + const isSelected = idx === selectedIdx; + return ( + + ); + })} +
+ ) : ( +
+ {options.map((opt, idx) => { + const isSelected = idx === selectedIdx; + return ( + + ); + })} +
+ )} +
+ + + + { + + if (!open) setPassthroughConfirmIdx(null); + + }} + + > + + + + + + + + {passthroughConfirmCopy?.title} + + + + + + {passthroughConfirmCopy?.message} + + + + + + + + + + {passthroughConfirmCopy?.cancel} + + + + void handlePassthroughConfirm()} + + > + + {passthroughConfirmCopy?.ok} + + + + + + + + +
+ ); + } + +