From cb2d4151650eb09fede2bf6a1441e934dcacd0aa Mon Sep 17 00:00:00 2001 From: fucheng Date: Fri, 30 Oct 2026 17:40:13 +0800 Subject: [PATCH] feat: integrate FIR library and AI FIR controls --- AI_FIR_INTEGRATION.md | 33 ++++ FIR_INTEGRATION.md | 83 ++++++++ client/src/components/AiFirCard.tsx | 171 ++++++++++++++++ client/src/components/FirPanel.tsx | 120 ++++++++++++ client/src/components/FirRoomDetails.tsx | 67 +++++++ client/src/lib/aiFir.ts | 239 +++++++++++++++++++++++ client/src/lib/aiFirLocale.ts | 72 +++++++ client/src/lib/fir.ts | 156 +++++++++++++++ client/src/lib/firLocale.ts | 19 ++ client/src/lib/luxsinApi.ts | 65 ++++++ client/src/locales/data-en.json | 67 +++++++ client/src/locales/data-zh-HK.json | 67 +++++++ client/src/locales/data-zh.json | 67 +++++++ client/src/pages/AIPage.tsx | 45 ++++- client/src/pages/EffectsPage.tsx | 3 + firDevProxy.ts | 36 ++++ scripts/ai-fir.test.mjs | 239 +++++++++++++++++++++++ scripts/fir-proxy.test.mjs | 47 +++++ scripts/fir-regression.test.mjs | 200 +++++++++++++++++++ scripts/fir-ui.test.mjs | 42 ++++ vite.config.ts | 11 ++ vite.legacy.config.ts | 12 +- 22 files changed, 1858 insertions(+), 3 deletions(-) create mode 100644 AI_FIR_INTEGRATION.md create mode 100644 FIR_INTEGRATION.md create mode 100644 client/src/components/AiFirCard.tsx create mode 100644 client/src/components/FirPanel.tsx create mode 100644 client/src/components/FirRoomDetails.tsx create mode 100644 client/src/lib/aiFir.ts create mode 100644 client/src/lib/aiFirLocale.ts create mode 100644 client/src/lib/fir.ts create mode 100644 client/src/lib/firLocale.ts create mode 100644 firDevProxy.ts create mode 100644 scripts/ai-fir.test.mjs create mode 100644 scripts/fir-proxy.test.mjs create mode 100644 scripts/fir-regression.test.mjs create mode 100644 scripts/fir-ui.test.mjs diff --git a/AI_FIR_INTEGRATION.md b/AI_FIR_INTEGRATION.md new file mode 100644 index 0000000..66df16d --- /dev/null +++ b/AI_FIR_INTEGRATION.md @@ -0,0 +1,33 @@ +# AI FIR 分析、确认上传与 A/B + +前端 `x8-controller` 与后端 `luxsin-ai-v2` 需要一起部署。仅更新前端时,旧 AI 服务不会调用新增工具。开发代理增加了 GET 支持,更新后需重启 Vite。 + +## 操作 + +进入 AI 页面点击“分析当前 FIR”,发送问题。AI 使用 `get_current_fir`,浏览器顺序读取设备 `GET /dev/info.cgi?action=fir&channel=left` 和 `right`。支持每耳 8191/8192 个有限 float32 小端系数,两个声道长度必须一致。JSON、错误页、NaN、长度异常拒绝解析,不使用房间库作为设备实际数据的替代。 + +模型收到本次快照标识、抽头数、双耳峰值及位置、能量、系数和(DC)、尾部能量占比。原始数组留在浏览器;不把数万系数交给模型手工重写。采样率未知就保持未知,不从系数推断房间信息或主观好坏。 + +用户给出听感目标后,模型调用 `propose_fir` 提交受限的调整参数及解释。程序基于当前快照生成两耳数组,不上传设备。AI 页面卡片展示建议和“确认并上传双耳数据”。 + +确认后重新读取设备,若与分析快照不同则拒绝应用。先将 A 原始/B 优化的完整 float32 字节保存在 localStorage(按设备 MAC 分开,hex 编码保留包括负零在内的原始位模式),验证写入成功,再依次 POST 左、右。最后 GET 回读两耳并逐样本核对后才显示成功。 + +随后 A/B 按钮分别重新上传原始/优化两耳,不调用 `fir_enable`,不更改开关或音量。每次上传会禁用重复操作。失败保留备份并显示双耳状态未确认,允许显式重新上传 A 或 B;不自动重试、回滚或假定部分上传是原子操作。重开页面不会把缓存中的最后状态当作当前设备状态。 + +浏览器备份只保留每台设备最近一次确认的 A/B 对。清除浏览器数据、更换浏览器或访问源(localhost 与局域网地址不同源)会丢失/隔离备份。未确认的新建议只在当前 AI 页面内存中保留,不保存为后端聊天记录。 + +## 当前 DSP 范围 + +- 两耳共同增益衰减:-12 至 0 dB,不放大。 +- 尾部衰减:-24 至 0 dB;从双耳较晚的主峰之后使用共同的线性 dB 包络,末抽头达到目标衰减。 +- 平滑:0 至 1,将原系数与因果三抽头 `[0.25, 0.5, 0.25]` 平滑混合。高频减弱,有最多一个样本的群延迟,末尾两样本滤波尾巴截断;不改变总抽头数。 + +左右耳使用同一套参数及共同包络,不各自归一化或移动主峰。保留左右耳时序关系;平滑会改变响应,不能声称频率相关的双耳线索完全不变。这不是任意频响校正、房间重建或延长混响算法。需要这些功能时,AI 应解释当前限制,而不能声称已经实现。 + +## 验证 + +前端:`node --test scripts/ai-fir.test.mjs scripts/fir-*.test.mjs scripts/eq-save-regression.test.mjs scripts/festival-regression.test.mjs`,以及 TypeScript、modern/legacy 构建。 + +后端:`python -m pytest -q`,新增 `tests/chat/test_fir.py` 覆盖 schema、工具返回、确认边界、历史快照失效。测试环境使用项目基准 SQLModel 0.0.38、Anthropic 0.91.0;最新 SQLModel 0.0.48 对原有无时区 datetime 的存储行为有兼容差异。 + +真实设备只读验证 GET:HTTP 200,Content-Type application/octet-stream,32768 字节。浏览器上传和 A/B 验证使用模拟设备,未改写真实设备。实际 AI 模型联调需启动/部署带新增工具的后端并配置有效模型凭证。 diff --git a/FIR_INTEGRATION.md b/FIR_INTEGRATION.md new file mode 100644 index 0000000..83f8565 --- /dev/null +++ b/FIR_INTEGRATION.md @@ -0,0 +1,83 @@ +# FIR 设备集成 + +效果页 `/effects` 提供 FIR 开关;关闭时仅显示标题与开关,开启后显示数据来源、房间选择或手动双耳 `.bin` 上传及提交按钮。下拉选项仅显示房间名称,内部仍使用 room_id,隐藏编号、房间详情、格式说明和刷新按钮。文案与错误提示遵循设备语言,支持英文、简体中文、繁体中文;房间原始名称保持原文。上传前校验两耳,顺序上传左耳及右耳;上传全程不发送 fir_enable 请求,不改变开关状态。只有用户手动切换开关时才调用开关接口。请求成功只表示 HTTP 成功,最终是否生效仍需固件联调确认。 + +## 开发启动 + +先启动 Luxsin FIR 文件库(默认 `http://127.0.0.1:8000`),再运行本项目 `npm run dev`,访问: + +``` +http://localhost:3000/?ip=192.168.9.127#/effects +``` + +modern 和 legacy Vite 开发入口均将 `/fir-library/*` 代理到 `http://127.0.0.1:8000/api/*`。修改 Vite 配置后需重启开发服务。房间读取失败不会阻止手动文件上传。 + +生产部署时需在页面服务器配置同样的反向代理,或通过构建环境变量 `VITE_FIR_API_BASE` 指定可访问的 API 基地址(包含 `/api`,例如 `https://library.example.com/api`)。跨源配置需要文件库服务器允许 CORS;HTTPS 页面不能请求 HTTP 文件库。设备请求仍沿用控制器原有设备 IP 和协议选择,设备端须允许对应浏览器访问。 + +## 文件库读取接口 + +- `GET /api/rooms/defaults`:默认房间列表,使用 `room_id` 和 `label` 构造下拉选项,保留 `sample_rate`、`taps`、`metadata`、`measurement` 供校验,但不在精简界面展示。 +- `GET /api/rooms/{room_id}/device/left`、`/device/right`:选择确认后读取设备专用二进制,严格每侧 8192 抽头、32768 字节,校验后原字节上传。后端将原 8191 抽头转换为 float32 小端并在末尾补零。未设置采样率的房间拒绝上传。 + +## 设备协议 + +开关(用户已确认): + +``` +GET http://设备IP/dev/info.cgi?action=setting&fir_enable=0 +GET http://设备IP/dev/info.cgi?action=setting&fir_enable=1 +``` + +同步状态 `syncData` 可返回 `fir_enable: 0 | 1`。未提供时默认显示关闭状态,不显示状态说明;开关请求成功后更新本地状态,请求失败不显示成功。 + +双耳上传: + +``` +POST http://设备IP/dev/info.cgi?action=fir&channel=left +POST http://设备IP/dev/info.cgi?action=fir&channel=right +Content-Type: application/octet-stream +``` + +请求体为无文件头的 IEEE754 float32 小端数据,左右耳分开,不使用 multipart,不附带 JSON、采样率或长度字段。手动文件保持原字节;房间输出由后端适配为 8192 抽头,不归一化、不重采样: + +| 抽头数 | 每侧字节数 | +| --- | --- | +| 8191(兼容手动文件) | 32764 | +| 8192(房间设备输出及手动样例) | 32768 | + +手动 `.bin` 原字节上传;要求以上两种长度之一、每个值为有限 float32、双耳抽头数一致。无法从无头二进制可靠判断采样率、原始格式或大小端,用户需确认文件符合协议并适配设备采样率。数组超过 float32 有限范围时拒绝转换。 + +失败时不自动重试、不跨重连补发,不改变 FIR 开关。两次单声道写入不具备设备端事务:左耳成功、右耳失败时必须重新上传两侧。页面提示未完成;上传失败会显示具体声道及错误原因,用户可重新上传两侧。所有设备请求沿用现有 4 秒超时。 + +## 无硬件测试 + +```sh +node --test scripts/fir-regression.test.mjs scripts/fir-ui.test.mjs +npx tsc --noEmit --incremental false +npm run build:modern +npm run build:legacy +``` + +测试通过 mock fetch 执行实际的 LuxsinAPI 方法及 applyFir 流程,校验 URL、POST 类型、原始字节、双耳顺序、开关、格式拒绝、404、网络及单侧失败。不向真实设备发送请求。设备固件尚未实现接口,因此测试不能替代固件兼容性验证。 + +## 开发代理与生产直连 + +开发模式(`npm run dev` / `npm run dev:legacy`)的 FIR 二进制上传使用同源代理 `/fir-device/{私网IPv4}/{left|right}`。Vite 将原始二进制转发至设备 `/dev/info.cgi?action=fir&channel=left|right`,避免浏览器跨域预检。代理只允许私网 IPv4、端口 80、POST、指定声道及 32764/32768 字节长度;不转发浏览器凭据。修改配置后 Vite 会重启,刷新页面再测试。 + +生产构建仍由浏览器直连设备,需要设备支持 CORS 或由部署服务器提供同源代理。房间数据读取仍使用 `/fir-library`。代理不修改数据、不重试,也不调用 FIR 开关。 + +房间数据使用设备专用接口输出 8192 抽头、32768 字节;内部存储及旧数组接口保持 8191 抽头。HTTP 400 表示服务端拒绝请求,需结合设备校验原因排查。 + +代理测试:`node --test scripts/fir-proxy.test.mjs`。 + + +## WAV 上传协议更新 + +设备 POST `/dev/info.cgi?action=fir&channel=left|right` 使用 `Content-Type: audio/wav`,请求体为完整单声道 IEEE float32 WAV(不是 multipart)。房间库 `/api/rooms/{room_id}/device/{side}` 返回 WAV;前端解析并保留采样率,上传时生成含 fmt、fact、data 的 WAV。手动导入使用 .wav。内部计算、AI FIR 和设备 GET 备份仍保留原始 float32 数据;没有采样率信息时上传默认 48000 Hz。此说明取代前文关于 POST 裸二进制的说明。 + + +## 已核实的设备上传限制 + +设备 HTTP 400 正文明确要求:单声道、48000 Hz、8192 点,PCM16/24/32 或 float32。现在 encodeDeviceFirWav 将所有上传统一为 float32、48 kHz、8192 点。旧房间库的 44.1 kHz 裸系数先按元数据解析,再用窗函数 sinc 重采样;不是只改 WAV 文件头。重采样按源/目标采样率缩放以保持卷积增益,两耳使用同一时间轴;过长响应固定截取 8192 点,末尾 64 点渐隐,不足补零。48 kHz / 8192 点原系数字节不变。开发代理关闭设备连接复用,避免固件 Keep-Alive 导致回读截断。以上更新取代前文关于保留源采样率上传的说明。 + +2026-10-23 实机验证:Listening Room 左右耳上传均 HTTP 200;设备 GET 返回 WAV 与生成 WAV 逐字节一致。未改变 fir_enable 或音量。 diff --git a/client/src/components/AiFirCard.tsx b/client/src/components/AiFirCard.tsx new file mode 100644 index 0000000..934276c --- /dev/null +++ b/client/src/components/AiFirCard.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from "react"; +import type { LuxsinAPI } from "@/lib/luxsinApi"; +import { + applyFirVersion, + loadFirBackup, + type FirBackup, + type FirProposal, +} from "@/lib/aiFir"; +import { aiFirText } from "@/lib/aiFirLocale"; +export default function AiFirCard({ + api, + device, + language, + proposal, + disabled, + onDiscard, + onBusy, +}: { + api: LuxsinAPI | null; + device: string; + language: number; + proposal: FirProposal | null; + disabled: boolean; + onDiscard: () => void; + onBusy: (busy: boolean) => void; +}) { + const t = aiFirText(language); + const [backup, setBackup] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const locked = useRef(false); + useEffect(() => { + setBackup(null); + setError(""); + if (!device) return; + try { + const saved = loadFirBackup(localStorage, device); + if (saved) setBackup({ ...saved, state: "incomplete" }); + } catch { + setError(t.cache); + } + }, [device]); + const staged = proposal?.device === device ? proposal : null; + const shown = staged || backup?.proposal; + if (!shown) + return error ? ( +

