201 lines
14 KiB
JavaScript
201 lines
14 KiB
JavaScript
// node --test scripts/fir-regression.test.mjs — no hardware requests.
|
|
import { test, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { build } from 'esbuild';
|
|
import { fileURLToPath } from 'node:url';
|
|
async function load(file, dev = false) {
|
|
const result = await build({entryPoints:[fileURLToPath(new URL('../client/src/lib/'+file,import.meta.url))],bundle:true,write:false,format:'esm',platform:'node',define:{'import.meta.env.DEV':String(dev)},alias:{'@':fileURLToPath(new URL('../client/src',import.meta.url))}});
|
|
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].text).toString('base64'));
|
|
}
|
|
// Bundle together like the app, sharing the sample-rate metadata attached to buffers.
|
|
const shared = await build({stdin:{contents:'export * from "./fir"; export { LuxsinAPI } from "./luxsinApi";',resolveDir:fileURLToPath(new URL('../client/src/lib',import.meta.url)),loader:'ts'},bundle:true,write:false,format:'esm',platform:'node',define:{'import.meta.env.DEV':'false'},alias:{'@':fileURLToPath(new URL('../client/src',import.meta.url))}});
|
|
const {encodeFir,validateFirBinary,applyFir,readFirJson,readFirBinary,firRoomName,encodeFirWav,decodeFirWav,encodeDeviceFirWav,LuxsinAPI}=await import('data:text/javascript;base64,'+Buffer.from(shared.outputFiles[0].text).toString('base64'));
|
|
const originalFetch=globalThis.fetch;
|
|
afterEach(()=>{globalThis.fetch=originalFetch;});
|
|
const coefficients=()=>[0.25,-0.5,...Array(8189).fill(0)];
|
|
test('float32 LE conversion keeps 8191 taps and exact byte order',()=>{
|
|
const b=encodeFir(coefficients());assert.equal(b.byteLength,32764);
|
|
assert.deepEqual([...new Uint8Array(b).slice(0,8)],[0,0,128,62,0,0,0,191]);
|
|
assert.equal(validateFirBinary(b),8191);
|
|
assert.equal(encodeFir(Array(8192).fill(0)).byteLength,32768);
|
|
});
|
|
test('reject bad lengths, NaN, infinity, float32 overflow and non-numbers',()=>{
|
|
for(const v of [NaN,Infinity,1e100,'1']) {const a=coefficients();a[0]=v;assert.throws(()=>encodeFir(a));}
|
|
assert.throws(()=>encodeFir([1]));assert.throws(()=>validateFirBinary(new ArrayBuffer(8)));
|
|
const bad=encodeFir(coefficients());new DataView(bad).setFloat32(0,NaN,true);assert.throws(()=>validateFirBinary(bad));
|
|
});
|
|
test('actual API sends only raw left/right binary, never FIR settings',async()=>{
|
|
const requests=[];globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response('OK');};
|
|
const l=encodeFir(coefficients()),r=encodeFir(Array(8191).fill(-0.125));
|
|
await applyFir(new LuxsinAPI('192.168.9.127'),l,r);
|
|
assert.deepEqual(requests.map(r=>r.url),['http://192.168.9.127/dev/info.cgi?action=fir&channel=left','http://192.168.9.127/dev/info.cgi?action=fir&channel=right']);
|
|
for (const [index,b] of [[0,l],[1,r]]) {assert.equal(requests[index].init.method,'POST');assert.equal(new Headers(requests[index].init.headers).has('Content-Type'),false);assert.deepEqual(new Uint8Array(decodeFirWav(requests[index].init.body)),new Uint8Array(decodeFirWav(encodeDeviceFirWav(b))));}
|
|
});
|
|
test('manual 8192-tap bytes preserved, enable/disable setting contract',async()=>{
|
|
const requests=[];globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response('OK');};
|
|
const api=new LuxsinAPI('10.0.0.8');const b=encodeFir(Array(8192).fill(0.2));
|
|
await applyFir(api,b,b);await api.setFirEnable(true);
|
|
assert.deepEqual(decodeFirWav(requests[0].init.body),b);assert.deepEqual(decodeFirWav(requests[1].init.body),b);
|
|
assert.equal(requests[2].url,'http://10.0.0.8/dev/info.cgi?action=setting&fir_enable=1');
|
|
});
|
|
test('both channels validated before device side effects',async()=>{
|
|
let calls=0;const device={setFirEnable:async()=>{calls++;},uploadFirChannel:async()=>{calls++;}};
|
|
await assert.rejects(applyFir(device,encodeFir(coefficients()),new ArrayBuffer(8)));
|
|
await assert.rejects(applyFir(device,encodeFir(coefficients()),encodeFir(Array(8192).fill(0))));assert.equal(calls,0);
|
|
});
|
|
test('missing firmware endpoint stops at left, no right upload or auto retry',async()=>{
|
|
const urls=[];globalThis.fetch=async url=>{urls.push(url);return new Response('',{status:404});};
|
|
await assert.rejects(applyFir(new LuxsinAPI('192.168.9.127'),encodeFir(coefficients()),encodeFir(coefficients())),/incomplete/);
|
|
assert.equal(urls.length,1);
|
|
});
|
|
test('right failure reported, subsequent explicit retry sends both sides again',async()=>{
|
|
const urls=[];globalThis.fetch=async url=>{urls.push(url);return new Response('',{status:urls.length===2?500:200});};
|
|
const api=new LuxsinAPI('192.168.9.127'),b=encodeFir(coefficients());
|
|
await assert.rejects(applyFir(api,b,b),/incomplete/);await applyFir(api,b,b);
|
|
assert.equal(urls.length,4);assert.equal(urls[0],urls[2]);assert.equal(urls[1],urls[3]);
|
|
});
|
|
test('network rejection surfaces without retry',async()=>{
|
|
let calls=0;globalThis.fetch=async()=>{calls++;throw new TypeError('Failed to fetch');};
|
|
await assert.rejects(new LuxsinAPI('192.168.9.127').uploadFirChannel('left',encodeFir(coefficients())),/Failed to fetch/);assert.equal(calls,1);
|
|
});
|
|
test('library API errors do not masquerade as rooms',async()=>{
|
|
globalThis.fetch=async()=>new Response('',{status:503});await assert.rejects(readFirJson('/fir-library/rooms/defaults'),/libraryError/);
|
|
globalThis.fetch=async()=>Response.json([{room_id:'R35'}]);assert.deepEqual(await readFirJson('/fir-library/rooms/defaults'),[{room_id:'R35'}]);
|
|
});
|
|
test('device request timeout aborts and never retries FIR binary',async()=>{
|
|
const realSetTimeout=globalThis.setTimeout, realClearTimeout=globalThis.clearTimeout;
|
|
let calls=0;
|
|
globalThis.setTimeout=(fn)=>{queueMicrotask(fn);return 123;};globalThis.clearTimeout=()=>{};
|
|
globalThis.fetch=async(_url,init)=>{calls++;return new Promise((_resolve,reject)=>{init.signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true});});};
|
|
try {await assert.rejects(new LuxsinAPI('192.168.9.127').uploadFirChannel('left',encodeFir(coefficients())),/timed out/);assert.equal(calls,1);}
|
|
finally {globalThis.setTimeout=realSetTimeout;globalThis.clearTimeout=realClearTimeout;}
|
|
});
|
|
|
|
test('room display hides IDs but preserves the selection identifier',()=>{
|
|
const room={room_id:'R35',name:'Studio C',label:'R35 · Studio C',metadata:{}};
|
|
assert.equal(firRoomName(room),'Studio C');assert.equal(room.room_id,'R35');
|
|
assert.equal(firRoomName({...room,name:undefined}),'Studio C');
|
|
assert.equal(firRoomName({...room,name:'',metadata:{'Room Name':'Room Calypso'}}),'Room Calypso');
|
|
});
|
|
|
|
test('development browser API uses same-origin relay and correct right channel',async()=>{
|
|
const {LuxsinAPI:DevAPI}=await load('luxsinApi.ts',true);const urls=[];
|
|
globalThis.fetch=async(url)=>{urls.push(url);return new Response('OK');};
|
|
await new DevAPI('192.168.9.133').uploadFirChannel('right',encodeFir(coefficients()));
|
|
assert.deepEqual(urls,['/fir-device/192.168.9.133/right']);
|
|
});
|
|
|
|
test('successful upload never invokes FIR switch',async()=>{
|
|
const calls=[];const device={setFirEnable:async()=>assert.fail('upload must not change switch'),uploadFirChannel:async side=>calls.push(side)};const b=encodeFir(coefficients());
|
|
await applyFir(device,b,b);assert.deepEqual(calls,['left','right']);
|
|
});
|
|
test('partial failure never invokes FIR switch',async()=>{
|
|
const calls=[];const device={setFirEnable:async()=>assert.fail('failure must not change switch'),uploadFirChannel:async side=>{calls.push(side);if(side==='right')throw new Error('failed');}};const b=encodeFir(coefficients());
|
|
await assert.rejects(applyFir(device,b,b),/incomplete/);assert.deepEqual(calls,['left','right']);
|
|
});
|
|
|
|
test('device binary library export forwards exactly 32768 bytes without conversion',async()=>{
|
|
const original=encodeFir(Array(8192).fill(0.25));
|
|
globalThis.fetch=async()=>new Response(encodeFirWav(original),{headers:{'Content-Type':'audio/wav'}});
|
|
assert.deepEqual(new Uint8Array(await readFirBinary('/fir-library/rooms/R35/device/left')),new Uint8Array(original));
|
|
globalThis.fetch=async()=>new Response(encodeFirWav(encodeFir(coefficients())).slice(0,-1));
|
|
await assert.rejects(readFirBinary('/bad-length'),/libraryFormat/);
|
|
globalThis.fetch=async()=>new Response('',{status:404});await assert.rejects(readFirBinary('/missing'),/libraryError/);
|
|
});
|
|
|
|
test('device FIR GET uses exact left/right endpoints without cache or setting writes',async()=>{
|
|
const bytes=encodeFir(Array(8192).fill(.125)),requests=[];
|
|
globalThis.fetch=async(url,init)=>{requests.push({url,init});return new Response(bytes);};
|
|
const api=new LuxsinAPI('192.168.9.133');
|
|
for(const side of ['left','right'])assert.deepEqual(new Uint8Array(await api.getFirChannel(side)),new Uint8Array(bytes));
|
|
assert.deepEqual(requests.map(r=>r.url),['http://192.168.9.133/dev/info.cgi?action=fir&channel=left','http://192.168.9.133/dev/info.cgi?action=fir&channel=right']);
|
|
assert.ok(requests.every(r=>r.init.cache==='no-store'&&!r.init.body&&!r.init.method));
|
|
globalThis.fetch=async()=>new Response('device error',{status:502});await assert.rejects(api.getFirChannel('right'),/right: HTTP 502/);
|
|
globalThis.fetch=async()=>new Response('{}');await assert.rejects(api.getFirChannel('left'),/binarySize/);
|
|
});
|
|
test('development FIR GET uses same-origin device relay',async()=>{
|
|
const {LuxsinAPI:DevAPI}=await load('luxsinApi.ts',true),urls=[];
|
|
globalThis.fetch=async url=>{urls.push(url);return new Response(encodeFir(Array(8192).fill(0)));};
|
|
await new DevAPI('192.168.9.133').getFirChannel('right');assert.deepEqual(urls,['/fir-device/192.168.9.133/right']);
|
|
});
|
|
|
|
test('WAV round trip preserves samples and sample rate', () => {
|
|
const raw = encodeFir(coefficients()); const wav = encodeFirWav(raw, 44100);
|
|
assert.equal(wav.byteLength, raw.byteLength + 58);
|
|
assert.equal(new DataView(wav).getUint16(20, true), 3);
|
|
const decoded = decodeFirWav(wav); assert.deepEqual(decoded, raw);
|
|
assert.deepEqual(encodeFirWav(decoded), wav);
|
|
assert.throws(() => decodeFirWav(wav.slice(0, -1)));
|
|
});
|
|
|
|
test('device GET accepts WAV for AI analysis', async () => {
|
|
const raw = encodeFir(Array(8192).fill(0.125));
|
|
globalThis.fetch = async () => new Response(encodeFirWav(raw));
|
|
assert.deepEqual(await new LuxsinAPI('192.168.9.133').getFirChannel('left'), raw);
|
|
});
|
|
test('manual WAV sample rates must match before writes', async () => {
|
|
const raw = encodeFir(Array(8192).fill(0)); let calls = 0;
|
|
await assert.rejects(applyFir({uploadFirChannel: async () => { calls++; }}, decodeFirWav(encodeFirWav(raw, 44100)), decodeFirWav(encodeFirWav(raw, 48000))), /mismatch/);
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test('legacy 44.1 kHz room becomes device-compatible 48 kHz WAV', async () => {
|
|
const raw = encodeFir(Array(8192).fill(0.25)); const requests = [];
|
|
globalThis.fetch = async (url, init) => {
|
|
if (url.startsWith('/library/')) return new Response(raw, {headers: {'Content-Type':'application/octet-stream', 'X-FIR-Sample-Rate':'44100'}});
|
|
requests.push(init); return new Response('OK');
|
|
};
|
|
const left = await readFirBinary('/library/left'); const right = await readFirBinary('/library/right');
|
|
await applyFir(new LuxsinAPI('192.168.9.133'), left, right);
|
|
assert.equal(requests.length, 2);
|
|
for (const req of requests) {
|
|
assert.equal(new Headers(req.headers).has('Content-Type'), false);
|
|
assert.equal(new DataView(req.body).getUint32(24, true), 48000);
|
|
assert.equal(req.body.byteLength, 32826);
|
|
const samples = new DataView(decodeFirWav(req.body));
|
|
assert.ok(Math.abs(samples.getFloat32(4000,true) - 0.25 * 44100 / 48000) < 1e-5);
|
|
}
|
|
});
|
|
test('legacy room uses room metadata if cross-origin headers are hidden', async () => {
|
|
const raw = encodeFir(Array(8192).fill(0));
|
|
globalThis.fetch = async () => new Response(raw, {headers:{'Content-Type':'application/octet-stream'}});
|
|
const data = await readFirBinary('/library/left', 44100);
|
|
assert.equal(new DataView(encodeFirWav(data)).getUint32(24, true), 44100);
|
|
await assert.rejects(readFirBinary('/library/left'), /rateError/);
|
|
});
|
|
test('room library rejects HTML, JSON and invalid raw coefficients', async () => {
|
|
for (const response of [new Response('<html>error</html>'), new Response('{}',{headers:{'Content-Type':'application/json'}}), new Response(new ArrayBuffer(32768),{headers:{'Content-Type':'audio/wav'}})]) {
|
|
globalThis.fetch = async () => response;
|
|
await assert.rejects(readFirBinary('/library/left', 44100), /libraryFormat/);
|
|
}
|
|
const bad = encodeFir(Array(8192).fill(0));new DataView(bad).setFloat32(0,NaN,true);
|
|
globalThis.fetch = async () => new Response(bad,{headers:{'Content-Type':'application/octet-stream'}});
|
|
await assert.rejects(readFirBinary('/library/left',44100), /libraryFormat/);
|
|
});
|
|
test('library WAV accepts 8191 taps and retains its own sample rate', async () => {
|
|
const raw = encodeFir(coefficients());
|
|
globalThis.fetch = async () => new Response(encodeFirWav(raw,44100),{headers:{'Content-Type':'audio/wav'}});
|
|
const data = await readFirBinary('/library/left',48000);
|
|
assert.deepEqual(data,raw);assert.equal(new DataView(encodeFirWav(data)).getUint32(24,true),44100);
|
|
});
|
|
|
|
test('resampling preserves impulse time, interaural delay and convolution gain', () => {
|
|
const channel = (index) => { const values=Array(8192).fill(0);values[index]=1;return decodeFirWav(encodeFirWav(encodeFir(values),44100)); };
|
|
const peak = values => values.reduce((best,x,i) => Math.abs(x)>Math.abs(values[best])?i:best,0);
|
|
const arrays = [441,882].map(index => { const v=new DataView(decodeFirWav(encodeDeviceFirWav(channel(index))));return Array.from({length:8192},(_,i)=>v.getFloat32(i*4,true)); });
|
|
assert.equal(peak(arrays[0]),480);assert.equal(peak(arrays[1]),960);
|
|
for (const values of arrays) assert.ok(Math.abs(values.reduce((sum,x)=>sum+x,0)-1)<0.001);
|
|
});
|
|
test('48 kHz 8191 taps are padded; 8192 taps keep exact bytes', () => {
|
|
for (const taps of [8191,8192]) {
|
|
const raw=encodeFir(Array(taps).fill(-0.125));const wav=encodeDeviceFirWav(raw);
|
|
assert.equal(wav.byteLength,32826);assert.equal(new DataView(wav).getUint32(24,true),48000);
|
|
const decoded=decodeFirWav(wav);assert.deepEqual(decoded.slice(0,raw.byteLength),raw);
|
|
if(taps===8191) assert.equal(new DataView(decoded).getFloat32(8191*4,true),0);
|
|
}
|
|
});
|
|
test('device error message is included in upload failure', async () => {
|
|
globalThis.fetch=async()=>new Response(JSON.stringify({ok:false,message:'WAV must be mono, 48 kHz, 8192 samples'}),{status:400});
|
|
await assert.rejects(new LuxsinAPI('192.168.9.133').uploadFirChannel('left',encodeFir(Array(8192).fill(0))), error=>error.params.reason.includes('48 kHz'));
|
|
});
|