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>;
}