feat: integrate FIR library and AI FIR controls
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user