feat: integrate FIR library and AI FIR controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user