// Checks on the voice-activity gate in capture.ts. The gate decides what gets // paid for, and its constants are the kind that invite tweaking, so its // behavior is pinned here. It is pure arithmetic over frame energies, so it // runs under node once capture.ts is bundled: // // npx esbuild src/transcribe/capture.ts --format=esm --outfile=/tmp/capture.mjs // node src/transcribe/gate.test.mjs /tmp/capture.mjs // // Energies are RMS of samples normalized to [-1, 1]; frames are 40 ms. const {SpeechGate} = await import(process.argv[2] ?? '/tmp/capture.mjs') let failed = 0 const check = (label, ok, detail = '') => { if (!ok) failed++ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`) } // Feed a sequence of frame energies and record where the gate opened/closed. const run = frames => { const g = new SpeechGate() const events = [] frames.forEach((rms, i) => { const e = g.push(rms) if (e) events.push([e, i]) }) return {events, gate: g} } const rep = (v, n) => Array(n).fill(v) // 1. Digital silence never opens the gate (a muted participant, or the silent // placeholder track someone without a microphone sends). check('silence stays shut', run(rep(0, 500)).events.length === 0) // 2. Ordinary quiet room tone never opens it either. const roomTone = Array.from({length: 500}, () => 0.0015 + Math.random() * 0.001) check('room tone stays shut', run(roomTone).events.length === 0) // 3. Speech opens it, and within OPEN_FRAMES (2) of onset. const speech = [...rep(0.002, 100), ...rep(0.05, 50), ...rep(0.002, 100)] { const {events} = run(speech) const open = events.find(e => e[0] === 'open') const close = events.find(e => e[0] === 'close') check('speech opens the gate', !!open, open && `at frame ${open[1]}`) check('opens promptly', open && open[1] - 100 <= 2, open && `${open[1] - 100} frames late`) check('speech closes the gate', !!close, close && `at frame ${close[1]}`) // CLOSE_FRAMES is 20 (800 ms) after the last loud frame at index 149. check('closes after the hold', close && close[1] - 149 === 20, close && `${close[1] - 149} frames`) } // 4. A mid-sentence pause shorter than the hold does NOT split the utterance. { const {events} = run([ ...rep(0.002, 60), ...rep(0.05, 25), ...rep(0.002, 15), // 600 ms pause, under the 800 ms hold ...rep(0.05, 25), ...rep(0.002, 60) ]) check( 'a short pause does not split the utterance', events.filter(e => e[0] === 'open').length === 1, `${events.filter(e => e[0] === 'open').length} openings` ) } // 5. A noisy room raises the floor: the same absolute level that counts as // speech in a quiet room is ignored once it IS the room. { const noisy = rep(0.02, 400) const {events, gate} = run(noisy) check('steady noise is learned, not transcribed', events.length === 0) check('noise floor tracked up', gate.noiseFloor > 0.015, `floor ${gate.noiseFloor.toFixed(4)}`) // Speech must now clear 2.5x the floor to register. const g2 = new SpeechGate() rep(0.02, 400).forEach(v => g2.push(v)) const over = [0.08, 0.08, 0.08].map(v => g2.push(v)) check('speech above a noisy floor still opens', over.includes('open')) } // 6. The floor does not creep up during a long utterance and shut it off. { const {events} = run([...rep(0.002, 50), ...rep(0.06, 600), ...rep(0.002, 50)]) check( 'a long utterance is not cut short', events.filter(e => e[0] === 'open').length === 1 && events.filter(e => e[0] === 'close').length === 1 ) } // 7. One isolated loud frame (a click) does not open it. { const {events} = run([...rep(0.002, 50), 0.2, ...rep(0.002, 50)]) check('a single click does not open the gate', events.length === 0) } // 8. The room gets loud AFTER the floor was learned quiet, and stays loud with // nobody speaking. The gate opens (it cannot know better), but the watchdog // must force it shut and the floor must re-learn, so this cannot run on. { const frames = [...rep(0.002, 200), ...rep(0.03, 8000)] const g = new SpeechGate() let sent = 0 let lastOpenAt = -1 frames.forEach((rms, i) => { const e = g.push(rms) if (e === 'open') lastOpenAt = i if (g.speaking) sent++ }) check('a room that turns loud is eventually learned', !g.speaking) check( 'and costs exactly one watchdog window, not a repeating cycle', lastOpenAt < 250 && sent <= 1600, `${(sent * 0.04).toFixed(0)} s sent over ${(frames.length * 0.04).toFixed(0)} s; last opening at frame ${lastOpenAt}` ) } // 9. Cost: a realistic conversation, one participant's channel. They talk in // 5 s bursts about a quarter of the time and listen the rest. { const frames = [] for (let turn = 0; turn < 24; turn++) { frames.push(...rep(0.0015, 375)) // 15 s listening for (let w = 0; w < 25; w++) { // 5 s of speech is not a plateau: syllables and gaps between words. frames.push(...rep(0.04, 3), ...rep(0.008, 2)) } } const g = new SpeechGate() let sent = 0 frames.forEach(rms => { g.push(rms) if (g.speaking) sent++ }) const talkFraction = 125 / 500 const sentFraction = sent / frames.length check( 'gating tracks actual speech', sentFraction > talkFraction && sentFraction < talkFraction * 1.6, `${(sentFraction * 100).toFixed(0)}% sent vs ${(talkFraction * 100).toFixed(0)}% spoken` ) } console.log(failed === 0 ? '\nall checks passed' : `\n${failed} FAILED`) process.exit(failed === 0 ? 0 : 1)