+ {error} +

+ ) : null; + const apply = async (target: "A" | "B") => { + if (!api || locked.current || disabled) return; + locked.current = true; + setBusy(true); + onBusy(true); + setError(""); + const value: FirBackup = staged + ? { version: 1, proposal: staged, state: "pending" } + : backup!; + try { + const done = await applyFirVersion( + api, + localStorage, + value, + target, + !!staged + ); + setBackup(done); + onDiscard(); + } catch (e) { + try { + const saved = loadFirBackup(localStorage, device); + if (saved && saved.proposal.id === value.proposal.id) { + setBackup({ ...saved, state: "incomplete" }); + onDiscard(); + } + } catch {} + setError( + e instanceof Error && e.message === "FIR_CHANGED" + ? t.changed + : e instanceof Error && + (e.message === "FIR_BACKUP" || + ["SecurityError", "QuotaExceededError"].includes(e.name)) + ? t.cache + : `${t.error} ${e instanceof Error ? e.message : ""}` + ); + } finally { + locked.current = false; + setBusy(false); + onBusy(false); + } + }; + return ( +
+
+

{t.title}

+ + {shown.before.left.length} {t.taps} + +
+

{shown.adjustment.name}

+

+ {shown.adjustment.explanation} +

+

+ {t.gain}: {shown.adjustment.gain_db} dB · {t.tail}:{" "} + {shown.adjustment.tail_gain_db} dB · {t.smooth}:{" "} + {Math.round(shown.adjustment.smoothing * 100)}% +

+

{t.backup}

+

+ {busy + ? t.busy + : staged + ? t.ready + : backup?.state === "incomplete" + ? t.incomplete + : `${t.verified} ${backup?.state}`} +

+ {error && ( +

+ {error} +

+ )} +
+ {staged ? ( + <> + + + + ) : ( + (["A", "B"] as const).map(target => ( + + )) + )} +
+ {staged && backup?.state === "incomplete" && ( + + )} +
+ ); +} diff --git a/client/src/components/FirPanel.tsx b/client/src/components/FirPanel.tsx new file mode 100644 index 0000000..f3dd6c2 --- /dev/null +++ b/client/src/components/FirPanel.tsx @@ -0,0 +1,120 @@ +import FirRoomDetails from "./FirRoomDetails"; +import { ChevronDown } from "lucide-react"; +import { toast } from "sonner"; +import { firTranslate, firErrorNotice, firNoticeText, type FirNotice } from '@/lib/firLocale'; +import { useEffect, useRef, useState } from 'react'; +import { useDevice } from '@/contexts/DeviceContext'; +import { decodeFirWav, FirError, firRoomName, applyFir, readFirBinary, readFirJson, validateFirBinary, type FirRoom } from '@/lib/fir'; + +const LIBRARY = (import.meta.env.VITE_FIR_API_BASE || '/fir-library').replace(/\/$/, ''); + +export default function FirPanel() { + const { ip } = useDevice(); + return ; +} +function FirControls() { + const { api, isConnected, isDemoMode, deviceState } = useDevice(); + const [enabled, setEnabled] = useState(deviceState?.fir_enable === undefined ? null : deviceState.fir_enable === 1); + const [rooms, setRooms] = useState([]); + const [selected, setSelected] = useState(''); + const [detailsOpen, setDetailsOpen] = useState(false); + const [source, setSource] = useState<'library' | 'manual'>('library'); + const [left, setLeft] = useState(null); + const [right, setRight] = useState(null); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const t = (code: string, params?: Record) => firTranslate(deviceState?.language, code, params); + const lock = useRef(false); + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + useEffect(() => { + if (deviceState?.fir_enable !== undefined && !lock.current) setEnabled(deviceState.fir_enable === 1); + }, [deviceState?.fir_enable]); + const connected = !!api && isConnected && !isDemoMode; + const room = rooms.find(item => item.room_id === selected); + async function loadRooms() { + if (lock.current) return; + setLoading(true); setError(null); + try { + const items = await readFirJson(`${LIBRARY}/rooms/defaults`); + if (!Array.isArray(items)) throw new FirError('invalidRooms'); + if (mounted.current) { + setRooms(items); + setSelected(value => items.some(r => r.room_id === value) ? value : items[0]?.room_id || ''); + } + } catch (e) { if (mounted.current) setError(firErrorNotice(e)); } + finally { if (mounted.current) setLoading(false); } + } + useEffect(() => { void loadRooms(); }, []); + async function toggle(value: boolean) { + if (!connected || !api || lock.current) return; + lock.current = true; setBusy(true); setError(null); setStatus(null); + try { await api.setFirEnable(value); if (mounted.current) { setEnabled(value); setStatus(null); } } + catch (e) { if (mounted.current) { setEnabled(null); setError(firErrorNotice(e)); } } + finally { lock.current = false; if (mounted.current) setBusy(false); } + } + async function upload() { + if (!connected || !api || lock.current) return; + lock.current = true; setBusy(true); setError(null); setStatus({ code: 'validating' }); + try { + let l: ArrayBuffer, r: ArrayBuffer; + if (source === 'library') { + if (!room) throw new FirError('selectError'); + if (room.sample_rate == null) throw new FirError('rateError'); + const base = `${LIBRARY}/rooms/${encodeURIComponent(room.room_id)}/device`; + [l, r] = await Promise.all([readFirBinary(`${base}/left`, room.sample_rate), readFirBinary(`${base}/right`, room.sample_rate)]); + } else { + if (!left || !right) throw new FirError('filesError'); + // Check sizes before loading potentially large files. + if (left.size > 1024 * 1024 || right.size > 1024 * 1024) throw new FirError('binarySize'); + [l, r] = await Promise.all([left.arrayBuffer().then(decodeFirWav), right.arrayBuffer().then(decodeFirWav)]); + } + if (validateFirBinary(l) !== validateFirBinary(r)) throw new FirError('mismatch'); + if (!mounted.current) return; + await applyFir(api, l, r, message => { if (mounted.current) { setStatus({ code: message }); } }); + if (mounted.current) { setStatus(null); toast.success(t('success', { count: l.byteLength / 4 }), { duration: 10000 }); } + } catch (e) { + if (mounted.current) { setStatus(null); setError(firErrorNotice(e)); } + } finally { lock.current = false; if (mounted.current) setBusy(false); } + } + const control = 'w-full rounded-lg border border-white/20 bg-black p-2 text-sm text-white'; + return
+
+

{t('title')}

+ +
+ {(enabled === true || busy) &&
+ + {source === 'library' ? <> +
+
+ + +
+ +
+ : <> + {(['left', 'right'] as const).map(side => )} + } + +
} + {room && } + {status && (enabled === true || busy) &&

{t(status.code, status.params)}

} + {error && (enabled === true || error.code !== 'libraryError') &&

{firNoticeText(deviceState?.language, error)}

} +
; +} diff --git a/client/src/components/FirRoomDetails.tsx b/client/src/components/FirRoomDetails.tsx new file mode 100644 index 0000000..555ed14 --- /dev/null +++ b/client/src/components/FirRoomDetails.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; +import { firImageUrl, firRoomName, type FirRoom } from '@/lib/fir'; +import { firTranslate } from '@/lib/firLocale'; + +const LIBRARY = (import.meta.env.VITE_FIR_API_BASE || '/fir-library').replace(/\/$/, ''); + +type Props = { room: FirRoom; language?: number }; +export function FirRoomDetailsBody({ room, language }: Props) { + const t = (key: string) => firTranslate(language, key); + const imageUrl = firImageUrl(room.image_url, LIBRARY); + const [failedImage, setFailedImage] = useState(null); + const metadata = room.metadata || {}; + const value = (key: string) => metadata[key]?.trim() || '—'; + // Only attach units to scalar values; preserve CSV configuration annotations verbatim. + const unit = (text: string, suffix: string) => /^[+-]?\d+(?:\.\d+)?$/.test(text) ? `${text} ${suffix}` : text; + const groups: { title: string; fields: [string, string][] }[] = [ + { title: 'detailRoom', fields: [ + ['detailName', firRoomName(room) || t('unnamed')], ['detailId', room.room_id], + ['detailLocation', value('Room Location')], ['detailType', value('Room Type')], + ['detailDimensions', value('Room Dimensions (m) (l x w x h)')], + ] }, + { title: 'detailAcoustics', fields: [ + ['detailRt60', unit(value('RT60 (s)'), 's')], + ['detailReduced', /^(yes|no)$/i.test(value('Reduced Reverberation?')) ? t(value('Reduced Reverberation?').toLowerCase() === 'yes' ? 'detailYes' : 'detailNo') : value('Reduced Reverberation?')], + ['detailListener', value('Listener')], ['detailDistance', unit(value('Source Distance (m)'), 'm')], + ['detailAzimuthRange', value('Source Azimuth Range')], ['detailElevationRange', value('Source Elevation Range')], + ['detailConfigurations', value('Measurement Configurations')], + ] }, + ]; + const renderGroup = (group: typeof groups[number]) =>
+

{t(group.title)}

+
+ {group.fields.map(([label, text]) =>
+
{t(label)}
+
{text}
+
)} +
+
; + const hasImage = imageUrl && failedImage !== imageUrl; + return
+ {hasImage &&
+ + {firRoomName(room)} setFailedImage(imageUrl)} /> + +
+ {t('detailDiagram')} + {t('detailEnlarge')} ↗ +
+
} + {!hasImage && groups.map(renderGroup)} +
; + +} +export default function FirRoomDetails({ room, language, open, onOpenChange }: Props & { open: boolean; onOpenChange: (open: boolean) => void }) { + const t = (key: string) => firTranslate(language, key); + return + +
+
{firRoomName(room) || t('unnamed')} + {t('detailTitle')}
+ {t('detailClose')} +
+ +
+
; +} diff --git a/client/src/lib/aiFir.ts b/client/src/lib/aiFir.ts new file mode 100644 index 0000000..2c64710 --- /dev/null +++ b/client/src/lib/aiFir.ts @@ -0,0 +1,239 @@ +import { encodeFir, encodeDeviceFirWav, validateFirBinary, applyFir } from "./fir"; +import type { LuxsinAPI } from "./luxsinApi"; +export type FirPair = { left: number[]; right: number[] }; +export type FirAdjustment = { + snapshot_id: string; + name: string; + explanation: string; + gain_db: number; + tail_gain_db: number; + smoothing: number; +}; +export type FirProposal = { + id: string; + device: string; + before: FirPair; + after: FirPair; + adjustment: FirAdjustment; + created: string; +}; +export type FirBackup = { + version: 1; + proposal: FirProposal; + state: "pending" | "A" | "B" | "incomplete"; +}; +export function decodeFir(buffer: ArrayBuffer): number[] { + validateFirBinary(buffer); + const view = new DataView(buffer); + return Array.from({ length: buffer.byteLength / 4 }, (_, i) => + view.getFloat32(i * 4, true) + ); +} +export function validatePair(pair: FirPair) { + if ( + !pair || + !Array.isArray(pair.left) || + !Array.isArray(pair.right) || + pair.left.length !== pair.right.length + ) + throw new Error("Invalid FIR pair"); + encodeFir(pair.left); + encodeFir(pair.right); +} +export async function readCurrentFir( + api: Pick +): Promise { + // Serial reads avoid concurrent work on constrained device firmware. + const left = decodeFir(await api.getFirChannel("left")); + const right = decodeFir(await api.getFirChannel("right")); + const pair = { left, right }; + validatePair(pair); + return pair; +} +function peakIndex(values: number[]) { + let index = 0; + values.forEach((x, i) => { + if (Math.abs(x) > Math.abs(values[index])) index = i; + }); + return index; +} +export function firMetrics(pair: FirPair) { + validatePair(pair); + const channel = (values: number[]) => { + const peak_index = peakIndex(values); + const energy = values.reduce((sum, x) => sum + x * x, 0); + const tailStart = + peak_index + Math.max(1, Math.floor((values.length - peak_index) / 4)); + return { + peak: Math.abs(values[peak_index]), + peak_index, + energy, + dc_sum: values.reduce((sum, x) => sum + x, 0), + late_energy_ratio: energy + ? values.slice(tailStart).reduce((sum, x) => sum + x * x, 0) / energy + : 0, + }; + }; + return { + taps: pair.left.length, + format: "float32-le", + sample_rate: null, + left: channel(pair.left), + right: channel(pair.right), + metric_note: + "late_energy_ratio: energy in last 75% of taps following each peak, not an RT60 measurement. Coefficients do not establish subjective quality.", + }; +} +export function renderFir(pair: FirPair, a: FirAdjustment): FirPair { + validatePair(pair); + for (const [key, min, max] of [ + ["gain_db", -12, 0], + ["tail_gain_db", -24, 0], + ["smoothing", 0, 1], + ] as const) { + if ( + typeof a[key] !== "number" || + !Number.isFinite(a[key]) || + a[key] < min || + a[key] > max + ) + throw new Error(`Invalid ${key}`); + } + if ( + !a.snapshot_id || + typeof a.name !== "string" || + !a.name.trim() || + typeof a.explanation !== "string" || + !a.explanation.trim() + ) + throw new Error("Invalid FIR recommendation"); + // Same envelope on BOTH ears; keep ITD/level differences instead of aligning or normalizing separately. + const start = Math.max(peakIndex(pair.left), peakIndex(pair.right)); + const channel = (values: number[]) => + values.map((x, i) => { + const smooth = + 0.25 * x + 0.5 * (values[i - 1] ?? 0) + 0.25 * (values[i - 2] ?? 0); + const tail = + Math.max(0, i - start) / Math.max(1, values.length - 1 - start); + return Math.fround( + ((1 - a.smoothing) * x + a.smoothing * smooth) * + 10 ** ((a.gain_db + a.tail_gain_db * tail) / 20) + ); + }); + const result = { left: channel(pair.left), right: channel(pair.right) }; + validatePair(result); + return result; +} +export function sameFir(a: FirPair, b: FirPair): boolean { + return (["left", "right"] as const).every( + side => + a[side].length === b[side].length && + a[side].every((x, i) => + Object.is(Math.fround(x), Math.fround(b[side][i])) + ) + ); +} +// Hex preserves exact float32 bytes including signed zero; do not JSON serialize sample arrays directly. +function pack(pair: FirPair) { + return Object.fromEntries( + (["left", "right"] as const).map(side => [ + side, + Array.from(new Uint8Array(encodeFir(pair[side])), x => + x.toString(16).padStart(2, "0") + ).join(""), + ]) + ); +} +function unpack(value: Record): FirPair { + const channel = (side: string) => { + const hex = value[side]; + if ( + typeof hex !== "string" || + !/^[0-9a-f]+$/.test(hex) || + ![65528, 65536].includes(hex.length) + ) + throw new Error("Invalid cached FIR"); + return decodeFir( + Uint8Array.from(hex.match(/../g)!, x => parseInt(x, 16)).buffer + ); + }; + const pair = { left: channel("left"), right: channel("right") }; + validatePair(pair); + return pair; +} +const cacheKey = (device: string) => `luxsin-ai-fir-v1:${device}`; +export function saveFirBackup(storage: Storage, value: FirBackup) { + const proposal = value.proposal; + validatePair(proposal.before); + validatePair(proposal.after); + const raw = JSON.stringify({ + ...value, + proposal: { + ...proposal, + before: pack(proposal.before), + after: pack(proposal.after), + }, + }); + storage.setItem(cacheKey(proposal.device), raw); + if (storage.getItem(cacheKey(proposal.device)) !== raw) + throw new Error("FIR backup verification failed"); +} +export function loadFirBackup( + storage: Storage, + device: string +): FirBackup | null { + const raw = storage.getItem(cacheKey(device)); + if (!raw) return null; + const value = JSON.parse(raw); + if ( + value.version !== 1 || + value.proposal?.device !== device || + !["pending", "A", "B", "incomplete"].includes(value.state) + ) + throw new Error("Invalid FIR backup"); + return { + ...value, + proposal: { + ...value.proposal, + before: unpack(value.proposal.before), + after: unpack(value.proposal.after), + }, + }; +} +export async function applyFirVersion( + api: Pick, + storage: Storage, + backup: FirBackup, + target: "A" | "B", + first = false +) { + if (first && !sameFir(await readCurrentFir(api), backup.proposal.before)) + throw new Error("FIR_CHANGED"); + try { + saveFirBackup(storage, { ...backup, state: "pending" }); // Must succeed BEFORE either POST. + } catch { + throw new Error("FIR_BACKUP"); + } + const pair = target === "A" ? backup.proposal.before : backup.proposal.after; + try { + await applyFir(api, encodeFir(pair.left), encodeFir(pair.right)); + if (!sameFir(await readCurrentFir(api), pair)) + throw new Error("FIR_VERIFY"); + const done: FirBackup = { ...backup, state: target }; + saveFirBackup(storage, done); + return done; + } catch (error) { + try { + saveFirBackup(storage, { ...backup, state: "incomplete" }); + } catch { + /* pending backup already preserves originals */ + } + throw error; + } +} + +/** Export deterministic A/B samples as device-ready WAV files. Raw device GET has no rate; use 48 kHz. */ +export function firPairWavs(pair: FirPair) { + validatePair(pair); + return { left: encodeDeviceFirWav(encodeFir(pair.left)), right: encodeDeviceFirWav(encodeFir(pair.right)) }; +} diff --git a/client/src/lib/aiFirLocale.ts b/client/src/lib/aiFirLocale.ts new file mode 100644 index 0000000..c384ceb --- /dev/null +++ b/client/src/lib/aiFirLocale.ts @@ -0,0 +1,72 @@ +export function aiFirText(language: number) { + const en = { + title: "FIR comparison", + confirm: "Confirm and upload both ears", + a: "A · Original", + b: "B · Optimized", + cancel: "Discard recommendation", + busy: "Uploading and verifying both ears…", + ready: "Recommendation ready; device unchanged.", + backup: + "Original coefficients are backed up in this browser before upload. A/B changes the actual device FIR.", + incomplete: + "Both-ear state is unconfirmed. Upload A or B again to restore a complete pair.", + error: "FIR operation failed. Original backup is retained.", + changed: + "Device FIR changed since analysis. Ask AI to read and optimize again.", + cache: "Browser backup unavailable. Upload has been blocked.", + verified: "Both ears uploaded and verified.", + analyze: "Analyze current FIR", + prompt: + "Please read and analyze my current left and right FIR, then ask me what listening changes I want before generating optimized FIR.", + gain: "Gain", + tail: "Tail attenuation", + smooth: "Smoothing", + taps: "taps per ear", + }; + const zh = { + title: "FIR 对比", + confirm: "确认并上传双耳数据", + a: "A · 原始", + b: "B · 优化", + cancel: "放弃此建议", + busy: "正在上传并校验双耳数据…", + ready: "优化建议已生成,设备尚未修改。", + backup: "上传前会在当前浏览器备份原始系数;A/B 切换会修改设备实际 FIR。", + incomplete: "双耳状态尚未确认,请重新上传 A 或 B 恢复完整双耳数据。", + error: "FIR 操作失败,原始备份仍保留。", + changed: "设备 FIR 已在分析后发生变化,请让 AI 重新读取并优化。", + cache: "浏览器备份不可用,已阻止上传。", + verified: "双耳上传并校验成功。", + analyze: "分析当前 FIR", + prompt: + "请读取并分析当前左右耳 FIR,先询问我希望改善的听感,再生成优化后的 FIR。", + gain: "增益", + tail: "尾部衰减", + smooth: "平滑", + taps: "抽头/耳", + }; + const hk = { + title: "FIR 對比", + confirm: "確認並上傳雙耳資料", + a: "A · 原始", + b: "B · 優化", + cancel: "放棄此建議", + busy: "正在上傳並校驗雙耳資料…", + ready: "優化建議已生成,裝置尚未修改。", + backup: "上傳前會在目前瀏覽器備份原始係數;A/B 切換會修改裝置實際 FIR。", + incomplete: "雙耳狀態尚未確認,請重新上傳 A 或 B 恢復完整雙耳資料。", + error: "FIR 操作失敗,原始備份仍保留。", + changed: "裝置 FIR 已在分析後發生變化,請讓 AI 重新讀取並優化。", + cache: "瀏覽器備份不可用,已阻止上傳。", + verified: "雙耳上傳並校驗成功。", + analyze: "分析目前 FIR", + prompt: + "請讀取並分析目前左右耳 FIR,先詢問我希望改善的聽感,再生成優化後的 FIR。", + gain: "增益", + tail: "尾部衰減", + smooth: "平滑", + taps: "抽頭/耳", + }; + return language === 2 ? zh : language === 1 ? hk : en; +} diff --git a/client/src/lib/fir.ts b/client/src/lib/fir.ts new file mode 100644 index 0000000..ac535e6 --- /dev/null +++ b/client/src/lib/fir.ts @@ -0,0 +1,156 @@ +export class FirError extends Error { + constructor(public code: string, public params: Record = {}) { super(code); } +} +export function firRoomName(room: FirRoom): string { + return room.name?.trim() || room.metadata?.['Room Name']?.trim() || room.label.replace(/^R\d+\s*[·:—-]\s*/i, '').trim(); +} +/** Internal coefficients use float32 LE; upload transport uses WAV. */ +export interface FirRoom { + room_id: string; image_url?: string | null; name?: string; label: string; sample_rate: number | null; taps: number; + metadata: Record; + measurement: { configuration?: string; elevation?: number; azimuth?: number; source?: string } | null; +} +export interface FirImpulse extends FirRoom { left: number[]; right: number[] } +export function encodeFir(values: number[]): ArrayBuffer { + if (!Array.isArray(values) || ![8191, 8192].includes(values.length)) throw new FirError('tapCount'); + const buffer = new ArrayBuffer(values.length * 4); + const view = new DataView(buffer); + values.forEach((value, index) => { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(Math.fround(value))) throw new FirError('finite', { index: index + 1 }); + view.setFloat32(index * 4, value, true); + }); + return buffer; +} +export function validateFirBinary(buffer: ArrayBuffer): number { + if (![8191 * 4, 8192 * 4].includes(buffer.byteLength)) throw new FirError('binarySize'); + const view = new DataView(buffer); + for (let i = 0; i < buffer.byteLength; i += 4) { + if (!Number.isFinite(view.getFloat32(i, true))) throw new FirError('finite', { index: i / 4 + 1 }); + } + return buffer.byteLength / 4; +} +export async function readFirJson(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new FirError('libraryError', { status: response.status }); + return await response.json(); + } finally { clearTimeout(timer); } +} +export interface FirDevice { + uploadFirChannel(side: 'left' | 'right', data: ArrayBuffer): Promise; +} +/** Validate both sides before any device write. Upload coefficients only; never change FIR enable state or replay partial writes. */ +export async function applyFir(device: FirDevice, left: ArrayBuffer, right: ArrayBuffer, onProgress?: (message: string) => void) { + const count = validateFirBinary(left); + if (validateFirBinary(right) !== count || (wavRates.get(left) ?? 48000) !== (wavRates.get(right) ?? 48000)) throw new FirError('mismatch'); + try { + onProgress?.('uploadingLeft'); await device.uploadFirChannel('left', left); + onProgress?.('uploadingRight'); await device.uploadFirChannel('right', right); + } catch (error) { + throw Object.assign(new FirError('incomplete'), { cause: error }); + } +} + +/** Accept WAV and the previous library's explicitly typed raw float32 export. + * Older deployed libraries return .bin; convert only at the device upload boundary. + */ +export async function readFirBinary(url: string, sampleRate?: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(url, { signal: controller.signal, cache: 'no-store' }); + if (!response.ok) throw new FirError('libraryError', { status: response.status }); + const payload = await response.arrayBuffer(); + const isWav = payload.byteLength >= 4 && new DataView(payload).getUint32(0, false) === 0x52494646; + try { + if (isWav) return decodeFirWav(payload); + const type = (response.headers.get('Content-Type') || '').split(';')[0].trim().toLowerCase(); + if (type !== 'application/octet-stream' || payload.byteLength !== 32768) throw new FirError('binarySize'); + validateFirBinary(payload); + const headerRate = response.headers.get('X-FIR-Sample-Rate'); + const rate = headerRate == null ? sampleRate : Number(headerRate); + if (!rate || !Number.isInteger(rate) || rate <= 0 || rate > 0x3fffffff) throw new FirError('rateError'); + wavRates.set(payload, rate); + return payload; + } catch (error) { + if (error instanceof FirError && error.code === 'rateError') throw error; + throw new FirError('libraryFormat'); + } + } finally { clearTimeout(timer); } +} + +/** Lossless mono IEEE float WAV, matching device exports (fmt + fact + data). */ +const wavRates = new WeakMap(); +export function encodeFirWav(data: ArrayBuffer, sampleRate = wavRates.get(data) ?? 48000): ArrayBuffer { + const taps = validateFirBinary(data); + const result = new ArrayBuffer(58 + data.byteLength); + const v = new DataView(result); + const tag = (offset: number, text: string) => text.split('').forEach((c, i) => v.setUint8(offset + i, c.charCodeAt(0))); + tag(0, 'RIFF'); v.setUint32(4, result.byteLength - 8, true); tag(8, 'WAVE'); tag(12, 'fmt '); + v.setUint32(16, 18, true); v.setUint16(20, 3, true); v.setUint16(22, 1, true); + v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 4, true); + v.setUint16(32, 4, true); v.setUint16(34, 32, true); v.setUint16(36, 0, true); + tag(38, 'fact'); v.setUint32(42, 4, true); v.setUint32(46, taps, true); + tag(50, 'data'); v.setUint32(54, data.byteLength, true); new Uint8Array(result, 58).set(new Uint8Array(data)); + return result; +} +export function decodeFirWav(data: ArrayBuffer): ArrayBuffer { + const v = new DataView(data); + const tag = (o: number) => String.fromCharCode(...Array.from(new Uint8Array(data, o, 4))); + if (data.byteLength < 12 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE' || v.getUint32(4, true) + 8 !== data.byteLength) throw new FirError('binarySize'); + let format = false, rate = 0, samples: ArrayBuffer | undefined; + for (let o = 12; o + 8 <= data.byteLength;) { + const size = v.getUint32(o + 4, true), start = o + 8; + if (start + size > data.byteLength) throw new FirError('binarySize'); + if (tag(o) === 'fmt ') { + if (size < 16 || v.getUint16(start, true) !== 3 || v.getUint16(start + 2, true) !== 1 || v.getUint32(start + 4, true) === 0 || v.getUint32(start + 8, true) !== v.getUint32(start + 4, true) * 4 || v.getUint16(start + 12, true) !== 4 || v.getUint16(start + 14, true) !== 32) throw new FirError('binarySize'); + format = true; rate = v.getUint32(start + 4, true); + } + if (tag(o) === 'data') samples = data.slice(start, start + size); + o = start + size + (size % 2); + } + if (!format || !samples) throw new FirError('binarySize'); + validateFirBinary(samples); wavRates.set(samples, rate); return samples; +} + +/** Device firmware requires exactly 48 kHz / 8192 taps. Resample the IR, + * scaling by source/target rate to preserve convolution gain (not audio amplitude). + * Windowed sinc uses the same time grid for both ears; never align peaks separately. + */ +export function encodeDeviceFirWav(data: ArrayBuffer): ArrayBuffer { + const taps = validateFirBinary(data), sourceRate = wavRates.get(data) ?? 48000; + if (!Number.isInteger(sourceRate) || sourceRate < 8000 || sourceRate > 384000) throw new FirError('rateError'); + const result = new ArrayBuffer(8192 * 4), out = new DataView(result), input = new DataView(data); + if (sourceRate === 48000) { + new Uint8Array(result).set(new Uint8Array(data)); + return encodeFirWav(result, 48000); + } + const step = sourceRate / 48000, cutoff = Math.min(1, 1 / step), radius = Math.ceil(32 / cutoff); + const frames = Math.ceil(taps / step); + for (let i = 0; i < Math.min(8192, frames); i++) { + const position = i * step; + let value = 0, weightSum = 0; + for (let j = Math.ceil(position - radius); j <= Math.floor(position + radius); j++) { + const distance = position - j, x = Math.PI * distance * cutoff; + const weight = cutoff * (Math.abs(x) < 1e-12 ? 1 : Math.sin(x) / x) * (0.5 + 0.5 * Math.cos(Math.PI * distance / radius)); + weightSum += weight; + if (j >= 0 && j < taps) value += input.getFloat32(j * 4, true) * weight; + } + // A longer resampled response must fit the device's 8192-tap window. + const fade = frames > 8192 && i >= 8128 ? 0.5 * (1 + Math.cos(Math.PI * (i - 8128) / 63)) : 1; + out.setFloat32(i * 4, value / weightSum * step * fade, true); + } + validateFirBinary(result); + return encodeFirWav(result, 48000); +} + +/** Map API-relative image links through the same configured library base. */ +export function firImageUrl(image: string | null | undefined, base: string): string | null { + if (!image) return null; + const library = base.replace(/\/$/, ''); + if (image.startsWith('/api/')) return library + image.slice(4); + if (/^https?:\/\//i.test(image)) return image; + return null; +} diff --git a/client/src/lib/firLocale.ts b/client/src/lib/firLocale.ts new file mode 100644 index 0000000..aab1568 --- /dev/null +++ b/client/src/lib/firLocale.ts @@ -0,0 +1,19 @@ +import { resolveLocalePack } from '@/locales/resolveLocale'; +import { FirError } from './fir'; +export type FirNotice = { code: string; params?: Record; detail?: FirNotice }; +export function firTranslate(language: number | undefined, code: string, params: Record = {}): string { + const pack = resolveLocalePack(language).fir; + const template = (pack as unknown as Record)[code] || pack.network; + return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? '—')); +} +export function firErrorNotice(error: unknown): FirNotice { + if (error instanceof FirError) return { code: error.code, params: error.params, ...((error as Error & {cause?: unknown}).cause ? { detail: firErrorNotice((error as Error & {cause?: unknown}).cause) } : {}) }; + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('timed out'))) return { code: 'timeout' }; + return { code: 'network' }; +} + +export function firNoticeText(language: number | undefined, notice: FirNotice): string { + const params = { ...notice.params }; + if (params.side === 'left' || params.side === 'right') params.side = firTranslate(language, params.side); + return firTranslate(language, notice.code, params) + (notice.detail ? ' ' + firNoticeText(language, notice.detail) : ''); +} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 20aeb3b..ce31dfb 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -1,3 +1,4 @@ +import { FirError, validateFirBinary, encodeDeviceFirWav, decodeFirWav } from "./fir"; // ============================================================ // LUXSIN X9 API — Client Library // Custom Base64 decode + HTTP API wrappers @@ -112,6 +113,11 @@ export interface DeviceState { sleep: number; autoHome: number; bootSound: number; + fir_enable?: number; + color_enable?: number; + color_bass_gain?: number; + color_mid_gain?: number; + color_treble_gain?: number; dsp_enable: number; audio_enable: number; peqEnable: number; @@ -333,6 +339,23 @@ export class LuxsinAPI { private baseUrl: string; private peqRequestQueue: Promise = Promise.resolve(); + private async timedFetch(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, 4000); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + if (timedOut) throw new Error(`Device request timed out: ${url}`); + throw error; + } finally { + clearTimeout(timer); + } + } + /** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */ private enqueuePeqRequest(request: () => Promise): Promise { const result = this.peqRequestQueue.then(request); @@ -372,6 +395,48 @@ export class LuxsinAPI { } } + /** FIR writes are explicit, single-attempt operations, never queued for reconnect replay. */ + async setFirEnable(enabled: boolean): Promise { + const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=setting&fir_enable=${enabled ? 1 : 0}`); + if (!response.ok) throw new FirError('switchError', { status: response.status }); + } + + /** Read the actual device coefficients, not the room library. */ + async getFirChannel(side: 'left' | 'right'): Promise { + if (side !== 'left' && side !== 'right') throw new Error('Invalid FIR channel'); + const host = new URL(this.baseUrl).hostname; + const url = import.meta.env.DEV ? `/fir-device/${host}/${side}` + : `${this.baseUrl}/dev/info.cgi?action=fir&channel=${side}`; + const response = await this.timedFetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error(`FIR ${side}: HTTP ${response.status}`); + let data = await response.arrayBuffer(); + if (data.byteLength >= 4 && new DataView(data).getUint32(0, false) === 0x52494646) data = decodeFirWav(data); + validateFirBinary(data); + return data; + } + + async uploadFirChannel(side: 'left' | 'right', data: ArrayBuffer): Promise { + if (side !== 'left' && side !== 'right') throw new FirError('invalidSide'); + validateFirBinary(data); + const host = new URL(this.baseUrl).hostname; + const url = import.meta.env.DEV + ? `/fir-device/${host}/${side}` + : `${this.baseUrl}/dev/info.cgi?action=fir&channel=${side}`; + const response = await this.timedFetch(url, { + method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: encodeDeviceFirWav(data), + }); + if (!response.ok) { + let reason = ''; + try { + const body = await response.text(); + try { const json = JSON.parse(body); if (typeof json.message === 'string') reason = json.message; } + catch { reason = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); } + } catch {} + throw new FirError('uploadError', { side, status: response.status, reason: reason.slice(0, 240) }); + } + if (import.meta.env.DEV) console.info(`[FIR] ${side}: HTTP ${response.status}, ${data.byteLength} bytes`); + } + async getMsgCount(): Promise { const response = await fetch(`${this.baseUrl}/msgCount`); const text = await response.text(); diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 123960c..b2ddfae 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -733,5 +733,72 @@ "rollback": "Rollback to original", "paramSummary": "{count} filters · preamp {preamp} dB" } + }, + "fir": { + "title": "FIR", + "enable": "Enable FIR", + "source": "Data source", + "library": "Room FIR library", + "manual": "Measured WAV files", + "room": "Room", + "loading": "Loading…", + "select": "Select a room", + "unnamed": "Unnamed room", + "left": "Left ear", + "right": "Right ear", + "bytes": "{count} bytes", + "upload": "Confirm and upload both channels", + "enabled": "FIR enable request succeeded", + "disabled": "FIR disable request succeeded", + "validating": "Validating both channels…", + "uploadingLeft": "Uploading left ear…", + "uploadingRight": "Uploading right ear…", + "success": "Both channels uploaded ({count} taps each).", + "tapCount": "Each channel requires 8191 or 8192 taps.", + "finite": "Coefficient {index} is not a finite float32 value.", + "libraryFormat": "The room library returned invalid FIR data. Update or check the library service.", + "binarySize": "Each file must be a mono float32 WAV with 8191 or 8192 taps.", + "mismatch": "Both channels must have the same tap count.", + "libraryError": "FIR library request failed (HTTP {status}).", + "incomplete": "Upload incomplete. Upload both channels again.", + "switchError": "FIR switch request failed (HTTP {status}).", + "uploadError": "FIR {side} upload failed (HTTP {status}): {reason}", + "invalidSide": "Invalid FIR channel.", + "invalidRooms": "Invalid room list format.", + "selectError": "Select a room first.", + "rateError": "Set this FIR’s sample rate in the library first.", + "countError": "Coefficient count does not match the tap count.", + "filesError": "Select both left and right .wav files.", + "timeout": "The request timed out. Please try again.", + "network": "Request failed. Check your connection and try again.", + "details": "View details", + "detailTitle": "Room measurement information", + "detailClose": "Close", + "detailRoom": "Room", + "detailAcoustics": "Acoustics", + "detailMeasurement": "Selected measurement", + "detailName": "Name", + "detailId": "Room ID", + "detailLocation": "Location", + "detailType": "Room type", + "detailDimensions": "Dimensions (m · L × W × H)", + "detailRt60": "RT60", + "detailReduced": "Reduced reverberation", + "detailListener": "Measurement listener / equipment", + "detailDistance": "Source distance", + "detailAzimuthRange": "Source azimuth range", + "detailElevationRange": "Source elevation range", + "detailConfigurations": "Measurement configurations", + "detailConfiguration": "Configuration", + "detailAzimuth": "Azimuth", + "detailElevation": "Elevation", + "detailRate": "Sample rate", + "detailTaps": "Stored taps per channel", + "detailYes": "Yes", + "detailNo": "No", + "detailDiagram": "Room layout · positions are illustrative", + "detailEnlarge": "Open full-size SVG", + "detailMore": "More room information", + "detailNote": "Source: ASH Rooms.csv. Room names and source descriptions are preserved. Configuration-specific values retain their original annotations and may differ from the selected measurement. Device export pads 8191 stored taps to 8192." } } diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 94b031d..159abad 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -727,5 +727,72 @@ "rollback": "回滾到優化前", "paramSummary": "{count} 段 · preamp {preamp} dB" } + }, + "fir": { + "title": "FIR", + "enable": "啟用 FIR", + "source": "資料來源", + "library": "房間 FIR 檔案庫", + "manual": "自行測量的 WAV 檔案", + "room": "房間", + "loading": "載入中…", + "select": "請選擇房間", + "unnamed": "未命名房間", + "left": "左耳", + "right": "右耳", + "bytes": "{count} 位元組", + "upload": "確認並上傳雙耳資料", + "enabled": "FIR 開啟請求成功", + "disabled": "FIR 關閉請求成功", + "validating": "驗證雙耳資料…", + "uploadingLeft": "上傳左耳…", + "uploadingRight": "上傳右耳…", + "success": "雙耳上傳成功(每側 {count} 抽頭)。", + "tapCount": "每側需要 8191 或 8192 個抽頭", + "finite": "第 {index} 個係數不是有限 float32", + "libraryFormat": "房間庫傳回的 FIR 資料格式異常,請檢查或更新房間庫服務。", + "binarySize": "檔案必須是單聲道 float32 WAV,包含 8191 或 8192 個抽頭", + "mismatch": "左右耳抽頭數必須一致", + "libraryError": "FIR 檔案庫請求失敗:HTTP {status}", + "incomplete": "雙耳上傳未完成,請重新上傳左右兩側。", + "switchError": "FIR 開關失敗:HTTP {status}", + "uploadError": "FIR {side} 上傳失敗:HTTP {status} {reason}", + "invalidSide": "無效 FIR 聲道", + "invalidRooms": "房間清單格式錯誤", + "selectError": "請選擇房間", + "rateError": "此 FIR 未設定取樣率,請先在檔案庫補充", + "countError": "係數數量與抽頭資訊不符", + "filesError": "請選擇左右耳兩個 .wav 檔案", + "timeout": "請求逾時,請重試。", + "network": "請求失敗,請檢查連線後重試。", + "details": "查看詳情", + "detailTitle": "房間量測資訊", + "detailClose": "關閉", + "detailRoom": "房間", + "detailAcoustics": "聲學量測", + "detailMeasurement": "目前量測", + "detailName": "名稱", + "detailId": "房間編號", + "detailLocation": "位置", + "detailType": "房間類型", + "detailDimensions": "尺寸(米 · 長 × 寬 × 高)", + "detailRt60": "RT60", + "detailReduced": "是否降低混響", + "detailListener": "量測裝置/聽者", + "detailDistance": "聲源距離", + "detailAzimuthRange": "聲源方位角範圍", + "detailElevationRange": "聲源仰角範圍", + "detailConfigurations": "量測設定範圍", + "detailConfiguration": "設定編號", + "detailAzimuth": "方位角", + "detailElevation": "仰角", + "detailRate": "取樣率", + "detailTaps": "每聲道儲存抽頭數", + "detailYes": "是", + "detailNo": "否", + "detailDiagram": "房間示意圖 · 位置僅供示意", + "detailEnlarge": "查看 SVG 大圖", + "detailMore": "更多房間資訊", + "detailNote": "來源:ASH Rooms.csv。房間名稱及來源描述保留原文;涉及不同設定的數值保留原始標註,可能與目前量測不同。裝置匯出將儲存的 8191 抽頭末尾補零至 8192。" } } diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 641f487..f5a14f4 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -728,5 +728,72 @@ "rollback": "回滚到优化前", "paramSummary": "{count} 段 · preamp {preamp} dB" } + }, + "fir": { + "title": "FIR", + "enable": "启用 FIR", + "source": "数据来源", + "library": "房间 FIR 文件库", + "manual": "自行测量的 WAV 文件", + "room": "房间", + "loading": "加载中…", + "select": "请选择房间", + "unnamed": "未命名房间", + "left": "左耳", + "right": "右耳", + "bytes": "{count} 字节", + "upload": "确定并上传双耳数据", + "enabled": "FIR 开启请求成功", + "disabled": "FIR 关闭请求成功", + "validating": "校验双耳数据…", + "uploadingLeft": "上传左耳…", + "uploadingRight": "上传右耳…", + "success": "双耳上传成功(每侧 {count} 抽头)。", + "tapCount": "每侧需要 8191 或 8192 个抽头", + "finite": "第 {index} 个系数不是有限 float32", + "libraryFormat": "房间库返回的 FIR 数据格式异常,请检查或更新房间库服务。", + "binarySize": "文件必须是单声道 float32 WAV,包含 8191 或 8192 个抽头", + "mismatch": "左右耳抽头数必须一致", + "libraryError": "FIR 文件库请求失败:HTTP {status}", + "incomplete": "双耳上传未完成,请重新上传左右两侧。", + "switchError": "FIR 开关失败:HTTP {status}", + "uploadError": "FIR {side} 上传失败:HTTP {status} {reason}", + "invalidSide": "无效 FIR 声道", + "invalidRooms": "房间列表格式错误", + "selectError": "请选择房间", + "rateError": "该 FIR 未设置采样率,请先在文件库补充", + "countError": "系数数量与抽头信息不符", + "filesError": "请选择左右耳两个 .wav 文件", + "timeout": "请求超时,请重试。", + "network": "请求失败,请检查连接后重试。", + "details": "查看详情", + "detailTitle": "房间测量信息", + "detailClose": "关闭", + "detailRoom": "房间", + "detailAcoustics": "声学测量", + "detailMeasurement": "当前测量", + "detailName": "名称", + "detailId": "房间编号", + "detailLocation": "位置", + "detailType": "房间类型", + "detailDimensions": "尺寸(米 · 长 × 宽 × 高)", + "detailRt60": "RT60", + "detailReduced": "是否降低混响", + "detailListener": "采集设备/听者", + "detailDistance": "声源距离", + "detailAzimuthRange": "声源方位角范围", + "detailElevationRange": "声源仰角范围", + "detailConfigurations": "测量配置范围", + "detailConfiguration": "配置编号", + "detailAzimuth": "方位角", + "detailElevation": "仰角", + "detailRate": "采样率", + "detailTaps": "每声道存储抽头数", + "detailYes": "是", + "detailNo": "否", + "detailDiagram": "房间示意图 · 位置仅供示意", + "detailEnlarge": "查看 SVG 大图", + "detailMore": "更多房间信息", + "detailNote": "来源:ASH Rooms.csv。房间名称及来源描述保持原文;涉及不同配置的数值保留原始标注,可能与当前测量不同。设备导出将存储的 8191 抽头末尾补零至 8192。" } } diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index e0408a7..e996571 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -1,3 +1,6 @@ +import AiFirCard from '@/components/AiFirCard'; +import {aiFirText} from '@/lib/aiFirLocale'; +import {readCurrentFir, firMetrics, renderFir, type FirPair, type FirProposal, type FirAdjustment} from '@/lib/aiFir'; /* ============================================================ AI PAGE — Luxsin X9 AI Assistant - 首次进入: 中央 Logo + 建议气泡,引导用户如何提问 @@ -243,6 +246,36 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) setAiPanelOpen(true); }, [setLocation, setAiPanelOpen]); + const [firCardVisible,setFirCardVisible] = useState(true); + const [firProposal,setFirProposal] = useState(null); + const [firBusy,setFirBusy] = useState(false); + const firSnapshot = useRef<{id:string; pair:FirPair}|null>(null); + const firDeviceRef = useRef(mac); + firDeviceRef.current=mac; + const firBusyRef = useRef(false); + useEffect(()=>{setFirProposal(null);firSnapshot.current=null;},[mac]); + const handleFirTool = async (block:ToolUseBlock):Promise<{ok:boolean;content?:any;message?:string}> => { + const device=mac; + if(!api || firBusyRef.current) return {ok:false,message:'FIR device busy or disconnected'}; + try { + if(block.name==='get_current_fir') { + firSnapshot.current=null; + const pair=await readCurrentFir(api); + if(firDeviceRef.current!==device) throw new Error('Device changed'); + const id=makeId();firSnapshot.current={id,pair}; + return {ok:true,content:{snapshot_id:id,...firMetrics(pair)}}; + } + const a=block.input as FirAdjustment; + const snapshot=firSnapshot.current; + if(!snapshot || a.snapshot_id!==snapshot.id) throw new Error('Read current FIR in this turn before proposing an adjustment'); + const after=renderFir(snapshot.pair,a); + if(firDeviceRef.current!==device) throw new Error('Device changed'); + setFirCardVisible(true); + setFirProposal({id:makeId(),device,before:snapshot.pair,after,adjustment:a,created:new Date().toISOString()}); + return {ok:true,content:{status:'awaiting_user_confirmation',before:firMetrics(snapshot.pair),after:firMetrics(after),message:'FIR arrays staged only. Device unchanged. User must confirm upload in the AI FIR card; backup precedes upload, then A/B is available.'}}; + }catch(error){return {ok:false,message:error instanceof Error?error.message:'FIR failed'};} + }; + const [chatId, setChatId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -465,6 +498,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) return; } + if(firBusyRef.current) return; + setFirCardVisible(false); const userMsg: ChatUIMessage = { kind: "chat", id: makeId(), @@ -482,6 +517,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); + firSnapshot.current=null; setSending(true); const assistantId = assistantMsg.id; @@ -548,7 +584,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) pending: false, tools: [...(m.tools ?? []), { id: block.id, name: block.name }], })); - const result = await executeFrontendTool(block, api, { + const result = ['get_current_fir','propose_fir'].includes(block.name) ? await handleFirTool(block) : await executeFrontendTool(block, api, { currentVolumeStored: deviceState?.volume, currentDacVolumeDirect: deviceState?.dacVolumeDirect, confirmRiskyChange, @@ -951,8 +987,12 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) )} )} + {/* Keep FIR controls in the conversation scroll area, never above the input as a dock. */} + - {/* ── Input bar ── */}
+
+ + {/* ── 交叉反馈 ── */}
diff --git a/firDevProxy.ts b/firDevProxy.ts new file mode 100644 index 0000000..95eabed --- /dev/null +++ b/firDevProxy.ts @@ -0,0 +1,36 @@ +/** Development relay for FIR WAV POSTs and GETs to private IPv4 devices. */ +import http from 'node:http'; +import type { Plugin } from 'vite'; +export function firDevProxy(): Plugin { + return { name: 'fir-device-dev-proxy', apply: 'serve', configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith('/fir-device/')) return next(); + const match = /^\/fir-device\/(\d+\.\d+\.\d+\.\d+)\/(left|right)$/.exec(req.url); + const octets = match?.[1].split('.').map(Number); + const privateIp = octets && octets.every(n => n >= 0 && n <= 255) && + (octets[0] === 10 || (octets[0] === 192 && octets[1] === 168) || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)); + if (!['GET', 'POST'].includes(req.method || '') || !match || !privateIp) { res.statusCode = 400; res.end('Invalid FIR device request'); return; } + const length = Number(req.headers['content-length']); + if (req.method === 'POST' && ![32822, 32826].includes(length)) { res.statusCode = 422; res.end('Invalid FIR payload size'); return; } + let upstreamResponse: http.IncomingMessage | undefined; + const upstream = http.request({ hostname: match[1], port: 80, method: req.method, agent: false, + path: `/dev/info.cgi?action=fir&channel=${match[2]}`, + headers: req.method === 'POST' ? { 'Content-Type': 'audio/wav', 'Content-Length': length } : {}, + }, response => { + upstreamResponse = response; + res.statusCode = response.statusCode || 502; + res.setHeader('Content-Type', response.headers['content-type'] || 'text/plain'); + response.pipe(res); + response.on('error', () => res.destroy()); + }); + upstream.setTimeout(3500, () => upstream.destroy(new Error('Device upload timed out'))); + upstream.on('error', () => { + // Firmware can append bytes after Content-Length. Node reports a parse error + // for that tail even when the entire declared WAV has already arrived. + if (upstreamResponse?.complete) return; + if (!res.headersSent) { res.statusCode = 502; res.end('Device FIR upload failed'); } else res.destroy(); }); + req.on('aborted', () => upstream.destroy()); + req.pipe(upstream); + }); + }}; +} diff --git a/scripts/ai-fir.test.mjs b/scripts/ai-fir.test.mjs new file mode 100644 index 0000000..039086f --- /dev/null +++ b/scripts/ai-fir.test.mjs @@ -0,0 +1,239 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { build } from "esbuild"; +const compiled = await build({ + entryPoints: ["client/src/lib/aiFir.ts"], + bundle: true, + write: false, + format: "esm", + platform: "node", +}); +const fir = await import( + "data:text/javascript;base64," + + Buffer.from(compiled.outputFiles[0].text).toString("base64") +); +function pair(n = 8192) { + const left = Array(n).fill(0), + right = Array(n).fill(0); + left[10] = 0.4; + right[12] = 0.2; + left[200] = 0.1; + right[202] = 0.05; + left[0] = -0; + return { left, right }; +} +function adjustment(extra = {}) { + return { + snapshot_id: "snapshot", + name: "test", + explanation: "Less late energy", + gain_db: 0, + tail_gain_db: 0, + smoothing: 0, + ...extra, + }; +} +function storage() { + const data = new Map(); + return { + setItem: (k, v) => data.set(k, v), + getItem: k => data.get(k) ?? null, + }; +} +function proposal() { + const before = pair(); + return { + id: "p", + device: "device1", + before, + after: fir.renderFir(before, adjustment({ tail_gain_db: -6 })), + adjustment: adjustment({ tail_gain_db: -6 }), + created: "now", + }; +} +function binary(values) { + const b = new ArrayBuffer(values.length * 4), + v = new DataView(b); + values.forEach((x, i) => v.setFloat32(i * 4, x, true)); + return b; +} +function mockDevice(p) { + const current = { + left: binary(p.before.left), + right: binary(p.before.right), + }; + const posts = []; + return { + posts, + current, + getFirChannel: async side => current[side].slice(0), + uploadFirChannel: async (side, data) => { + posts.push(side); + current[side] = data.slice(0); + }, + }; +} +test("decode validates raw float32 lengths/nonfinite and mismatched ear counts", () => { + assert.throws(() => fir.decodeFir(new ArrayBuffer(4))); + const b = binary(pair().left); + new DataView(b).setFloat32(0, NaN, true); + assert.throws(() => fir.decodeFir(b)); + assert.throws(() => + fir.validatePair({ left: pair().left, right: pair(8191).right }) + ); +}); +test("identity, common attenuation and common envelope keep taps and stereo timing", () => { + for (const n of [8191, 8192]) { + const p = pair(n), + a = fir.renderFir(p, adjustment()); + assert.equal(a.left.length, n); + assert.equal(a.right.length, n); + assert.equal(a.left[10], Math.fround(p.left[10])); + assert.equal(a.right[12], Math.fround(p.right[12])); + const q = fir.renderFir(p, adjustment({ gain_db: -6, tail_gain_db: -12 })); + assert.ok(q.left[200] < q.left[10] / 4); + assert.equal(q.left.indexOf(Math.max(...q.left)), 10); + assert.equal(q.right.indexOf(Math.max(...q.right)), 12); + assert.ok(q.left.every((x, i) => Math.abs(x) <= Math.abs(p.left[i]))); + } +}); +test("bounded transforms reject gain boosts, NaN and out-of-range smoothing", () => { + for (const a of [ + { gain_db: 1 }, + { gain_db: NaN }, + { tail_gain_db: -25 }, + { smoothing: 2 }, + ]) + assert.throws(() => fir.renderFir(pair(), adjustment(a))); +}); +test("smoothing uses documented causal 3 tap kernel", () => { + const p = pair(); + const q = fir.renderFir(p, adjustment({ smoothing: 1 })); + assert.equal(q.left[10], Math.fround(0.1)); + assert.equal(q.left[11], Math.fround(0.2)); + assert.equal(q.left[12], Math.fround(0.1)); +}); +test("cache preserves exact float32 bytes including signed zero, isolated by device", () => { + const p = proposal(), + s = storage(); + fir.saveFirBackup(s, { version: 1, proposal: p, state: "pending" }); + const loaded = fir.loadFirBackup(s, p.device); + assert.ok(Object.is(loaded.proposal.before.left[0], -0)); + assert.deepEqual(binary(loaded.proposal.before.left), binary(p.before.left)); + assert.equal(fir.loadFirBackup(s, "different"), null); +}); +test("backup failure or changed device blocks ALL POSTs", async () => { + const p = proposal(), + d = mockDevice(p); + await assert.rejects( + fir.applyFirVersion( + d, + { + setItem() { + throw Error("quota"); + }, + getItem() { + return null; + }, + }, + { version: 1, proposal: p, state: "pending" }, + "B", + true + ) + ); + assert.deepEqual(d.posts, []); + new DataView(d.current.left).setFloat32(100, 1, true); + await assert.rejects( + fir.applyFirVersion( + d, + storage(), + { version: 1, proposal: p, state: "pending" }, + "B", + true + ), + /FIR_CHANGED/ + ); + assert.deepEqual(d.posts, []); +}); +test("backup precedes left/right upload; verified A/B restores byte-exact original without switch writes", async () => { + const p = proposal(), + s = storage(), + d = mockDevice(p); + const send = d.uploadFirChannel; + d.uploadFirChannel = async (side, data) => { + assert.ok(fir.loadFirBackup(s, p.device)); + await send(side, data); + }; + const done = await fir.applyFirVersion( + d, + s, + { version: 1, proposal: p, state: "pending" }, + "B", + true + ); + assert.equal(done.state, "B"); + assert.deepEqual(d.posts, ["left", "right"]); + const restored = await fir.applyFirVersion(d, s, done, "A"); + assert.equal(restored.state, "A"); + assert.deepEqual(d.current.left, binary(p.before.left)); + assert.deepEqual(d.current.right, binary(p.before.right)); +}); +test("right-ear failure retains original backup and marks incomplete, no automatic retries", async () => { + const p = proposal(), + s = storage(), + d = mockDevice(p), + send = d.uploadFirChannel; + d.uploadFirChannel = async (side, data) => { + if (side === "right") throw Error("502"); + await send(side, data); + }; + await assert.rejects( + fir.applyFirVersion( + d, + s, + { version: 1, proposal: p, state: "pending" }, + "B", + true + ) + ); + const cached = fir.loadFirBackup(s, p.device); + assert.equal(cached.state, "incomplete"); + assert.deepEqual(binary(cached.proposal.before.left), binary(p.before.left)); + assert.deepEqual(d.posts, ["left"]); +}); +test("readback mismatch does not report success", async () => { + const p = proposal(), + s = storage(), + d = mockDevice(p); + d.uploadFirChannel = async () => {}; + await assert.rejects( + fir.applyFirVersion( + d, + s, + { version: 1, proposal: p, state: "pending" }, + "B", + true + ), + /FIR_VERIFY/ + ); + assert.equal(fir.loadFirBackup(s, p.device).state, "incomplete"); +}); +test("zero FIR metrics are finite and do not invent sample rate", () => { + const p = { left: Array(8192).fill(0), right: Array(8192).fill(0) }; + const m = fir.firMetrics(p); + assert.equal(m.sample_rate, null); + assert.equal(m.left.late_energy_ratio, 0); +}); + +test('A/B WAV exports preserve coefficients and signed zero', () => { + const before = pair(); const after = fir.renderFir(before, adjustment({tail_gain_db: -6})); + for (const value of [before, after]) { + const files = fir.firPairWavs(value); + for (const side of ['left', 'right']) { + const view = new DataView(files[side]); + assert.equal(view.getUint16(20, true), 3); + assert.equal(view.getUint32(24, true), 48000); + assert.deepEqual(fir.decodeFir(files[side].slice(58)), value[side].map(Math.fround)); + } + } +}); diff --git a/scripts/fir-proxy.test.mjs b/scripts/fir-proxy.test.mjs new file mode 100644 index 0000000..c3ec179 --- /dev/null +++ b/scripts/fir-proxy.test.mjs @@ -0,0 +1,47 @@ +import {test,afterEach} from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {Readable,Writable} from 'node:stream'; +import {build} from 'esbuild'; +import {fileURLToPath} from 'node:url'; +const result=await build({entryPoints:[fileURLToPath(new URL('../firDevProxy.ts',import.meta.url))],bundle:true,write:false,format:'esm',platform:'node'}); +const {firDevProxy}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].text).toString('base64')); +const original=http.request;afterEach(()=>http.request=original); +function middleware(){let handler;firDevProxy().configureServer({middlewares:{use:fn=>handler=fn}});return handler;} +function response(){const chunks=[];const res=new Writable({write(chunk,_enc,done){chunks.push(chunk);done();}});res.setHeader=()=>{};return {res,chunks};} +test('development relay preserves both binary payloads and exact device channel paths',async()=>{ + for(const side of ['left','right']) { + let options;const chunks=[];http.request=(opts,callback)=>{ + options=opts;const upstream=new Writable({write(chunk,_enc,done){chunks.push(chunk);done();}}); + upstream.setTimeout=()=>{};upstream.on('finish',()=>{const reply=Readable.from(['OK']);reply.statusCode=200;reply.headers={};callback(reply);});return upstream; + }; + const data=Buffer.alloc(32826,side==='left'?0x12:0x34);const req=Readable.from([data]);req.url='/fir-device/192.168.9.133/'+side;req.method='POST';req.headers={'content-length':'32826'}; + const {res}=response();const finished=new Promise(resolve=>res.on('finish',resolve));middleware()(req,res,()=>assert.fail('unexpected fallthrough'));await finished; + assert.equal(options.agent,false);assert.equal(options.hostname,'192.168.9.133');assert.equal(options.path,'/dev/info.cgi?action=fir&channel='+side);assert.equal(options.headers['Content-Type'],'audio/wav');assert.deepEqual(Buffer.concat(chunks),data);assert.equal(res.statusCode,200); + } +}); +test('relay refuses public hosts, bad channels, methods and payload sizes',()=>{ + http.request=()=>assert.fail('invalid request forwarded'); + for(const [url,method,length,status] of [['/fir-device/8.8.8.8/left','POST',32826,400],['/fir-device/192.168.9.133/both','POST',32826,400],['/fir-device/192.168.9.133/left','DELETE',32826,400],['/fir-device/192.168.9.133/right','POST',8,422]]) { + const {res}=response();middleware()({url,method,headers:{'content-length':String(length)}},res,()=>{});assert.equal(res.statusCode,status); + } +}); + +test('GET relay reads original device binary with exact ear path and no upload headers',async()=>{ + let options; http.request=(opts,callback)=>{options=opts; const upstream=new Writable({write(_c,_e,done){done();}});upstream.setTimeout=()=>{};upstream.on('finish',()=>{const reply=Readable.from([Buffer.alloc(32826,1)]);reply.statusCode=200;reply.headers={'content-type':'audio/wav'};callback(reply);});return upstream;}; + for(const side of ['left','right']){const req=Readable.from([]);req.url='/fir-device/192.168.9.133/'+side;req.method='GET';req.headers={};const {res,chunks}=response();const finished=new Promise(resolve=>res.on('finish',resolve));middleware()(req,res,()=>assert.fail('fallthrough'));await finished;assert.equal(options.agent,false);assert.equal(options.method,'GET');assert.equal(options.path,'/dev/info.cgi?action=fir&channel='+side);assert.deepEqual(options.headers,{});assert.equal(Buffer.concat(chunks).length,32826);} +}); + +test('complete firmware response survives an invalid trailing HTTP tail', async () => { + http.request=(_opts,callback)=>{ + const upstream=new Writable({write(_c,_e,done){done();}});upstream.setTimeout=()=>{}; + upstream.on('finish',()=>{ + const reply=Readable.from([Buffer.alloc(32826,7)]);reply.statusCode=200;reply.headers={'content-type':'audio/wav'};reply.complete=true; + callback(reply);upstream.emit('error',new Error('Parse Error: Expected HTTP/')); + });return upstream; + }; + const req=Readable.from([]);req.url='/fir-device/192.168.9.133/left';req.method='GET';req.headers={}; + const {res,chunks}=response();const finished=new Promise(resolve=>res.on('finish',resolve)); + middleware()(req,res,()=>assert.fail('fallthrough'));await finished; + assert.equal(res.statusCode,200);assert.equal(Buffer.concat(chunks).length,32826); +}); diff --git a/scripts/fir-regression.test.mjs b/scripts/fir-regression.test.mjs new file mode 100644 index 0000000..b440336 --- /dev/null +++ b/scripts/fir-regression.test.mjs @@ -0,0 +1,200 @@ +// node --test scripts/fir-regression.test.mjs — no hardware requests. +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { fileURLToPath } from 'node:url'; +async function load(file, dev = false) { + const result = await build({entryPoints:[fileURLToPath(new URL('../client/src/lib/'+file,import.meta.url))],bundle:true,write:false,format:'esm',platform:'node',define:{'import.meta.env.DEV':String(dev)},alias:{'@':fileURLToPath(new URL('../client/src',import.meta.url))}}); + return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].text).toString('base64')); +} +// Bundle together like the app, sharing the sample-rate metadata attached to buffers. +const shared = await build({stdin:{contents:'export * from "./fir"; export { LuxsinAPI } from "./luxsinApi";',resolveDir:fileURLToPath(new URL('../client/src/lib',import.meta.url)),loader:'ts'},bundle:true,write:false,format:'esm',platform:'node',define:{'import.meta.env.DEV':'false'},alias:{'@':fileURLToPath(new URL('../client/src',import.meta.url))}}); +const {encodeFir,validateFirBinary,applyFir,readFirJson,readFirBinary,firRoomName,encodeFirWav,decodeFirWav,encodeDeviceFirWav,LuxsinAPI}=await import('data:text/javascript;base64,'+Buffer.from(shared.outputFiles[0].text).toString('base64')); +const originalFetch=globalThis.fetch; +afterEach(()=>{globalThis.fetch=originalFetch;}); +const coefficients=()=>[0.25,-0.5,...Array(8189).fill(0)]; +test('float32 LE conversion keeps 8191 taps and exact byte order',()=>{ + const b=encodeFir(coefficients());assert.equal(b.byteLength,32764); + assert.deepEqual([...new Uint8Array(b).slice(0,8)],[0,0,128,62,0,0,0,191]); + assert.equal(validateFirBinary(b),8191); + assert.equal(encodeFir(Array(8192).fill(0)).byteLength,32768); +}); +test('reject bad lengths, NaN, infinity, float32 overflow and non-numbers',()=>{ + for(const v of [NaN,Infinity,1e100,'1']) {const a=coefficients();a[0]=v;assert.throws(()=>encodeFir(a));} + assert.throws(()=>encodeFir([1]));assert.throws(()=>validateFirBinary(new ArrayBuffer(8))); + const bad=encodeFir(coefficients());new DataView(bad).setFloat32(0,NaN,true);assert.throws(()=>validateFirBinary(bad)); +}); +test('actual API sends only raw left/right binary, never FIR settings',async()=>{ + const requests=[];globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response('OK');}; + const l=encodeFir(coefficients()),r=encodeFir(Array(8191).fill(-0.125)); + await applyFir(new LuxsinAPI('192.168.9.127'),l,r); + assert.deepEqual(requests.map(r=>r.url),['http://192.168.9.127/dev/info.cgi?action=fir&channel=left','http://192.168.9.127/dev/info.cgi?action=fir&channel=right']); + for (const [index,b] of [[0,l],[1,r]]) {assert.equal(requests[index].init.method,'POST');assert.equal(requests[index].init.headers['Content-Type'],'audio/wav');assert.deepEqual(new Uint8Array(decodeFirWav(requests[index].init.body)),new Uint8Array(decodeFirWav(encodeDeviceFirWav(b))));} +}); +test('manual 8192-tap bytes preserved, enable/disable setting contract',async()=>{ + const requests=[];globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response('OK');}; + const api=new LuxsinAPI('10.0.0.8');const b=encodeFir(Array(8192).fill(0.2)); + await applyFir(api,b,b);await api.setFirEnable(true); + assert.deepEqual(decodeFirWav(requests[0].init.body),b);assert.deepEqual(decodeFirWav(requests[1].init.body),b); + assert.equal(requests[2].url,'http://10.0.0.8/dev/info.cgi?action=setting&fir_enable=1'); +}); +test('both channels validated before device side effects',async()=>{ + let calls=0;const device={setFirEnable:async()=>{calls++;},uploadFirChannel:async()=>{calls++;}}; + await assert.rejects(applyFir(device,encodeFir(coefficients()),new ArrayBuffer(8))); + await assert.rejects(applyFir(device,encodeFir(coefficients()),encodeFir(Array(8192).fill(0))));assert.equal(calls,0); +}); +test('missing firmware endpoint stops at left, no right upload or auto retry',async()=>{ + const urls=[];globalThis.fetch=async url=>{urls.push(url);return new Response('',{status:404});}; + await assert.rejects(applyFir(new LuxsinAPI('192.168.9.127'),encodeFir(coefficients()),encodeFir(coefficients())),/incomplete/); + assert.equal(urls.length,1); +}); +test('right failure reported, subsequent explicit retry sends both sides again',async()=>{ + const urls=[];globalThis.fetch=async url=>{urls.push(url);return new Response('',{status:urls.length===2?500:200});}; + const api=new LuxsinAPI('192.168.9.127'),b=encodeFir(coefficients()); + await assert.rejects(applyFir(api,b,b),/incomplete/);await applyFir(api,b,b); + assert.equal(urls.length,4);assert.equal(urls[0],urls[2]);assert.equal(urls[1],urls[3]); +}); +test('network rejection surfaces without retry',async()=>{ + let calls=0;globalThis.fetch=async()=>{calls++;throw new TypeError('Failed to fetch');}; + await assert.rejects(new LuxsinAPI('192.168.9.127').uploadFirChannel('left',encodeFir(coefficients())),/Failed to fetch/);assert.equal(calls,1); +}); +test('library API errors do not masquerade as rooms',async()=>{ + globalThis.fetch=async()=>new Response('',{status:503});await assert.rejects(readFirJson('/fir-library/rooms/defaults'),/libraryError/); + globalThis.fetch=async()=>Response.json([{room_id:'R35'}]);assert.deepEqual(await readFirJson('/fir-library/rooms/defaults'),[{room_id:'R35'}]); +}); +test('device request timeout aborts and never retries FIR binary',async()=>{ + const realSetTimeout=globalThis.setTimeout, realClearTimeout=globalThis.clearTimeout; + let calls=0; + globalThis.setTimeout=(fn)=>{queueMicrotask(fn);return 123;};globalThis.clearTimeout=()=>{}; + globalThis.fetch=async(_url,init)=>{calls++;return new Promise((_resolve,reject)=>{init.signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true});});}; + try {await assert.rejects(new LuxsinAPI('192.168.9.127').uploadFirChannel('left',encodeFir(coefficients())),/timed out/);assert.equal(calls,1);} + finally {globalThis.setTimeout=realSetTimeout;globalThis.clearTimeout=realClearTimeout;} +}); + +test('room display hides IDs but preserves the selection identifier',()=>{ + const room={room_id:'R35',name:'Studio C',label:'R35 · Studio C',metadata:{}}; + assert.equal(firRoomName(room),'Studio C');assert.equal(room.room_id,'R35'); + assert.equal(firRoomName({...room,name:undefined}),'Studio C'); + assert.equal(firRoomName({...room,name:'',metadata:{'Room Name':'Room Calypso'}}),'Room Calypso'); +}); + +test('development browser API uses same-origin relay and correct right channel',async()=>{ + const {LuxsinAPI:DevAPI}=await load('luxsinApi.ts',true);const urls=[]; + globalThis.fetch=async(url)=>{urls.push(url);return new Response('OK');}; + await new DevAPI('192.168.9.133').uploadFirChannel('right',encodeFir(coefficients())); + assert.deepEqual(urls,['/fir-device/192.168.9.133/right']); +}); + +test('successful upload never invokes FIR switch',async()=>{ + const calls=[];const device={setFirEnable:async()=>assert.fail('upload must not change switch'),uploadFirChannel:async side=>calls.push(side)};const b=encodeFir(coefficients()); + await applyFir(device,b,b);assert.deepEqual(calls,['left','right']); +}); +test('partial failure never invokes FIR switch',async()=>{ + const calls=[];const device={setFirEnable:async()=>assert.fail('failure must not change switch'),uploadFirChannel:async side=>{calls.push(side);if(side==='right')throw new Error('failed');}};const b=encodeFir(coefficients()); + await assert.rejects(applyFir(device,b,b),/incomplete/);assert.deepEqual(calls,['left','right']); +}); + +test('device binary library export forwards exactly 32768 bytes without conversion',async()=>{ + const original=encodeFir(Array(8192).fill(0.25)); + globalThis.fetch=async()=>new Response(encodeFirWav(original),{headers:{'Content-Type':'audio/wav'}}); + assert.deepEqual(new Uint8Array(await readFirBinary('/fir-library/rooms/R35/device/left')),new Uint8Array(original)); + globalThis.fetch=async()=>new Response(encodeFirWav(encodeFir(coefficients())).slice(0,-1)); + await assert.rejects(readFirBinary('/bad-length'),/libraryFormat/); + globalThis.fetch=async()=>new Response('',{status:404});await assert.rejects(readFirBinary('/missing'),/libraryError/); +}); + +test('device FIR GET uses exact left/right endpoints without cache or setting writes',async()=>{ + const bytes=encodeFir(Array(8192).fill(.125)),requests=[]; + globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response(bytes);}; + const api=new LuxsinAPI('192.168.9.133'); + for(const side of ['left','right'])assert.deepEqual(new Uint8Array(await api.getFirChannel(side)),new Uint8Array(bytes)); + assert.deepEqual(requests.map(r=>r.url),['http://192.168.9.133/dev/info.cgi?action=fir&channel=left','http://192.168.9.133/dev/info.cgi?action=fir&channel=right']); + assert.ok(requests.every(r=>r.init.cache==='no-store'&&!r.init.body&&!r.init.method)); + globalThis.fetch=async()=>new Response('device error',{status:502});await assert.rejects(api.getFirChannel('right'),/right: HTTP 502/); + globalThis.fetch=async()=>new Response('{}');await assert.rejects(api.getFirChannel('left'),/binarySize/); +}); +test('development FIR GET uses same-origin device relay',async()=>{ + const {LuxsinAPI:DevAPI}=await load('luxsinApi.ts',true),urls=[]; + globalThis.fetch=async url=>{urls.push(url);return new Response(encodeFir(Array(8192).fill(0)));}; + await new DevAPI('192.168.9.133').getFirChannel('right');assert.deepEqual(urls,['/fir-device/192.168.9.133/right']); +}); + +test('WAV round trip preserves samples and sample rate', () => { + const raw = encodeFir(coefficients()); const wav = encodeFirWav(raw, 44100); + assert.equal(wav.byteLength, raw.byteLength + 58); + assert.equal(new DataView(wav).getUint16(20, true), 3); + const decoded = decodeFirWav(wav); assert.deepEqual(decoded, raw); + assert.deepEqual(encodeFirWav(decoded), wav); + assert.throws(() => decodeFirWav(wav.slice(0, -1))); +}); + +test('device GET accepts WAV for AI analysis', async () => { + const raw = encodeFir(Array(8192).fill(0.125)); + globalThis.fetch = async () => new Response(encodeFirWav(raw)); + assert.deepEqual(await new LuxsinAPI('192.168.9.133').getFirChannel('left'), raw); +}); +test('manual WAV sample rates must match before writes', async () => { + const raw = encodeFir(Array(8192).fill(0)); let calls = 0; + await assert.rejects(applyFir({uploadFirChannel: async () => { calls++; }}, decodeFirWav(encodeFirWav(raw, 44100)), decodeFirWav(encodeFirWav(raw, 48000))), /mismatch/); + assert.equal(calls, 0); +}); + +test('legacy 44.1 kHz room becomes device-compatible 48 kHz WAV', async () => { + const raw = encodeFir(Array(8192).fill(0.25)); const requests = []; + globalThis.fetch = async (url, init) => { + if (url.startsWith('/library/')) return new Response(raw, {headers: {'Content-Type':'application/octet-stream', 'X-FIR-Sample-Rate':'44100'}}); + requests.push(init); return new Response('OK'); + }; + const left = await readFirBinary('/library/left'); const right = await readFirBinary('/library/right'); + await applyFir(new LuxsinAPI('192.168.9.133'), left, right); + assert.equal(requests.length, 2); + for (const req of requests) { + assert.equal(req.headers['Content-Type'], 'audio/wav'); + assert.equal(new DataView(req.body).getUint32(24, true), 48000); + assert.equal(req.body.byteLength, 32826); + const samples = new DataView(decodeFirWav(req.body)); + assert.ok(Math.abs(samples.getFloat32(4000,true) - 0.25 * 44100 / 48000) < 1e-5); + } +}); +test('legacy room uses room metadata if cross-origin headers are hidden', async () => { + const raw = encodeFir(Array(8192).fill(0)); + globalThis.fetch = async () => new Response(raw, {headers:{'Content-Type':'application/octet-stream'}}); + const data = await readFirBinary('/library/left', 44100); + assert.equal(new DataView(encodeFirWav(data)).getUint32(24, true), 44100); + await assert.rejects(readFirBinary('/library/left'), /rateError/); +}); +test('room library rejects HTML, JSON and invalid raw coefficients', async () => { + for (const response of [new Response('error'), new Response('{}',{headers:{'Content-Type':'application/json'}}), new Response(new ArrayBuffer(32768),{headers:{'Content-Type':'audio/wav'}})]) { + globalThis.fetch = async () => response; + await assert.rejects(readFirBinary('/library/left', 44100), /libraryFormat/); + } + const bad = encodeFir(Array(8192).fill(0));new DataView(bad).setFloat32(0,NaN,true); + globalThis.fetch = async () => new Response(bad,{headers:{'Content-Type':'application/octet-stream'}}); + await assert.rejects(readFirBinary('/library/left',44100), /libraryFormat/); +}); +test('library WAV accepts 8191 taps and retains its own sample rate', async () => { + const raw = encodeFir(coefficients()); + globalThis.fetch = async () => new Response(encodeFirWav(raw,44100),{headers:{'Content-Type':'audio/wav'}}); + const data = await readFirBinary('/library/left',48000); + assert.deepEqual(data,raw);assert.equal(new DataView(encodeFirWav(data)).getUint32(24,true),44100); +}); + +test('resampling preserves impulse time, interaural delay and convolution gain', () => { + const channel = (index) => { const values=Array(8192).fill(0);values[index]=1;return decodeFirWav(encodeFirWav(encodeFir(values),44100)); }; + const peak = values => values.reduce((best,x,i) => Math.abs(x)>Math.abs(values[best])?i:best,0); + const arrays = [441,882].map(index => { const v=new DataView(decodeFirWav(encodeDeviceFirWav(channel(index))));return Array.from({length:8192},(_,i)=>v.getFloat32(i*4,true)); }); + assert.equal(peak(arrays[0]),480);assert.equal(peak(arrays[1]),960); + for (const values of arrays) assert.ok(Math.abs(values.reduce((sum,x)=>sum+x,0)-1)<0.001); +}); +test('48 kHz 8191 taps are padded; 8192 taps keep exact bytes', () => { + for (const taps of [8191,8192]) { + const raw=encodeFir(Array(taps).fill(-0.125));const wav=encodeDeviceFirWav(raw); + assert.equal(wav.byteLength,32826);assert.equal(new DataView(wav).getUint32(24,true),48000); + const decoded=decodeFirWav(wav);assert.deepEqual(decoded.slice(0,raw.byteLength),raw); + if(taps===8191) assert.equal(new DataView(decoded).getFloat32(8191*4,true),0); + } +}); +test('device error message is included in upload failure', async () => { + globalThis.fetch=async()=>new Response(JSON.stringify({ok:false,message:'WAV must be mono, 48 kHz, 8192 samples'}),{status:400}); + await assert.rejects(new LuxsinAPI('192.168.9.133').uploadFirChannel('left',encodeFir(Array(8192).fill(0))), error=>error.params.reason.includes('48 kHz')); +}); diff --git a/scripts/fir-ui.test.mjs b/scripts/fir-ui.test.mjs new file mode 100644 index 0000000..3303cb2 --- /dev/null +++ b/scripts/fir-ui.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +const root=fileURLToPath(new URL('..',import.meta.url)); +const result=await build({stdin:{contents:`import React from 'react'; import {renderToStaticMarkup} from 'react-dom/server'; import Panel from './client/src/components/FirPanel'; import {firTranslate} from './client/src/lib/firLocale'; import {FirRoomDetailsBody} from './client/src/components/FirRoomDetails'; import {firImageUrl} from './client/src/lib/fir'; export {firTranslate,firImageUrl}; export const renderDetails=(room)=>renderToStaticMarkup(React.createElement(FirRoomDetailsBody,{room,language:0})); export const render=()=>renderToStaticMarkup(React.createElement(Panel));`,resolveDir:root},bundle:true,write:false,jsx:'automatic',format:'cjs',platform:'node',alias:{'@':root+'/client/src'},define:{'import.meta.env.VITE_FIR_API_BASE':'"/fir-library"'},plugins:[{name:'device-fixture',setup(b){b.onLoad({filter:/contexts\/DeviceContext\.tsx$/},()=>({contents:'export const useDevice=()=>globalThis.__firDevice;',loader:'js'}));}}]}); +const module={exports:{}};new Function('require','module','exports',result.outputFiles[0].text)(createRequire(import.meta.url),module,module.exports); +const {render,firTranslate}=module.exports; +test('FIR off shows only heading and switch, no form or descriptions',()=>{ + globalThis.__firDevice={ip:'test',api:{},isConnected:true,isDemoMode:false,deviceState:{language:0,fir_enable:0}}; + const html=render();assert.match(html,/FIR/);assert.doesNotMatch(html,/{ + for(const [language,source,upload] of [[0,'Data source','Confirm and upload both channels'],[2,'数据来源','确定并上传双耳数据'],[1,'資料來源','確認並上傳雙耳資料']]) { + globalThis.__firDevice={ip:'test',api:{},isConnected:true,isDemoMode:false,deviceState:{language,fir_enable:1}}; + const html=render();assert.ok(html.includes(source));assert.ok(html.includes(upload));assert.match(html,/
{ + const packs=['en','zh','zh-HK'].map(lang=>JSON.parse(readFileSync(root+'/client/src/locales/data-'+lang+'.json')).fir); + for(const pack of packs) {assert.deepEqual(Object.keys(pack).sort(),Object.keys(packs[0]).sort());for(const key of Object.keys(pack))assert.deepEqual((pack[key].match(/\{\w+\}/g)||[]).sort(),(packs[0][key].match(/\{\w+\}/g)||[]).sort());} + assert.equal(firTranslate(0,'finite',{index:3}),'Coefficient 3 is not a finite float32 value.'); + assert.equal(firTranslate(2,'libraryError',{status:404}),'FIR 文件库请求失败:HTTP 404'); + assert.equal(firTranslate(1,'mismatch'),'左右耳抽頭數必須一致'); +}); + +test('room SVG uses configured proxy or remote API base and preserves cache digest',()=>{ + const {firImageUrl,renderDetails}=module.exports; + assert.equal(firImageUrl('/api/firs/12/image?v=abc','/fir-library'),'/fir-library/firs/12/image?v=abc'); + assert.equal(firImageUrl('/api/rooms/R01/image?v=abc','https://library.example/api/'),'https://library.example/api/rooms/R01/image?v=abc'); + assert.equal(firImageUrl('javascript:alert(1)','/fir-library'),null); + const room={room_id:'R01',label:'Room One',sample_rate:48000,taps:8192,metadata:{},measurement:{azimuth:30,elevation:0},image_url:'/api/firs/12/image?v=abc'}; + const html=renderDetails(room); + assert.match(html,/]+src="\/fir-library\/firs\/12\/image\?v=abc"/); + assert.match(html,/Open full-size SVG/);assert.doesNotMatch(html,/ p.replace(/^\/ai-api/, ""), + timeout: 0, + proxyTimeout: 0, + }, + "/fir-library": { target: "http://127.0.0.1:8000", changeOrigin: true, rewrite: p => p.replace(/^\/fir-library/, "/api") }, "/luxsin-audio-api": { target: "https://api.luxsin.com.cn", changeOrigin: true, diff --git a/vite.legacy.config.ts b/vite.legacy.config.ts index e1b3573..98a7c1a 100644 --- a/vite.legacy.config.ts +++ b/vite.legacy.config.ts @@ -1,3 +1,4 @@ +import { firDevProxy } from "./firDevProxy"; import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc"; import react from "@vitejs/plugin-react"; import path from "node:path"; @@ -51,7 +52,7 @@ const deployBase = isProd : "/"; export default defineConfig({ - plugins: [legacySkipModernCssPlugin(), legacyIndexRewritePlugin(), react(), jsxLocPlugin()], + plugins: [firDevProxy(),legacySkipModernCssPlugin(), legacyIndexRewritePlugin(), react(), jsxLocPlugin()], define: { "import.meta.env.VITE_X9_LEGACY": JSON.stringify("1"), }, @@ -108,6 +109,15 @@ export default defineConfig({ strictPort: false, host: true, proxy: { + // Same-origin AI endpoint works for localhost and LAN visitors, including SSE. + "/ai-api": { + target: "http://127.0.0.1:8001", + changeOrigin: true, + rewrite: p => p.replace(/^\/ai-api/, ""), + timeout: 0, + proxyTimeout: 0, + }, + "/fir-library": { target: "http://127.0.0.1:8000", changeOrigin: true, rewrite: p => p.replace(/^\/fir-library/, "/api") }, "/luxsin-audio-api": { target: "https://api.luxsin.com.cn", changeOrigin: true,