Spaces:
Running
Running
| 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); | |
| }); | |
| } | |
| }); | |