morse-code / tests /unit /detector.test.js
RemiFabre
feat: initial Reachy Mini Morse Code app
843a4b2
Raw
History Blame Contribute Delete
4.24 kB
import { describe, it, expect } from "vitest";
import { detectTransientOnsets, highpass, energyFlux } from "../../lib/detector.js";
import { textToSchedule, decodeOnsets } from "../../lib/wire.js";
import { makeTiming } from "../../lib/timing.js";
const SR = 48000;
const T = makeTiming(120);
/**
* Render an onset schedule (ms) into a synthetic mono buffer.
* Each impulse is a short decaying ~4 kHz burst (sharp click, energy well
* above the 2 kHz highpass). Optionally add a low 60 Hz hum + deterministic
* pseudo-noise to prove the highpass + flux stages are doing real work.
*/
function renderClicks(onsetsMs, { leadMs = 60, tailMs = 300, hum = 0, noise = 0 } = {}) {
// Lead-in silence: a real listener is already recording before the
// sender starts, so the very first click has a low-energy window before
// it for the rising-edge detector to key off.
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); // 10 ms
const tau = 0.0015; // 1.5 ms decay
for (const t0 of onsetsMs) {
const start = Math.round(((t0 + leadMs) / 1000) * SR);
for (let j = 0; j < clickLen && start + j < n; j++) {
const tt = j / SR;
buf[start + j] += 0.6 * Math.exp(-tt / tau) * Math.sin(2 * Math.PI * 4000 * tt);
}
}
if (hum || noise) {
for (let i = 0; i < n; i++) {
const tt = i / SR;
if (hum) buf[i] += hum * Math.sin(2 * Math.PI * 60 * tt);
// deterministic low-level "noise": sum of a few incommensurate tones
if (noise) {
buf[i] += noise * (
Math.sin(2 * Math.PI * 137 * tt) +
Math.sin(2 * Math.PI * 311 * tt)
) * 0.5;
}
}
}
return buf;
}
describe("highpass + energyFlux primitives", () => {
it("highpass removes a strong 60 Hz hum", () => {
const n = SR; // 1 s
const hum = new Float32Array(n);
for (let i = 0; i < n; i++) hum[i] = Math.sin(2 * Math.PI * 60 * (i / SR));
const filtered = highpass(hum, SR, 2000);
let rmsIn = 0, rmsOut = 0;
for (let i = 0; i < n; i++) { rmsIn += hum[i] ** 2; rmsOut += filtered[i] ** 2; }
expect(Math.sqrt(rmsOut / n)).toBeLessThan(Math.sqrt(rmsIn / n) * 0.05);
});
it("energyFlux returns positive spikes around clicks", () => {
const buf = renderClicks([100, 500]);
const { flux } = energyFlux(highpass(buf, SR, 2000), SR, 5);
let maxFlux = 0;
for (const f of flux) if (f > maxFlux) maxFlux = f;
expect(maxFlux).toBeGreaterThan(0);
});
});
describe("detectTransientOnsets — count + timing", () => {
it("finds all 12 impulses of SOS within 25 ms", () => {
const { onsets } = textToSchedule("SOS", T);
const detected = detectTransientOnsets(renderClicks(onsets), SR);
expect(detected.length).toBe(onsets.length);
// Compare spacing relative to the first onset (absorbs the constant
// lead-in offset + any fixed detection latency).
for (let i = 0; i < onsets.length; i++) {
const exp = onsets[i] - onsets[0];
const got = detected[i] - detected[0];
expect(Math.abs(got - exp)).toBeLessThan(25);
}
});
});
describe("end-to-end: text -> clicks -> detect -> decode", () => {
const phrases = ["SOS", "HI", "HELLO WORLD", "CQ CQ"];
for (const text of phrases) {
it(`recovers "${text}" from clean synthetic audio`, () => {
const { onsets } = textToSchedule(text, T);
const detected = detectTransientOnsets(renderClicks(onsets), SR);
expect(decodeOnsets(detected, T).text).toBe(text);
});
it(`recovers "${text}" through 60 Hz hum + tonal noise`, () => {
const { onsets } = textToSchedule(text, T);
const buf = renderClicks(onsets, { hum: 0.15, noise: 0.03 });
const detected = detectTransientOnsets(buf, SR);
expect(decodeOnsets(detected, T).text).toBe(text);
});
}
});