morse-code / tests /unit /streaming.test.js
RemiFabre
tune: robust live detector + calibrated robot clap from hardware test
fa08634
Raw
History Blame Contribute Delete
2.7 kB
import { describe, it, expect } from "vitest";
import { StreamingOnsetDetector, highpass } from "../../lib/detector.js";
import { textToSchedule, decodeOnsets } from "../../lib/wire.js";
import { makeTiming } from "../../lib/timing.js";
const SR = 48000;
const T = makeTiming(150);
// Same synthetic click renderer as detector.test.js, plus optional "rebound"
// echoes after each click to mimic the real antenna ringing we observed on
// hardware — the peakRatio + refractory gates should reject them.
function renderClicks(onsetsMs, { leadMs = 300, tailMs = 600, rebound = 0 } = {}) {
const all = [];
for (const t0 of onsetsMs) {
all.push(t0);
// quiet ringing echoes: one inside the refractory window, one later but
// well below the peakRatio gate — the two gates should reject both.
if (rebound) { all.push(t0 + 130); all.push(t0 + 250); }
}
const durMs = leadMs + (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + tailMs;
const n = Math.ceil((durMs / 1000) * SR);
const buf = new Float32Array(n);
const clickLen = Math.round(0.01 * SR);
const tau = 0.0015;
for (const t0 of all) {
const start = Math.round(((t0 + leadMs) / 1000) * SR);
// rebounds are clearly quieter than the real strike (~15%)
const amp = onsetsMs.includes(t0) ? 0.6 : 0.09;
for (let j = 0; j < clickLen && start + j < n; j++) {
const tt = j / SR;
buf[start + j] += amp * Math.exp(-tt / tau) * Math.sin(2 * Math.PI * 4000 * tt);
}
}
return buf;
}
function streamDecode(buf, opts = {}) {
const filtered = highpass(buf, SR, 2000);
const onsets = [];
const det = new StreamingOnsetDetector({ sampleRate: SR, onOnset: (t) => onsets.push(t), ...opts });
const BLOCK = 2048;
for (let i = 0; i < filtered.length; i += BLOCK) {
det.process(filtered.subarray(i, Math.min(i + BLOCK, filtered.length)), (i / SR) * 1000);
}
return { onsets, ...decodeOnsets(onsets, T) };
}
describe("StreamingOnsetDetector (live path)", () => {
for (const phrase of ["SOS", "HELLO WORLD", "CQ"]) {
it(`decodes "${phrase}" from clean clicks`, () => {
const { onsets } = textToSchedule(phrase, T);
const r = streamDecode(renderClicks(onsets));
expect(r.onsets.length).toBe(onsets.length);
expect(r.text).toBe(phrase);
});
it(`decodes "${phrase}" despite per-click ringing echoes`, () => {
const { onsets } = textToSchedule(phrase, T);
const r = streamDecode(renderClicks(onsets, { rebound: 1 }));
expect(r.text).toBe(phrase);
});
}
});