feat: integrate FIR library and AI FIR controls

This commit is contained in:
fucheng
2026-10-30 17:40:13 +08:00
parent 1a04dddfcc
commit cb2d415165
22 changed files with 1858 additions and 3 deletions
+171
View File
@@ -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<FirBackup | null>(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 ? (
<p role="alert" className="text-sm text-red-300">
{error}
</p>
) : 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 (
<section
aria-label={t.title}
className="my-3 space-y-3 rounded-2xl border border-cyan-300/20 bg-white/5 p-4 text-sm"
>
<div className="flex justify-between gap-2">
<h3 className="font-semibold text-cyan-300">{t.title}</h3>
<span className="text-white/50">
{shown.before.left.length} {t.taps}
</span>
</div>
<p className="font-medium">{shown.adjustment.name}</p>
<p className="whitespace-pre-wrap text-white/70">
{shown.adjustment.explanation}
</p>
<p className="text-xs text-white/60">
{t.gain}: {shown.adjustment.gain_db} dB · {t.tail}:{" "}
{shown.adjustment.tail_gain_db} dB · {t.smooth}:{" "}
{Math.round(shown.adjustment.smoothing * 100)}%
</p>
<p className="text-xs text-white/50">{t.backup}</p>
<p role="status" className="text-xs text-white/70">
{busy
? t.busy
: staged
? t.ready
: backup?.state === "incomplete"
? t.incomplete
: `${t.verified} ${backup?.state}`}
</p>
{error && (
<p role="alert" className="text-red-300">
{error}
</p>
)}
<div className="flex flex-wrap gap-2">
{staged ? (
<>
<button
disabled={busy || disabled}
onClick={() => void apply("B")}
className="rounded-lg bg-cyan-300 px-3 py-2 font-semibold text-black disabled:opacity-40"
>
{t.confirm}
</button>
<button
disabled={busy}
onClick={onDiscard}
className="rounded-lg bg-white/10 px-3 py-2 disabled:opacity-40"
>
{t.cancel}
</button>
</>
) : (
(["A", "B"] as const).map(target => (
<button
key={target}
disabled={busy || disabled}
aria-pressed={backup?.state === target}
onClick={() => void apply(target)}
className={`rounded-lg px-4 py-2 disabled:opacity-40 ${backup?.state === target ? "bg-cyan-300 text-black" : "bg-white/10"}`}
>
{target === "A" ? t.a : t.b}
</button>
))
)}
</div>
{staged && backup?.state === "incomplete" && (
<button
disabled={busy || disabled}
onClick={onDiscard}
className="text-cyan-300"
>
{t.a} / {t.b}
</button>
)}
</section>
);
}
+120
View File
@@ -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 <FirControls key={ip} />;
}
function FirControls() {
const { api, isConnected, isDemoMode, deviceState } = useDevice();
const [enabled, setEnabled] = useState<boolean | null>(deviceState?.fir_enable === undefined ? null : deviceState.fir_enable === 1);
const [rooms, setRooms] = useState<FirRoom[]>([]);
const [selected, setSelected] = useState('');
const [detailsOpen, setDetailsOpen] = useState(false);
const [source, setSource] = useState<'library' | 'manual'>('library');
const [left, setLeft] = useState<File | null>(null);
const [right, setRight] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<FirNotice | null>(null);
const [error, setError] = useState<FirNotice | null>(null);
const t = (code: string, params?: Record<string, string | number>) => 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<FirRoom[]>(`${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 <section className="ios-list-group p-4 space-y-3" aria-label={t('title')}>
<div className="flex items-center justify-between">
<h2 className="text-[16px] font-medium text-white">{t('title')}</h2>
<label className="ios-toggle">
<input aria-label={t('enable')} type="checkbox" checked={enabled === true} disabled={!connected || busy} onChange={e => void toggle(e.target.checked)} />
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
</label>
</div>
{(enabled === true || busy) && <fieldset disabled={busy} className="space-y-3 disabled:opacity-60">
<label className="block text-sm text-white/80">{t('source')}
<span className="relative mt-1 block"><select className={`${control} appearance-none pr-10`} value={source} onChange={e => { setSource(e.target.value as 'library' | 'manual'); setStatus(null); }}>
<option value="library">{t('library')}</option><option value="manual">{t('manual')}</option>
</select><ChevronDown aria-hidden="true" size={16} className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-white/60" /></span>
</label>
{source === 'library' ? <>
<div>
<div className="flex items-center justify-between">
<label htmlFor="fir-room-select" className="text-sm text-white/80">{t('room')}</label>
<button type="button" disabled={!room || loading} onClick={() => setDetailsOpen(true)} className="text-xs text-cyan-300 hover:underline disabled:opacity-40">{t('details')}</button>
</div>
<span className="relative mt-1 block"><select className={`${control} appearance-none pr-10`} id="fir-room-select" value={selected} disabled={loading} onChange={e => { setSelected(e.target.value); setStatus(null); }}>
<option value="">{loading ? t('loading') : t('select')}</option>
{rooms.map(item => <option key={item.room_id} value={item.room_id}>{firRoomName(item) || t('unnamed')}</option>)}
</select><ChevronDown aria-hidden="true" size={16} className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-white/60" /></span>
</div>
</> : <>
{(['left', 'right'] as const).map(side => <label key={side} className="block text-sm text-white/80">{t(side)} .wav
<input className={control} type="file" accept=".wav,audio/wav,audio/x-wav" onChange={e => { (side === 'left' ? setLeft : setRight)(e.target.files?.[0] || null); setStatus(null); }} />
<span className="text-xs text-white/60">{t('bytes', { count: (side === 'left' ? left : right)?.size ?? 0 })}</span>
</label>)}
</>}
<button className="w-full rounded-lg bg-cyan-300 p-2 text-black font-medium disabled:opacity-40" disabled={!connected || loading || (source === 'library' ? !room : !left || !right)} onClick={() => void upload()}>{t('upload')}</button>
</fieldset>}
{room && <FirRoomDetails room={room} language={deviceState?.language} open={detailsOpen && enabled === true && source === 'library'} onOpenChange={setDetailsOpen} />}
{status && (enabled === true || busy) && <p role="status" className="text-sm text-cyan-300">{t(status.code, status.params)}</p>}
{error && (enabled === true || error.code !== 'libraryError') && <p role="alert" className="text-sm text-red-300">{firNoticeText(deviceState?.language, error)}</p>}
</section>;
}
+67
View File
@@ -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<string | null>(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]) => <section key={group.title}>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-cyan-300">{t(group.title)}</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{group.fields.map(([label, text]) => <div key={label} className="min-w-0 border-b border-white/10 pb-2">
<dt className="mb-1 text-xs text-white/45">{t(label)}</dt>
<dd className="break-words text-sm leading-relaxed text-white/90">{text}</dd>
</div>)}
</dl>
</section>;
const hasImage = imageUrl && failedImage !== imageUrl;
return <div className="space-y-5">
{hasImage && <figure className="space-y-2">
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="block overflow-hidden rounded-xl border border-white/15 bg-[#f8fafc]" aria-label={t('detailEnlarge')}>
<img src={imageUrl} alt={firRoomName(room)} className="block h-auto w-full" onError={() => setFailedImage(imageUrl)} />
</a>
<figcaption className="flex items-center justify-between gap-3 text-xs text-white/50">
<span>{t('detailDiagram')}</span>
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="shrink-0 text-cyan-300 hover:underline">{t('detailEnlarge')} ↗</a>
</figcaption>
</figure>}
{!hasImage && groups.map(renderGroup)}
</div>;
}
export default function FirRoomDetails({ room, language, open, onOpenChange }: Props & { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = (key: string) => firTranslate(language, key);
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="max-h-[85dvh] overflow-y-auto border-white/15 bg-[#19191c] text-white sm:max-w-4xl">
<div className="flex items-start justify-between gap-4">
<div><DialogTitle className="text-lg">{firRoomName(room) || t('unnamed')}</DialogTitle>
<DialogDescription className="mt-1 text-xs text-white/50">{t('detailTitle')}</DialogDescription></div>
<DialogClose className="shrink-0 rounded-md px-2 py-1 text-sm text-cyan-300 hover:bg-white/10">{t('detailClose')}</DialogClose>
</div>
<FirRoomDetailsBody room={room} language={language} />
</DialogContent>
</Dialog>;
}
+239
View File
@@ -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<LuxsinAPI, "getFirChannel">
): Promise<FirPair> {
// 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<string, string>): 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<LuxsinAPI, "getFirChannel" | "uploadFirChannel">,
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)) };
}
+72
View File
@@ -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;
}
+156
View File
@@ -0,0 +1,156 @@
export class FirError extends Error {
constructor(public code: string, public params: Record<string, string | number> = {}) { 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<string, string>;
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<T>(url: string): Promise<T> {
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<void>;
}
/** 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<ArrayBuffer> {
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<ArrayBuffer, number>();
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;
}
+19
View File
@@ -0,0 +1,19 @@
import { resolveLocalePack } from '@/locales/resolveLocale';
import { FirError } from './fir';
export type FirNotice = { code: string; params?: Record<string, string | number>; detail?: FirNotice };
export function firTranslate(language: number | undefined, code: string, params: Record<string, string | number> = {}): string {
const pack = resolveLocalePack(language).fir;
const template = (pack as unknown as Record<string, string>)[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) : '');
}
+65
View File
@@ -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<void> = Promise.resolve();
private async timedFetch(url: string, init?: RequestInit): Promise<Response> {
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<T>(request: () => Promise<T>): Promise<T> {
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<void> {
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<ArrayBuffer> {
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<void> {
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<number> {
const response = await fetch(`${this.baseUrl}/msgCount`);
const text = await response.text();
+67
View File
@@ -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."
}
}
+67
View File
@@ -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。"
}
}
+67
View File
@@ -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。"
}
}
+43 -2
View File
@@ -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<FirProposal|null>(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<string | null>(null);
const [messages, setMessages] = useState<UIMessage[]>([]);
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 = {})
)}
</div>
)}
{/* Keep FIR controls in the conversation scroll area, never above the input as a dock. */}
<div hidden={!firCardVisible || sending}>
<AiFirCard key={mac} api={api} device={mac} language={language} proposal={firProposal} disabled={sending}
onDiscard={()=>setFirProposal(null)} onBusy={busy=>{firBusyRef.current=busy;setFirBusy(busy);}} />
</div>
</div>
{/* ── Input bar ── */}
<div
className="flex-shrink-0 px-3 pt-3 pb-3"
@@ -963,6 +1003,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<button type="button" disabled={sending||firBusy} className="mb-2 text-xs text-cyan-300 disabled:opacity-40" onClick={()=>setInput(aiFirText(language).prompt)}>{aiFirText(language).analyze}</button>
<form
onSubmit={onSubmit}
className="flex items-end gap-2 rounded-[22px] px-3 py-2 w-full"
+3
View File
@@ -1,3 +1,4 @@
import FirPanel from "@/components/FirPanel";
/* ============================================================
EFFECTS PAGE — Sound Effect Settings
Design: Reference luxsin_x8_音效.jpg
@@ -522,6 +523,8 @@ export default function EffectsPage() {
/>
</div>
<FirPanel />
{/* ── 交叉反馈 ── */}
<div className="ios-list-group p-4">
<div className="flex items-center justify-between mb-3">