Files
controller-v2/scripts/ai-fir.test.mjs
T
2026-10-30 17:40:13 +08:00

240 lines
7.0 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { build } from "esbuild";
const compiled = await build({
entryPoints: ["client/src/lib/aiFir.ts"],
bundle: true,
write: false,
format: "esm",
platform: "node",
});
const fir = await import(
"data:text/javascript;base64," +
Buffer.from(compiled.outputFiles[0].text).toString("base64")
);
function pair(n = 8192) {
const left = Array(n).fill(0),
right = Array(n).fill(0);
left[10] = 0.4;
right[12] = 0.2;
left[200] = 0.1;
right[202] = 0.05;
left[0] = -0;
return { left, right };
}
function adjustment(extra = {}) {
return {
snapshot_id: "snapshot",
name: "test",
explanation: "Less late energy",
gain_db: 0,
tail_gain_db: 0,
smoothing: 0,
...extra,
};
}
function storage() {
const data = new Map();
return {
setItem: (k, v) => data.set(k, v),
getItem: k => data.get(k) ?? null,
};
}
function proposal() {
const before = pair();
return {
id: "p",
device: "device1",
before,
after: fir.renderFir(before, adjustment({ tail_gain_db: -6 })),
adjustment: adjustment({ tail_gain_db: -6 }),
created: "now",
};
}
function binary(values) {
const b = new ArrayBuffer(values.length * 4),
v = new DataView(b);
values.forEach((x, i) => v.setFloat32(i * 4, x, true));
return b;
}
function mockDevice(p) {
const current = {
left: binary(p.before.left),
right: binary(p.before.right),
};
const posts = [];
return {
posts,
current,
getFirChannel: async side => current[side].slice(0),
uploadFirChannel: async (side, data) => {
posts.push(side);
current[side] = data.slice(0);
},
};
}
test("decode validates raw float32 lengths/nonfinite and mismatched ear counts", () => {
assert.throws(() => fir.decodeFir(new ArrayBuffer(4)));
const b = binary(pair().left);
new DataView(b).setFloat32(0, NaN, true);
assert.throws(() => fir.decodeFir(b));
assert.throws(() =>
fir.validatePair({ left: pair().left, right: pair(8191).right })
);
});
test("identity, common attenuation and common envelope keep taps and stereo timing", () => {
for (const n of [8191, 8192]) {
const p = pair(n),
a = fir.renderFir(p, adjustment());
assert.equal(a.left.length, n);
assert.equal(a.right.length, n);
assert.equal(a.left[10], Math.fround(p.left[10]));
assert.equal(a.right[12], Math.fround(p.right[12]));
const q = fir.renderFir(p, adjustment({ gain_db: -6, tail_gain_db: -12 }));
assert.ok(q.left[200] < q.left[10] / 4);
assert.equal(q.left.indexOf(Math.max(...q.left)), 10);
assert.equal(q.right.indexOf(Math.max(...q.right)), 12);
assert.ok(q.left.every((x, i) => Math.abs(x) <= Math.abs(p.left[i])));
}
});
test("bounded transforms reject gain boosts, NaN and out-of-range smoothing", () => {
for (const a of [
{ gain_db: 1 },
{ gain_db: NaN },
{ tail_gain_db: -25 },
{ smoothing: 2 },
])
assert.throws(() => fir.renderFir(pair(), adjustment(a)));
});
test("smoothing uses documented causal 3 tap kernel", () => {
const p = pair();
const q = fir.renderFir(p, adjustment({ smoothing: 1 }));
assert.equal(q.left[10], Math.fround(0.1));
assert.equal(q.left[11], Math.fround(0.2));
assert.equal(q.left[12], Math.fround(0.1));
});
test("cache preserves exact float32 bytes including signed zero, isolated by device", () => {
const p = proposal(),
s = storage();
fir.saveFirBackup(s, { version: 1, proposal: p, state: "pending" });
const loaded = fir.loadFirBackup(s, p.device);
assert.ok(Object.is(loaded.proposal.before.left[0], -0));
assert.deepEqual(binary(loaded.proposal.before.left), binary(p.before.left));
assert.equal(fir.loadFirBackup(s, "different"), null);
});
test("backup failure or changed device blocks ALL POSTs", async () => {
const p = proposal(),
d = mockDevice(p);
await assert.rejects(
fir.applyFirVersion(
d,
{
setItem() {
throw Error("quota");
},
getItem() {
return null;
},
},
{ version: 1, proposal: p, state: "pending" },
"B",
true
)
);
assert.deepEqual(d.posts, []);
new DataView(d.current.left).setFloat32(100, 1, true);
await assert.rejects(
fir.applyFirVersion(
d,
storage(),
{ version: 1, proposal: p, state: "pending" },
"B",
true
),
/FIR_CHANGED/
);
assert.deepEqual(d.posts, []);
});
test("backup precedes left/right upload; verified A/B restores byte-exact original without switch writes", async () => {
const p = proposal(),
s = storage(),
d = mockDevice(p);
const send = d.uploadFirChannel;
d.uploadFirChannel = async (side, data) => {
assert.ok(fir.loadFirBackup(s, p.device));
await send(side, data);
};
const done = await fir.applyFirVersion(
d,
s,
{ version: 1, proposal: p, state: "pending" },
"B",
true
);
assert.equal(done.state, "B");
assert.deepEqual(d.posts, ["left", "right"]);
const restored = await fir.applyFirVersion(d, s, done, "A");
assert.equal(restored.state, "A");
assert.deepEqual(d.current.left, binary(p.before.left));
assert.deepEqual(d.current.right, binary(p.before.right));
});
test("right-ear failure retains original backup and marks incomplete, no automatic retries", async () => {
const p = proposal(),
s = storage(),
d = mockDevice(p),
send = d.uploadFirChannel;
d.uploadFirChannel = async (side, data) => {
if (side === "right") throw Error("502");
await send(side, data);
};
await assert.rejects(
fir.applyFirVersion(
d,
s,
{ version: 1, proposal: p, state: "pending" },
"B",
true
)
);
const cached = fir.loadFirBackup(s, p.device);
assert.equal(cached.state, "incomplete");
assert.deepEqual(binary(cached.proposal.before.left), binary(p.before.left));
assert.deepEqual(d.posts, ["left"]);
});
test("readback mismatch does not report success", async () => {
const p = proposal(),
s = storage(),
d = mockDevice(p);
d.uploadFirChannel = async () => {};
await assert.rejects(
fir.applyFirVersion(
d,
s,
{ version: 1, proposal: p, state: "pending" },
"B",
true
),
/FIR_VERIFY/
);
assert.equal(fir.loadFirBackup(s, p.device).state, "incomplete");
});
test("zero FIR metrics are finite and do not invent sample rate", () => {
const p = { left: Array(8192).fill(0), right: Array(8192).fill(0) };
const m = fir.firMetrics(p);
assert.equal(m.sample_rate, null);
assert.equal(m.left.late_energy_ratio, 0);
});
test('A/B WAV exports preserve coefficients and signed zero', () => {
const before = pair(); const after = fir.renderFir(before, adjustment({tail_gain_db: -6}));
for (const value of [before, after]) {
const files = fir.firPairWavs(value);
for (const side of ['left', 'right']) {
const view = new DataView(files[side]);
assert.equal(view.getUint16(20, true), 3);
assert.equal(view.getUint32(24, true), 48000);
assert.deepEqual(fir.decodeFir(files[side].slice(58)), value[side].map(Math.fround));
}
}
});