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>;
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) : '');
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user