diff --git a/client/src/config/firmwareFeatures.ts b/client/src/config/firmwareFeatures.ts new file mode 100644 index 0000000..11a663b --- /dev/null +++ b/client/src/config/firmwareFeatures.ts @@ -0,0 +1,28 @@ +/** + * Firmware version gates for UI features. + * + * Versions are compared using `parseFirmwareVersion` (e.g. 2007 → 2.0.0.7). + * Adjust thresholds here instead of hardcoding in page components. + */ +export const FIRMWARE_FEATURE_MIN_VERSION = { + /** Boot sound: indices 7–10 (-35…-50 dB); legacy firmware caps at index 6. */ + bootSoundExtendedSteps: 26, + /** Subwoofer LPF/HPF full-range toggles on the Effects page. */ + subwooferFullRange: 2001, + /** Hearing compensation section on the Effects page. */ + hearingCompensation: 2007, +} as const; + +export type FirmwareFeatureKey = keyof typeof FIRMWARE_FEATURE_MIN_VERSION; + +export function isFirmwareFeatureAvailable( + feature: FirmwareFeatureKey, + firmwareVersion: number, +): boolean { + return firmwareVersion >= FIRMWARE_FEATURE_MIN_VERSION[feature]; +} + +/** Max `bootSound` select index (0–6 legacy, 0–10 when extended steps are supported). */ +export function getBootSoundMaxIndex(firmwareVersion: number): number { + return isFirmwareFeatureAvailable("bootSoundExtendedSteps", firmwareVersion) ? 10 : 6; +} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index c38fb89..1a23847 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -120,6 +120,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; @@ -127,6 +130,37 @@ export interface DeviceState { msgCount: 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; @@ -375,7 +409,7 @@ export class LuxsinAPI { setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); } setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); } setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); } - /** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (firmware >= 26). */ + /** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (see firmwareFeatures.bootSoundExtendedSteps). */ setBootSound(level: number) { return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) }); } @@ -383,7 +417,7 @@ export class LuxsinAPI { powerOff() { return this.setSetting({ power: 0 }); } } -/** Parse firmware `version` for feature gating (e.g. bootSound extended steps). */ +/** Parse firmware `version` for feature gating (see `config/firmwareFeatures.ts`). */ export function parseFirmwareVersion(version: unknown): number { if (version === null || version === undefined) return 0; if (typeof version === "number" && Number.isFinite(version)) return version; @@ -423,10 +457,7 @@ export function isHomeVolumeLockedByPassthrough( 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; -} +export { getBootSoundMaxIndex } from "@/config/firmwareFeatures"; /** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */ export function normalizeBootSound(value: unknown): number { @@ -502,6 +533,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 99672db..b9114d8 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -71,6 +71,7 @@ "pageTitle": "Effekte", "selectStyleTitle": "Klangstil auswählen", "selectCrossfeedTitle": "Crossfeed-Voreinstellung auswählen", + "selectHearingTitle": "Hörkompensationsprofil auswählen", "lowBass": "Bass", "enterBass": "Mitten", "highBass": "Höhen", @@ -110,6 +111,10 @@ "BS2B Entspannt (650 Hz, 9,5 dB)" ] }, + "hearing": { + "label": "Hörkompensation", + "emptyProfile": "Kein Profil verfügbar" + }, "subwoofer": { "label": "Subwoofer", "cutOffFrequency": "Trennfrequenz", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index ac8c2ae..51246a2 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -71,6 +71,7 @@ "pageTitle": "Effects", "selectStyleTitle": "Select sound style", "selectCrossfeedTitle": "Select crossfeed preset", + "selectHearingTitle": "Select hearing compensation profile", "lowBass": "Bass", "enterBass": "Mid", "highBass": "Treble", @@ -110,6 +111,10 @@ "BS2B relax (650 Hz, 9.5 dB)" ] }, + "hearing": { + "label": "Hearing compensation", + "emptyProfile": "No profile available" + }, "subwoofer": { "label": "Subwoofer", "cutOffFrequency": "Cut off frequency", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 70d96bb..0092dc5 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -71,6 +71,7 @@ "pageTitle": "Efectos", "selectStyleTitle": "Seleccionar estilo de sonido", "selectCrossfeedTitle": "Seleccionar preset de crossfeed", + "selectHearingTitle": "Seleccionar perfil de compensación auditiva", "lowBass": "Graves", "enterBass": "Medios", "highBass": "Agudos", @@ -110,6 +111,10 @@ "BS2B relajado (650 Hz, 9,5 dB)" ] }, + "hearing": { + "label": "Compensación auditiva", + "emptyProfile": "No hay perfil disponible" + }, "subwoofer": { "label": "Subwoofer", "cutOffFrequency": "Frecuencia de corte", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index 97eebed..babea51 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -71,6 +71,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", @@ -110,6 +111,10 @@ "BS2B relax (650 Hz, 9,5 dB)" ] }, + "hearing": { + "label": "Compensation auditive", + "emptyProfile": "Aucun profil disponible" + }, "subwoofer": { "label": "Caisson de basses", "cutOffFrequency": "Fréquence de coupure", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 43a23fb..0c015c7 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -64,6 +64,7 @@ "pageTitle": "音效", "selectStyleTitle": "選擇音效風格", "selectCrossfeedTitle": "選擇交叉回授模式", + "selectHearingTitle": "選擇聽力補償配置", "lowBass": "低頻", "enterBass": "中頻", "highBass": "高頻", @@ -103,6 +104,10 @@ "BS2B relax(650Hz,9.5dB)" ] }, + "hearing": { + "label": "聽力補償", + "emptyProfile": "無可用配置" + }, "subwoofer": { "label": "低音炮輸出", "cutOffFrequency": "截止頻率", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index b1da996..87dd825 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -64,6 +64,7 @@ "pageTitle": "音效", "selectStyleTitle": "选择音效风格", "selectCrossfeedTitle": "选择交叉反馈模式", + "selectHearingTitle": "选择听力补偿配置", "lowBass": "低音", "enterBass": "中音", "highBass": "高音", @@ -103,6 +104,10 @@ "BS2B relax(650Hz,9.5dB)" ] }, + "hearing": { + "label": "听力补偿", + "emptyProfile": "无可用配置" + }, "subwoofer": { "label": "低音炮输出", "cutOffFrequency": "截止频率", diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx index 8b8be9e..004512e 100644 --- a/client/src/pages/EffectsPage.tsx +++ b/client/src/pages/EffectsPage.tsx @@ -14,7 +14,8 @@ 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 { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi"; +import { isFirmwareFeatureAvailable } from "@/config/firmwareFeatures"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; import localeEn from "@/locales/data-en.json"; @@ -247,6 +248,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; @@ -265,7 +276,8 @@ export default function EffectsPage() { const subwooferLpfOn = (ds?.subwoofer_lpf_enable ?? 0) === 1; const subwooferHpfOn = (ds?.subwoofer_hpf_enable ?? 0) === 1; const firmwareVersion = parseFirmwareVersion(ds?.version); - const showSubwooferFullRange = firmwareVersion >= 2001; + const showSubwooferFullRange = isFirmwareFeatureAvailable("subwooferFullRange", firmwareVersion); + const showHearingCompensation = isFirmwareFeatureAvailable("hearingCompensation", firmwareVersion); const subwooferDelayMainL = clampSubwooferDelayValue(ds?.subwoofer_delay_main ?? 0); const subwooferDelaySubL = clampSubwooferDelayValue(ds?.subwoofer_delay ?? 0); @@ -393,6 +405,9 @@ 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 subRateIdx = clampPickIndex(subwooferRate, subwooferRateLabels.length); const subRateDisplay = subwooferRateLabels[subRateIdx] ?? ""; const subMixIdx = clampPickIndex(subwooferMixType, subwooferOutputLabels.length); @@ -870,6 +885,35 @@ export default function EffectsPage() { )} + + {/* ── 听力补偿 ── */} + {showHearingCompensation && ( +
+
+ {effectText.hearing?.label ?? "听力补偿"} + updateSetting({ hearing_enable: v ? 1 : 0 })} /> +
+ +
+ )} diff --git a/client/src/pages/SelectPage.tsx b/client/src/pages/SelectPage.tsx index c64b22e..c3e2356 100644 --- a/client/src/pages/SelectPage.tsx +++ b/client/src/pages/SelectPage.tsx @@ -111,7 +111,7 @@ const KEY_TO_SETTING: Record = { effect_value: "effect_value", crossfeed_value: "crossfeed_value", - + hearing_select: "hearing_select", subwoofer_rate: "subwoofer_rate", subwoofer_mix_type: "subwoofer_mix_type", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 225bcc0..bcfd4ff 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,20 +1,33 @@ #!/bin/bash # ============================================================ -# 部署脚本 - 支持 test 环境与 images 目录排除 -# -# 用法: -# ./scripts/deploy.sh 上传至正式环境 (v2) -# ./scripts/deploy.sh -test 上传至测试环境 (test) -# ./scripts/deploy.sh -eximg 排除 images 目录,上传至正式环境 -# ./scripts/deploy.sh -eximg -test 排除 images 目录,上传至测试环境 -# -reload 参数:上传完成后刷新 CloudFront 缓存 -# ./scripts/deploy.sh -reload 上传正式环境并刷新缓存 -# ./scripts/deploy.sh -eximg -test -reload 排除 images,上传测试环境并刷新缓存 +# 部署脚本 - 支持 test/prod 环境与 images 目录排除 # ============================================================ set -e +show_help() { + cat <