新增听力补偿,将有版本限制的功能,提取到配置文件,不再硬编码,优化部署文件参数

This commit is contained in:
eafonyang
2026-06-26 15:03:36 +08:00
parent 522bb602b3
commit 1e5e6f5548
11 changed files with 209 additions and 29 deletions
+28
View File
@@ -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 710 (-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 (06 legacy, 010 when extended steps are supported). */
export function getBootSoundMaxIndex(firmwareVersion: number): number {
return isFirmwareFeatureAvailable("bootSoundExtendedSteps", firmwareVersion) ? 10 : 6;
}
+46 -6
View File
@@ -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<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;
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "截止頻率",
+5
View File
@@ -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": "截止频率",
+46 -2
View File
@@ -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() {
</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={`flex items-center justify-between w-full transition-opacity ${
hearingOn && hearingLabels.length > 0 ? "active:opacity-70" : "cursor-default opacity-50"
}`}
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>
</div>
)}
</FeatureGate>
</div>
+1 -1
View File
@@ -111,7 +111,7 @@ const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
hearing_select: "hearing_select",
subwoofer_rate: "subwoofer_rate",
subwoofer_mix_type: "subwoofer_mix_type",
+56 -18
View File
@@ -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 <<EOF
用法: ./scripts/deploy.sh [环境] [选项]
环境(可选,默认 prod:
prod 上传至正式环境 (s3://.../v2/)
test 上传至测试环境 (s3://.../test/)
选项(可合并,如 -ir:
-i 排除 images 目录 (*images/*)
-r 上传完成后刷新 CloudFront 缓存
-h 显示此帮助
示例:
./scripts/deploy.sh # prod,不排除 images,不刷新
./scripts/deploy.sh test # test 环境
./scripts/deploy.sh test -ir # test,排除 images,并刷新缓存
./scripts/deploy.sh prod -i # prod,排除 images
./scripts/deploy.sh -r # prod,刷新缓存
EOF
}
# 自动识别项目名称 (x8 / x9),依据脚本所在的项目根目录名
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(basename "$(dirname "$SCRIPT_DIR")")"
@@ -26,24 +39,49 @@ if [ -z "$PROJECT_NAME" ]; then
fi
# 解析参数
TARGET_ENV="prod" # 默认正式环境
EXCLUDE_IMAGES=false # 默认不排除 images
RELOAD=false # 默认不刷新 CloudFront 缓存
TARGET_ENV="prod"
EXCLUDE_IMAGES=false
RELOAD=false
for arg in "$@"; do
case "$arg" in
-test)
TARGET_ENV="test"
-h|--help)
show_help
exit 0
;;
-eximg)
test|prod)
TARGET_ENV="$arg"
;;
-*)
flags="${arg#-}"
if [ -z "$flags" ]; then
echo "未知参数: $arg"
echo ""
show_help
exit 1
fi
for ((i = 0; i < ${#flags}; i++)); do
c="${flags:i:1}"
case "$c" in
i)
EXCLUDE_IMAGES=true
;;
-reload)
r)
RELOAD=true
;;
*)
echo "未知选项: -$c"
echo ""
show_help
exit 1
;;
esac
done
;;
*)
echo "未知参数: $arg"
echo "用法: ./scripts/deploy.sh [-test] [-eximg] [-reload]"
echo ""
show_help
exit 1
;;
esac