1// Checks on the voice-activity gate in capture.ts. The gate decides what gets
2// paid for, and its constants are the kind that invite tweaking, so its
3// behavior is pinned here. It is pure arithmetic over frame energies, so it
4// runs under node once capture.ts is bundled:
5//
6// npx esbuild src/transcribe/capture.ts --format=esm --outfile=/tmp/capture.mjs
7// node src/transcribe/gate.test.mjs /tmp/capture.mjs
8//
9// Energies are RMS of samples normalized to [-1, 1]; frames are 40 ms.
11const {SpeechGate} = await import(process.argv[2] ?? '/tmp/capture.mjs')
13let failed = 0
14const check = (label, ok, detail = '') => {
15 if (!ok) failed++
16 console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`)
17}
19// Feed a sequence of frame energies and record where the gate opened/closed.
20const run = frames => {
21 const g = new SpeechGate()
22 const events = []
23 frames.forEach((rms, i) => {
24 const e = g.push(rms)
25 if (e) events.push([e, i])
26 })
27 return {events, gate: g}
28}
30const rep = (v, n) => Array(n).fill(v)
32// 1. Digital silence never opens the gate (a muted participant, or the silent
33// placeholder track someone without a microphone sends).
34check('silence stays shut', run(rep(0, 500)).events.length === 0)
36// 2. Ordinary quiet room tone never opens it either.
37const roomTone = Array.from({length: 500}, () => 0.0015 + Math.random() * 0.001)
38check('room tone stays shut', run(roomTone).events.length === 0)
40// 3. Speech opens it, and within OPEN_FRAMES (2) of onset.
41const speech = [...rep(0.002, 100), ...rep(0.05, 50), ...rep(0.002, 100)]
42{
43 const {events} = run(speech)
44 const open = events.find(e => e[0] === 'open')
45 const close = events.find(e => e[0] === 'close')
46 check('speech opens the gate', !!open, open && `at frame ${open[1]}`)
47 check('opens promptly', open && open[1] - 100 <= 2, open && `${open[1] - 100} frames late`)
48 check('speech closes the gate', !!close, close && `at frame ${close[1]}`)
49 // CLOSE_FRAMES is 20 (800 ms) after the last loud frame at index 149.
50 check('closes after the hold', close && close[1] - 149 === 20, close && `${close[1] - 149} frames`)
51}
53// 4. A mid-sentence pause shorter than the hold does NOT split the utterance.
54{
55 const {events} = run([
56 ...rep(0.002, 60),
57 ...rep(0.05, 25),
58 ...rep(0.002, 15), // 600 ms pause, under the 800 ms hold
59 ...rep(0.05, 25),
60 ...rep(0.002, 60)
61 ])
62 check(
63 'a short pause does not split the utterance',
64 events.filter(e => e[0] === 'open').length === 1,
65 `${events.filter(e => e[0] === 'open').length} openings`
66 )
67}
69// 5. A noisy room raises the floor: the same absolute level that counts as
70// speech in a quiet room is ignored once it IS the room.
71{
72 const noisy = rep(0.02, 400)
73 const {events, gate} = run(noisy)
74 check('steady noise is learned, not transcribed', events.length === 0)
75 check('noise floor tracked up', gate.noiseFloor > 0.015, `floor ${gate.noiseFloor.toFixed(4)}`)
76 // Speech must now clear 2.5x the floor to register.
77 const g2 = new SpeechGate()
78 rep(0.02, 400).forEach(v => g2.push(v))
79 const over = [0.08, 0.08, 0.08].map(v => g2.push(v))
80 check('speech above a noisy floor still opens', over.includes('open'))
81}
83// 6. The floor does not creep up during a long utterance and shut it off.
84{
85 const {events} = run([...rep(0.002, 50), ...rep(0.06, 600), ...rep(0.002, 50)])
86 check(
87 'a long utterance is not cut short',
88 events.filter(e => e[0] === 'open').length === 1 &&
89 events.filter(e => e[0] === 'close').length === 1
90 )
91}
93// 7. One isolated loud frame (a click) does not open it.
94{
95 const {events} = run([...rep(0.002, 50), 0.2, ...rep(0.002, 50)])
96 check('a single click does not open the gate', events.length === 0)
97}
99// 8. The room gets loud AFTER the floor was learned quiet, and stays loud with
100// nobody speaking. The gate opens (it cannot know better), but the watchdog
101// must force it shut and the floor must re-learn, so this cannot run on.
102{
103 const frames = [...rep(0.002, 200), ...rep(0.03, 8000)]
104 const g = new SpeechGate()
105 let sent = 0
106 let lastOpenAt = -1
107 frames.forEach((rms, i) => {
108 const e = g.push(rms)
109 if (e === 'open') lastOpenAt = i
110 if (g.speaking) sent++
111 })
112 check('a room that turns loud is eventually learned', !g.speaking)
113 check(
114 'and costs exactly one watchdog window, not a repeating cycle',
115 lastOpenAt < 250 && sent <= 1600,
116 `${(sent * 0.04).toFixed(0)} s sent over ${(frames.length * 0.04).toFixed(0)} s; last opening at frame ${lastOpenAt}`
117 )
118}
120// 9. Cost: a realistic conversation, one participant's channel. They talk in
121// 5 s bursts about a quarter of the time and listen the rest.
122{
123 const frames = []
124 for (let turn = 0; turn < 24; turn++) {
125 frames.push(...rep(0.0015, 375)) // 15 s listening
126 for (let w = 0; w < 25; w++) {
127 // 5 s of speech is not a plateau: syllables and gaps between words.
128 frames.push(...rep(0.04, 3), ...rep(0.008, 2))
129 }
130 }
131 const g = new SpeechGate()
132 let sent = 0
133 frames.forEach(rms => {
134 g.push(rms)
135 if (g.speaking) sent++
136 })
137 const talkFraction = 125 / 500
138 const sentFraction = sent / frames.length
139 check(
140 'gating tracks actual speech',
141 sentFraction > talkFraction && sentFraction < talkFraction * 1.6,
142 `${(sentFraction * 100).toFixed(0)}% sent vs ${(talkFraction * 100).toFixed(0)}% spoken`
143 )
144}
146console.log(failed === 0 ? '\nall checks passed' : `\n${failed} FAILED`)
147process.exit(failed === 0 ? 0 : 1)