// 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('error'), 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')); });