240 lines
7.0 KiB
JavaScript
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));
|
|
}
|
|
}
|
|
});
|