test_segmenter.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. """Tests for the VAD segmenter with pre-roll buffer."""
  2. import numpy as np
  3. import pytest
  4. import webrtcvad
  5. from stt_daemon.segmenter import FrameSegmenter, make_vad
  6. class FakeVad:
  7. """A VAD for testing: returns scripted speech/non-speech decisions."""
  8. def __init__(self, decisions: list[bool]):
  9. """Initialize with a sequence of boolean decisions.
  10. Args:
  11. decisions: List of True (speech) / False (silence) per frame.
  12. """
  13. self.decisions = decisions
  14. self.frame_index = 0
  15. def is_speech(self, frame: bytes, sample_rate: int) -> bool:
  16. """Return the next scripted decision."""
  17. if self.frame_index >= len(self.decisions):
  18. # If we've exhausted decisions, default to False
  19. return False
  20. result = self.decisions[self.frame_index]
  21. self.frame_index += 1
  22. return result
  23. def make_frame(samples: np.ndarray) -> bytes:
  24. """Convert samples to int16 PCM bytes."""
  25. assert samples.dtype == np.int16
  26. return samples.tobytes()
  27. def make_silent_frame(frame_samples: int) -> bytes:
  28. """Create a silent frame (zeros)."""
  29. return make_frame(np.zeros(frame_samples, dtype=np.int16))
  30. def make_speech_frame(frame_samples: int, value: int = 1000) -> bytes:
  31. """Create a frame with some amplitude."""
  32. return make_frame(np.full(frame_samples, value, dtype=np.int16))
  33. def test_make_vad_invalid_aggressiveness():
  34. """Test that make_vad validates aggressiveness."""
  35. with pytest.raises(ValueError):
  36. make_vad(4)
  37. with pytest.raises(ValueError):
  38. make_vad(-1)
  39. def test_make_vad_valid():
  40. """Test that make_vad accepts 0-3."""
  41. for aggr in (0, 1, 2, 3):
  42. vad = make_vad(aggr)
  43. assert isinstance(vad, webrtcvad.Vad)
  44. def test_preroll_preserved():
  45. """Test that pre-roll buffer content is included in the emitted utterance.
  46. Pre-roll frames are kept in a ring buffer before speech is confirmed.
  47. When speech is confirmed, those pre-roll frames should be at the start
  48. of the emitted utterance, ensuring the first phoneme is not cut off.
  49. """
  50. sample_rate = 16000
  51. frame_ms = 30
  52. frame_samples = round(sample_rate * frame_ms / 1000)
  53. # Pre-roll frames: 2 silent frames (60ms silence before speech)
  54. # Then: 3 speech frames to confirm speech (90ms to trigger confirmation)
  55. # Then: 1 more speech frame + 2 silence frames to finalize
  56. fake_vad = FakeVad(
  57. [
  58. False, # Frame 0: pre-roll silence
  59. False, # Frame 1: pre-roll silence
  60. True, # Frame 2: speech (count=1)
  61. True, # Frame 3: speech (count=2)
  62. True, # Frame 4: speech (count=3, confirmed)
  63. True, # Frame 5: speech (during speaking)
  64. False, # Frame 6: silence (count=1)
  65. False, # Frame 7: silence (count=2, finalize)
  66. ]
  67. )
  68. segmenter = FrameSegmenter(
  69. sample_rate=sample_rate,
  70. frame_ms=frame_ms,
  71. vad=fake_vad,
  72. pre_roll_ms=150, # 5 frames (enough for 2 silence + 3 speech to not lose frame 0)
  73. speech_start_ms=90, # 3 frames needed
  74. silence_ms=60, # 2 frames to finalize
  75. min_utterance_ms=30,
  76. max_utterance_s=30,
  77. )
  78. pre_roll_frame_0 = make_silent_frame(frame_samples)
  79. pre_roll_frame_1 = make_silent_frame(frame_samples)
  80. speech_frame_2 = make_speech_frame(frame_samples)
  81. speech_frame_3 = make_speech_frame(frame_samples)
  82. speech_frame_4 = make_speech_frame(frame_samples)
  83. speech_frame_5 = make_speech_frame(frame_samples)
  84. silence_frame_6 = make_silent_frame(frame_samples)
  85. silence_frame_7 = make_silent_frame(frame_samples)
  86. # Push pre-roll frames (should be buffered, not emitted)
  87. assert segmenter.push(pre_roll_frame_0) is None
  88. assert segmenter.push(pre_roll_frame_1) is None
  89. # Push speech frames until confirmation (frame 4 confirms)
  90. assert segmenter.push(speech_frame_2) is None
  91. assert segmenter.push(speech_frame_3) is None
  92. utt = segmenter.push(speech_frame_4)
  93. assert utt is None # _begin() returns None, not the utterance yet
  94. # Continue with more frames
  95. assert segmenter.push(speech_frame_5) is None
  96. assert segmenter.push(silence_frame_6) is None
  97. # Final silence triggers finalization
  98. utt = segmenter.push(silence_frame_7)
  99. assert utt is not None, "Utterance should be emitted after silence"
  100. # Verify that the utterance contains the pre-roll frames and speech frames
  101. # Expected: pre_roll_0, pre_roll_1, speech_2-5, silence_6-7
  102. # (silence frames kept as tail when finalizing with keep_tail=True)
  103. expected_sample_count = frame_samples * 8
  104. actual_sample_count = len(utt.audio)
  105. assert actual_sample_count == expected_sample_count, (
  106. f"Expected {expected_sample_count} samples (8 frames), "
  107. f"got {actual_sample_count}"
  108. )
  109. # Verify the audio starts with zeros (the pre-roll silence frames 0-1)
  110. pre_roll_audio = utt.audio[:2 * frame_samples]
  111. assert np.allclose(pre_roll_audio, 0, atol=0.01), (
  112. "Pre-roll should be silent at the start (first 2 frames)"
  113. )
  114. # Verify speech frames come next (non-zero)
  115. speech_audio = utt.audio[2 * frame_samples : 6 * frame_samples]
  116. assert not np.allclose(speech_audio, 0, atol=0.01), (
  117. "Speech frames should be non-silent"
  118. )
  119. def test_silence_finalization():
  120. """Test that silence_frames consecutive frames trigger finalization."""
  121. sample_rate = 16000
  122. frame_ms = 30
  123. frame_samples = round(sample_rate * frame_ms / 1000)
  124. fake_vad = FakeVad(
  125. [
  126. True, # Start of speech
  127. True,
  128. True,
  129. True, # 4 speech frames
  130. False, # Silence count=1
  131. False, # Silence count=2
  132. False, # Silence count=3 (finalizes if silence_ms=90)
  133. ]
  134. )
  135. segmenter = FrameSegmenter(
  136. sample_rate=sample_rate,
  137. frame_ms=frame_ms,
  138. vad=fake_vad,
  139. pre_roll_ms=60, # 2 frames
  140. speech_start_ms=30, # 1 frame to confirm
  141. silence_ms=60, # 2 frames to finalize
  142. min_utterance_ms=30,
  143. max_utterance_s=30,
  144. )
  145. # Push 1 speech frame to trigger confirmation
  146. utt = segmenter.push(make_speech_frame(frame_samples))
  147. assert utt is None
  148. # Push 3 more speech frames
  149. for _ in range(3):
  150. assert segmenter.push(make_speech_frame(frame_samples)) is None
  151. # Push 1 silence frame (count=1)
  152. assert segmenter.push(make_silent_frame(frame_samples)) is None
  153. # Push 2nd silence frame (count=2, finalizes)
  154. utt = segmenter.push(make_silent_frame(frame_samples))
  155. assert utt is not None
  156. # At finalization with 2 trailing silence frames, we keep those as tail.
  157. # Segment progression:
  158. # - After frame 0 (speech confirm): segment = [frame_0]
  159. # - After frames 1-3: segment = [0, 1, 2, 3]
  160. # - After frame 4 (1st silence): segment = [0, 1, 2, 3, 4], silence_run=1
  161. # - After frame 5 (2nd silence): segment = [0, 1, 2, 3, 4, 5], silence_run=2
  162. # Finalize: emit 6 frames, keep tail [4, 5] for next pre-roll
  163. assert len(utt.audio) == frame_samples * 6
  164. def test_min_utterance_discarded():
  165. """Test that utterances shorter than min_utterance_ms are discarded."""
  166. sample_rate = 16000
  167. frame_ms = 30
  168. frame_samples = round(sample_rate * frame_ms / 1000)
  169. fake_vad = FakeVad(
  170. [
  171. True, # 1 speech frame (30ms, less than min 300ms)
  172. False, # Silence to finalize
  173. False,
  174. ]
  175. )
  176. segmenter = FrameSegmenter(
  177. sample_rate=sample_rate,
  178. frame_ms=frame_ms,
  179. vad=fake_vad,
  180. pre_roll_ms=0,
  181. speech_start_ms=30, # 1 frame
  182. silence_ms=60, # 2 frames
  183. min_utterance_ms=300, # Only keep if >= 300ms (10 frames at 30ms)
  184. max_utterance_s=30,
  185. )
  186. # Confirm speech with 1 frame
  187. segmenter.push(make_speech_frame(frame_samples))
  188. # Finalize with 2 silence frames
  189. segmenter.push(make_silent_frame(frame_samples))
  190. utt = segmenter.push(make_silent_frame(frame_samples))
  191. assert utt is None, "Short utterance should be discarded"
  192. def test_max_utterance_forces_finalization():
  193. """Test that max_utterance_s force-finalizes long continuous speech."""
  194. sample_rate = 16000
  195. frame_ms = 30
  196. frame_samples = round(sample_rate * frame_ms / 1000)
  197. max_utterance_s = 0.3 # 300ms, ~10 frames
  198. # Create a long sequence of speech with no silence
  199. num_frames = 15
  200. fake_vad = FakeVad([True] * num_frames)
  201. segmenter = FrameSegmenter(
  202. sample_rate=sample_rate,
  203. frame_ms=frame_ms,
  204. vad=fake_vad,
  205. pre_roll_ms=0,
  206. speech_start_ms=30, # 1 frame to confirm
  207. silence_ms=60,
  208. min_utterance_ms=30,
  209. max_utterance_s=max_utterance_s,
  210. )
  211. # Confirm speech
  212. segmenter.push(make_speech_frame(frame_samples))
  213. # Push more frames; should finalize when max is hit
  214. utt = None
  215. for i in range(1, num_frames):
  216. utt = segmenter.push(make_speech_frame(frame_samples))
  217. if utt is not None:
  218. break
  219. assert utt is not None, "Max duration should trigger finalization"
  220. # Should have finalized at ~max_utterance_s (a bit flexible due to frame alignment)
  221. assert len(utt.audio) >= frame_samples * 10
  222. def test_flush_finalizes_inprogress():
  223. """Test that flush() finalizes an in-progress utterance."""
  224. sample_rate = 16000
  225. frame_ms = 30
  226. frame_samples = round(sample_rate * frame_ms / 1000)
  227. fake_vad = FakeVad([False, False, True, True])
  228. segmenter = FrameSegmenter(
  229. sample_rate=sample_rate,
  230. frame_ms=frame_ms,
  231. vad=fake_vad,
  232. pre_roll_ms=60, # 2 frames
  233. speech_start_ms=60, # 2 frames
  234. silence_ms=300,
  235. min_utterance_ms=30,
  236. max_utterance_s=30,
  237. )
  238. # Pre-roll frames (silence)
  239. segmenter.push(make_silent_frame(frame_samples))
  240. segmenter.push(make_silent_frame(frame_samples))
  241. # Confirm speech (need 2 consecutive)
  242. segmenter.push(make_speech_frame(frame_samples))
  243. segmenter.push(make_speech_frame(frame_samples))
  244. # Now call flush without sending silence
  245. utt = segmenter.flush()
  246. assert utt is not None
  247. # Ring at confirmation has the 2 speech frames, so segment = [speech_0, speech_1]
  248. assert len(utt.audio) == frame_samples * 2 # The 2 speech frames
  249. def test_no_utterance_if_never_speaks():
  250. """Test that a stream of only silence emits no utterance."""
  251. sample_rate = 16000
  252. frame_ms = 30
  253. frame_samples = round(sample_rate * frame_ms / 1000)
  254. fake_vad = FakeVad([False, False, False, False])
  255. segmenter = FrameSegmenter(
  256. sample_rate=sample_rate,
  257. frame_ms=frame_ms,
  258. vad=fake_vad,
  259. pre_roll_ms=60,
  260. speech_start_ms=60,
  261. silence_ms=60,
  262. min_utterance_ms=30,
  263. max_utterance_s=30,
  264. )
  265. # Push only silence
  266. for _ in range(4):
  267. utt = segmenter.push(make_silent_frame(frame_samples))
  268. assert utt is None
  269. # Flush should also return None
  270. utt = segmenter.flush()
  271. assert utt is None
  272. def test_blip_discarded_with_realistic_pre_roll():
  273. """Regression: min_utterance_ms must count real speech, not pre-roll padding.
  274. With the default 400 ms pre-roll the segment is always at least 13 frames
  275. long, so a length-based check would never discard a blip. Only the actual
  276. number of speech frames should decide.
  277. """
  278. sample_rate = 16000
  279. frame_ms = 30
  280. frame_samples = round(sample_rate * frame_ms / 1000)
  281. fake_vad = FakeVad([False] * 15 + [True] * 3)
  282. segmenter = FrameSegmenter(
  283. sample_rate=sample_rate,
  284. frame_ms=frame_ms,
  285. vad=fake_vad,
  286. pre_roll_ms=400,
  287. speech_start_ms=90,
  288. silence_ms=700,
  289. min_utterance_ms=300,
  290. max_utterance_s=30.0,
  291. )
  292. for _ in range(15):
  293. assert segmenter.push(make_silent_frame(frame_samples)) is None
  294. for _ in range(3):
  295. assert segmenter.push(make_speech_frame(frame_samples)) is None
  296. utt = None
  297. for _ in range(40):
  298. utt = segmenter.push(make_silent_frame(frame_samples))
  299. if utt is not None or not segmenter.speaking:
  300. break
  301. assert utt is None, "90ms blip must be dropped even though pre-roll inflates the segment"
  302. def test_real_speech_kept_with_realistic_pre_roll():
  303. """Complement to the blip test: a real utterance with enough speech is kept."""
  304. sample_rate = 16000
  305. frame_ms = 30
  306. frame_samples = round(sample_rate * frame_ms / 1000)
  307. fake_vad = FakeVad([False] * 15 + [True] * 12)
  308. segmenter = FrameSegmenter(
  309. sample_rate=sample_rate,
  310. frame_ms=frame_ms,
  311. vad=fake_vad,
  312. pre_roll_ms=400,
  313. speech_start_ms=90,
  314. silence_ms=700,
  315. min_utterance_ms=300,
  316. max_utterance_s=30.0,
  317. )
  318. for _ in range(15):
  319. assert segmenter.push(make_silent_frame(frame_samples)) is None
  320. for _ in range(12):
  321. segmenter.push(make_speech_frame(frame_samples))
  322. utt = None
  323. for _ in range(40):
  324. utt = segmenter.push(make_silent_frame(frame_samples))
  325. if utt is not None:
  326. break
  327. assert utt is not None
  328. assert len(utt.audio) >= frame_samples * 12
  329. def _segmenter(vad, **overrides):
  330. params: dict = dict(
  331. sample_rate=16000,
  332. frame_ms=30,
  333. vad=vad,
  334. pre_roll_ms=60,
  335. speech_start_ms=30,
  336. silence_ms=600,
  337. min_utterance_ms=30,
  338. max_utterance_s=30.0,
  339. chunk_pause_ms=300,
  340. soft_max_s=1.0,
  341. rms_floor=0.0,
  342. trailing_trim_ms=200,
  343. )
  344. params.update(overrides)
  345. return FrameSegmenter(**params)
  346. def test_long_speech_ends_chunk_at_pause_after_soft_max():
  347. """A long utterance must split at a pause once soft_max_s is reached."""
  348. frame_samples = 480
  349. # 35 speech frames (> soft_max of 33) then a 10-frame (300ms) pause.
  350. seg = _segmenter(FakeVad([True] * 35 + [False] * 10))
  351. for _ in range(35):
  352. assert seg.push(make_speech_frame(frame_samples)) is None
  353. utt = None
  354. for _ in range(10):
  355. utt = seg.push(make_silent_frame(frame_samples))
  356. if utt is not None:
  357. break
  358. assert utt is not None, "chunk should end at the pause (silence_ms is 600, so not end-of-turn)"
  359. # 45 frames total; trailing 10 silence trimmed to round(200/30)=7 kept -> 3 dropped
  360. assert len(utt.audio) == (45 - 3) * frame_samples
  361. def test_short_pause_before_soft_max_does_not_split():
  362. """A pause shorter than the threshold must not split a short utterance."""
  363. frame_samples = 480
  364. # soft_max is 10s here, so 50 frames (1.5s) + a 300ms pause must NOT finalize.
  365. seg = _segmenter(FakeVad([True] * 50 + [False] * 10), soft_max_s=10.0)
  366. for _ in range(50):
  367. assert seg.push(make_speech_frame(frame_samples)) is None
  368. for _ in range(10):
  369. assert seg.push(make_silent_frame(frame_samples)) is None
  370. # Still speaking; flush emits the whole thing.
  371. assert seg.flush() is not None
  372. def test_quiet_segment_discarded_by_rms_floor():
  373. """Noise that trips the VAD but is too quiet should be dropped."""
  374. frame_samples = 480
  375. # High floor: the quiet utterance is dropped at finalization.
  376. strict = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60, rms_floor=0.5)
  377. for _ in range(5):
  378. strict.push(make_speech_frame(frame_samples, value=1000))
  379. strict.push(make_silent_frame(frame_samples))
  380. assert strict.push(make_silent_frame(frame_samples)) is None
  381. # Same audio with a permissive floor is kept -> proves the floor is the cause.
  382. lenient = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60, rms_floor=0.0)
  383. for _ in range(5):
  384. lenient.push(make_speech_frame(frame_samples, value=1000))
  385. lenient.push(make_silent_frame(frame_samples))
  386. assert lenient.push(make_silent_frame(frame_samples)) is not None
  387. def test_trailing_silence_trimmed():
  388. """Trailing silence is trimmed to the configured padding."""
  389. frame_samples = 480
  390. # 4 speech frames then 10 silence; silence_ms=600 keeps it speaking until flush.
  391. seg = _segmenter(FakeVad([True] * 4 + [False] * 10))
  392. for _ in range(4):
  393. seg.push(make_speech_frame(frame_samples))
  394. for _ in range(10):
  395. assert seg.push(make_silent_frame(frame_samples)) is None
  396. utt = seg.flush()
  397. assert utt is not None
  398. # 14 frames; trailing 10 silence trimmed to 7 kept -> 3 dropped
  399. assert len(utt.audio) == (14 - 3) * frame_samples
  400. def test_utterance_reports_rms_and_reason_for_each_end():
  401. """Each finalize path must label why it ended and report the audio level."""
  402. fs = 480
  403. # End of turn
  404. end = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60)
  405. for _ in range(5):
  406. end.push(make_speech_frame(fs, value=1000))
  407. end.push(make_silent_frame(fs))
  408. u_end = end.push(make_silent_frame(fs))
  409. assert u_end is not None
  410. assert u_end.reason == "end_of_turn"
  411. assert u_end.rms > 0.0
  412. # Pause-aligned chunk
  413. chunk = _segmenter(FakeVad([True] * 35 + [False] * 10))
  414. for _ in range(35):
  415. chunk.push(make_speech_frame(fs, value=1000))
  416. u_chunk = None
  417. for _ in range(10):
  418. u_chunk = chunk.push(make_silent_frame(fs))
  419. if u_chunk is not None:
  420. break
  421. assert u_chunk is not None
  422. assert u_chunk.reason == "pause"
  423. # Hard cap
  424. capped = _segmenter(FakeVad([True] * 40), max_utterance_s=0.3)
  425. u_cap = None
  426. for _ in range(40):
  427. u_cap = capped.push(make_speech_frame(fs, value=1000))
  428. if u_cap is not None:
  429. break
  430. assert u_cap is not None
  431. assert u_cap.reason == "hard_cap"
  432. # Flush on shutdown
  433. flushed = _segmenter(FakeVad([True] * 3))
  434. for _ in range(3):
  435. flushed.push(make_speech_frame(fs, value=1000))
  436. u_flush = flushed.flush()
  437. assert u_flush is not None
  438. assert u_flush.reason == "flush"