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); }); } });