| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- """Tests for the VAD segmenter with pre-roll buffer."""
- import numpy as np
- import pytest
- import webrtcvad
- from stt_daemon.segmenter import FrameSegmenter, make_vad
- class FakeVad:
- """A VAD for testing: returns scripted speech/non-speech decisions."""
- def __init__(self, decisions: list[bool]):
- """Initialize with a sequence of boolean decisions.
- Args:
- decisions: List of True (speech) / False (silence) per frame.
- """
- self.decisions = decisions
- self.frame_index = 0
- def is_speech(self, frame: bytes, sample_rate: int) -> bool:
- """Return the next scripted decision."""
- if self.frame_index >= len(self.decisions):
- # If we've exhausted decisions, default to False
- return False
- result = self.decisions[self.frame_index]
- self.frame_index += 1
- return result
- def make_frame(samples: np.ndarray) -> bytes:
- """Convert samples to int16 PCM bytes."""
- assert samples.dtype == np.int16
- return samples.tobytes()
- def make_silent_frame(frame_samples: int) -> bytes:
- """Create a silent frame (zeros)."""
- return make_frame(np.zeros(frame_samples, dtype=np.int16))
- def make_speech_frame(frame_samples: int, value: int = 1000) -> bytes:
- """Create a frame with some amplitude."""
- return make_frame(np.full(frame_samples, value, dtype=np.int16))
- def test_make_vad_invalid_aggressiveness():
- """Test that make_vad validates aggressiveness."""
- with pytest.raises(ValueError):
- make_vad(4)
- with pytest.raises(ValueError):
- make_vad(-1)
- def test_make_vad_valid():
- """Test that make_vad accepts 0-3."""
- for aggr in (0, 1, 2, 3):
- vad = make_vad(aggr)
- assert isinstance(vad, webrtcvad.Vad)
- def test_preroll_preserved():
- """Test that pre-roll buffer content is included in the emitted utterance.
- Pre-roll frames are kept in a ring buffer before speech is confirmed.
- When speech is confirmed, those pre-roll frames should be at the start
- of the emitted utterance, ensuring the first phoneme is not cut off.
- """
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- # Pre-roll frames: 2 silent frames (60ms silence before speech)
- # Then: 3 speech frames to confirm speech (90ms to trigger confirmation)
- # Then: 1 more speech frame + 2 silence frames to finalize
- fake_vad = FakeVad(
- [
- False, # Frame 0: pre-roll silence
- False, # Frame 1: pre-roll silence
- True, # Frame 2: speech (count=1)
- True, # Frame 3: speech (count=2)
- True, # Frame 4: speech (count=3, confirmed)
- True, # Frame 5: speech (during speaking)
- False, # Frame 6: silence (count=1)
- False, # Frame 7: silence (count=2, finalize)
- ]
- )
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=150, # 5 frames (enough for 2 silence + 3 speech to not lose frame 0)
- speech_start_ms=90, # 3 frames needed
- silence_ms=60, # 2 frames to finalize
- min_utterance_ms=30,
- max_utterance_s=30,
- )
- pre_roll_frame_0 = make_silent_frame(frame_samples)
- pre_roll_frame_1 = make_silent_frame(frame_samples)
- speech_frame_2 = make_speech_frame(frame_samples)
- speech_frame_3 = make_speech_frame(frame_samples)
- speech_frame_4 = make_speech_frame(frame_samples)
- speech_frame_5 = make_speech_frame(frame_samples)
- silence_frame_6 = make_silent_frame(frame_samples)
- silence_frame_7 = make_silent_frame(frame_samples)
- # Push pre-roll frames (should be buffered, not emitted)
- assert segmenter.push(pre_roll_frame_0) is None
- assert segmenter.push(pre_roll_frame_1) is None
- # Push speech frames until confirmation (frame 4 confirms)
- assert segmenter.push(speech_frame_2) is None
- assert segmenter.push(speech_frame_3) is None
- utt = segmenter.push(speech_frame_4)
- assert utt is None # _begin() returns None, not the utterance yet
- # Continue with more frames
- assert segmenter.push(speech_frame_5) is None
- assert segmenter.push(silence_frame_6) is None
- # Final silence triggers finalization
- utt = segmenter.push(silence_frame_7)
- assert utt is not None, "Utterance should be emitted after silence"
- # Verify that the utterance contains the pre-roll frames and speech frames
- # Expected: pre_roll_0, pre_roll_1, speech_2-5, silence_6-7
- # (silence frames kept as tail when finalizing with keep_tail=True)
- expected_sample_count = frame_samples * 8
- actual_sample_count = len(utt.audio)
- assert actual_sample_count == expected_sample_count, (
- f"Expected {expected_sample_count} samples (8 frames), "
- f"got {actual_sample_count}"
- )
- # Verify the audio starts with zeros (the pre-roll silence frames 0-1)
- pre_roll_audio = utt.audio[:2 * frame_samples]
- assert np.allclose(pre_roll_audio, 0, atol=0.01), (
- "Pre-roll should be silent at the start (first 2 frames)"
- )
- # Verify speech frames come next (non-zero)
- speech_audio = utt.audio[2 * frame_samples : 6 * frame_samples]
- assert not np.allclose(speech_audio, 0, atol=0.01), (
- "Speech frames should be non-silent"
- )
- def test_silence_finalization():
- """Test that silence_frames consecutive frames trigger finalization."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad(
- [
- True, # Start of speech
- True,
- True,
- True, # 4 speech frames
- False, # Silence count=1
- False, # Silence count=2
- False, # Silence count=3 (finalizes if silence_ms=90)
- ]
- )
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=60, # 2 frames
- speech_start_ms=30, # 1 frame to confirm
- silence_ms=60, # 2 frames to finalize
- min_utterance_ms=30,
- max_utterance_s=30,
- )
- # Push 1 speech frame to trigger confirmation
- utt = segmenter.push(make_speech_frame(frame_samples))
- assert utt is None
- # Push 3 more speech frames
- for _ in range(3):
- assert segmenter.push(make_speech_frame(frame_samples)) is None
- # Push 1 silence frame (count=1)
- assert segmenter.push(make_silent_frame(frame_samples)) is None
- # Push 2nd silence frame (count=2, finalizes)
- utt = segmenter.push(make_silent_frame(frame_samples))
- assert utt is not None
- # At finalization with 2 trailing silence frames, we keep those as tail.
- # Segment progression:
- # - After frame 0 (speech confirm): segment = [frame_0]
- # - After frames 1-3: segment = [0, 1, 2, 3]
- # - After frame 4 (1st silence): segment = [0, 1, 2, 3, 4], silence_run=1
- # - After frame 5 (2nd silence): segment = [0, 1, 2, 3, 4, 5], silence_run=2
- # Finalize: emit 6 frames, keep tail [4, 5] for next pre-roll
- assert len(utt.audio) == frame_samples * 6
- def test_min_utterance_discarded():
- """Test that utterances shorter than min_utterance_ms are discarded."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad(
- [
- True, # 1 speech frame (30ms, less than min 300ms)
- False, # Silence to finalize
- False,
- ]
- )
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=0,
- speech_start_ms=30, # 1 frame
- silence_ms=60, # 2 frames
- min_utterance_ms=300, # Only keep if >= 300ms (10 frames at 30ms)
- max_utterance_s=30,
- )
- # Confirm speech with 1 frame
- segmenter.push(make_speech_frame(frame_samples))
- # Finalize with 2 silence frames
- segmenter.push(make_silent_frame(frame_samples))
- utt = segmenter.push(make_silent_frame(frame_samples))
- assert utt is None, "Short utterance should be discarded"
- def test_max_utterance_forces_finalization():
- """Test that max_utterance_s force-finalizes long continuous speech."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- max_utterance_s = 0.3 # 300ms, ~10 frames
- # Create a long sequence of speech with no silence
- num_frames = 15
- fake_vad = FakeVad([True] * num_frames)
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=0,
- speech_start_ms=30, # 1 frame to confirm
- silence_ms=60,
- min_utterance_ms=30,
- max_utterance_s=max_utterance_s,
- )
- # Confirm speech
- segmenter.push(make_speech_frame(frame_samples))
- # Push more frames; should finalize when max is hit
- utt = None
- for i in range(1, num_frames):
- utt = segmenter.push(make_speech_frame(frame_samples))
- if utt is not None:
- break
- assert utt is not None, "Max duration should trigger finalization"
- # Should have finalized at ~max_utterance_s (a bit flexible due to frame alignment)
- assert len(utt.audio) >= frame_samples * 10
- def test_flush_finalizes_inprogress():
- """Test that flush() finalizes an in-progress utterance."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad([False, False, True, True])
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=60, # 2 frames
- speech_start_ms=60, # 2 frames
- silence_ms=300,
- min_utterance_ms=30,
- max_utterance_s=30,
- )
- # Pre-roll frames (silence)
- segmenter.push(make_silent_frame(frame_samples))
- segmenter.push(make_silent_frame(frame_samples))
- # Confirm speech (need 2 consecutive)
- segmenter.push(make_speech_frame(frame_samples))
- segmenter.push(make_speech_frame(frame_samples))
- # Now call flush without sending silence
- utt = segmenter.flush()
- assert utt is not None
- # Ring at confirmation has the 2 speech frames, so segment = [speech_0, speech_1]
- assert len(utt.audio) == frame_samples * 2 # The 2 speech frames
- def test_no_utterance_if_never_speaks():
- """Test that a stream of only silence emits no utterance."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad([False, False, False, False])
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=60,
- speech_start_ms=60,
- silence_ms=60,
- min_utterance_ms=30,
- max_utterance_s=30,
- )
- # Push only silence
- for _ in range(4):
- utt = segmenter.push(make_silent_frame(frame_samples))
- assert utt is None
- # Flush should also return None
- utt = segmenter.flush()
- assert utt is None
- def test_blip_discarded_with_realistic_pre_roll():
- """Regression: min_utterance_ms must count real speech, not pre-roll padding.
- With the default 400 ms pre-roll the segment is always at least 13 frames
- long, so a length-based check would never discard a blip. Only the actual
- number of speech frames should decide.
- """
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad([False] * 15 + [True] * 3)
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=400,
- speech_start_ms=90,
- silence_ms=700,
- min_utterance_ms=300,
- max_utterance_s=30.0,
- )
- for _ in range(15):
- assert segmenter.push(make_silent_frame(frame_samples)) is None
- for _ in range(3):
- assert segmenter.push(make_speech_frame(frame_samples)) is None
- utt = None
- for _ in range(40):
- utt = segmenter.push(make_silent_frame(frame_samples))
- if utt is not None or not segmenter.speaking:
- break
- assert utt is None, "90ms blip must be dropped even though pre-roll inflates the segment"
- def test_real_speech_kept_with_realistic_pre_roll():
- """Complement to the blip test: a real utterance with enough speech is kept."""
- sample_rate = 16000
- frame_ms = 30
- frame_samples = round(sample_rate * frame_ms / 1000)
- fake_vad = FakeVad([False] * 15 + [True] * 12)
- segmenter = FrameSegmenter(
- sample_rate=sample_rate,
- frame_ms=frame_ms,
- vad=fake_vad,
- pre_roll_ms=400,
- speech_start_ms=90,
- silence_ms=700,
- min_utterance_ms=300,
- max_utterance_s=30.0,
- )
- for _ in range(15):
- assert segmenter.push(make_silent_frame(frame_samples)) is None
- for _ in range(12):
- segmenter.push(make_speech_frame(frame_samples))
- utt = None
- for _ in range(40):
- utt = segmenter.push(make_silent_frame(frame_samples))
- if utt is not None:
- break
- assert utt is not None
- assert len(utt.audio) >= frame_samples * 12
- def _segmenter(vad, **overrides):
- params: dict = dict(
- sample_rate=16000,
- frame_ms=30,
- vad=vad,
- pre_roll_ms=60,
- speech_start_ms=30,
- silence_ms=600,
- min_utterance_ms=30,
- max_utterance_s=30.0,
- chunk_pause_ms=300,
- soft_max_s=1.0,
- rms_floor=0.0,
- trailing_trim_ms=200,
- )
- params.update(overrides)
- return FrameSegmenter(**params)
- def test_long_speech_ends_chunk_at_pause_after_soft_max():
- """A long utterance must split at a pause once soft_max_s is reached."""
- frame_samples = 480
- # 35 speech frames (> soft_max of 33) then a 10-frame (300ms) pause.
- seg = _segmenter(FakeVad([True] * 35 + [False] * 10))
- for _ in range(35):
- assert seg.push(make_speech_frame(frame_samples)) is None
- utt = None
- for _ in range(10):
- utt = seg.push(make_silent_frame(frame_samples))
- if utt is not None:
- break
- assert utt is not None, "chunk should end at the pause (silence_ms is 600, so not end-of-turn)"
- # 45 frames total; trailing 10 silence trimmed to round(200/30)=7 kept -> 3 dropped
- assert len(utt.audio) == (45 - 3) * frame_samples
- def test_short_pause_before_soft_max_does_not_split():
- """A pause shorter than the threshold must not split a short utterance."""
- frame_samples = 480
- # soft_max is 10s here, so 50 frames (1.5s) + a 300ms pause must NOT finalize.
- seg = _segmenter(FakeVad([True] * 50 + [False] * 10), soft_max_s=10.0)
- for _ in range(50):
- assert seg.push(make_speech_frame(frame_samples)) is None
- for _ in range(10):
- assert seg.push(make_silent_frame(frame_samples)) is None
- # Still speaking; flush emits the whole thing.
- assert seg.flush() is not None
- def test_quiet_segment_discarded_by_rms_floor():
- """Noise that trips the VAD but is too quiet should be dropped."""
- frame_samples = 480
- # High floor: the quiet utterance is dropped at finalization.
- strict = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60, rms_floor=0.5)
- for _ in range(5):
- strict.push(make_speech_frame(frame_samples, value=1000))
- strict.push(make_silent_frame(frame_samples))
- assert strict.push(make_silent_frame(frame_samples)) is None
- # Same audio with a permissive floor is kept -> proves the floor is the cause.
- lenient = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60, rms_floor=0.0)
- for _ in range(5):
- lenient.push(make_speech_frame(frame_samples, value=1000))
- lenient.push(make_silent_frame(frame_samples))
- assert lenient.push(make_silent_frame(frame_samples)) is not None
- def test_trailing_silence_trimmed():
- """Trailing silence is trimmed to the configured padding."""
- frame_samples = 480
- # 4 speech frames then 10 silence; silence_ms=600 keeps it speaking until flush.
- seg = _segmenter(FakeVad([True] * 4 + [False] * 10))
- for _ in range(4):
- seg.push(make_speech_frame(frame_samples))
- for _ in range(10):
- assert seg.push(make_silent_frame(frame_samples)) is None
- utt = seg.flush()
- assert utt is not None
- # 14 frames; trailing 10 silence trimmed to 7 kept -> 3 dropped
- assert len(utt.audio) == (14 - 3) * frame_samples
- def test_utterance_reports_rms_and_reason_for_each_end():
- """Each finalize path must label why it ended and report the audio level."""
- fs = 480
- # End of turn
- end = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60)
- for _ in range(5):
- end.push(make_speech_frame(fs, value=1000))
- end.push(make_silent_frame(fs))
- u_end = end.push(make_silent_frame(fs))
- assert u_end is not None
- assert u_end.reason == "end_of_turn"
- assert u_end.rms > 0.0
- # Pause-aligned chunk
- chunk = _segmenter(FakeVad([True] * 35 + [False] * 10))
- for _ in range(35):
- chunk.push(make_speech_frame(fs, value=1000))
- u_chunk = None
- for _ in range(10):
- u_chunk = chunk.push(make_silent_frame(fs))
- if u_chunk is not None:
- break
- assert u_chunk is not None
- assert u_chunk.reason == "pause"
- # Hard cap
- capped = _segmenter(FakeVad([True] * 40), max_utterance_s=0.3)
- u_cap = None
- for _ in range(40):
- u_cap = capped.push(make_speech_frame(fs, value=1000))
- if u_cap is not None:
- break
- assert u_cap is not None
- assert u_cap.reason == "hard_cap"
- # Flush on shutdown
- flushed = _segmenter(FakeVad([True] * 3))
- for _ in range(3):
- flushed.push(make_speech_frame(fs, value=1000))
- u_flush = flushed.flush()
- assert u_flush is not None
- assert u_flush.reason == "flush"
|