vocero-s2s/tests/test_speculative_turns.py
valenti b5f82fb48c
Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
first git
2026-08-26 11:30:14 +00:00

846 lines
29 KiB
Python

import time
from queue import Queue
from threading import Event, Thread
from typing import Literal
import numpy as np
import torch
from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent
from speech_to_speech.pipeline.messages import VADAudio
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
from speech_to_speech.VAD.vad_handler import VADHandler
def test_pending_reopen_defers_commit_until_cancelled():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
tracker.commit("turn_1", 0)
assert candidate_revision == 1
assert not tracker.is_committed("turn_1", 0)
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
tracker.commit("turn_1", 0)
assert tracker.is_committed("turn_1", 0)
def test_confirmed_reopen_makes_previous_revision_stale():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
assert not tracker.is_latest("turn_1", 0)
assert tracker.is_latest("turn_1", 1)
def test_tracker_prunes_old_turn_revisions():
tracker = SpeculativeTurnTracker(max_tracked_turns=2)
tracker.observe("turn_1", 0)
tracker.commit("turn_1", 0)
tracker.observe("turn_2", 0)
tracker.observe("turn_3", 0)
assert list(tracker._latest_revision) == ["turn_2", "turn_3"]
assert "turn_1" not in tracker._committed_revision
def test_tracker_keeps_pending_reopen_while_pruning():
tracker = SpeculativeTurnTracker(max_tracked_turns=1)
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
tracker.observe("turn_2", 0)
assert candidate_revision == 1
assert "turn_1" in tracker._latest_revision
assert "turn_1" in tracker._pending_reopen
assert "turn_2" in tracker._latest_revision
def test_pending_reopen_wait_timeout_clears_candidate():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
tracker.wait_for_pending_reopen("turn_1", 0, timeout_s=0)
assert candidate_revision == 1
assert tracker._pending_reopen == {}
def test_commit_if_latest_waits_for_pending_reopen_and_drops_confirmed_reopen():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
assert not tracker.commit_if_latest_after_pending_reopen("turn_1", 0)
assert not tracker.is_committed("turn_1", 0)
def test_commit_if_latest_commits_after_pending_reopen_is_cancelled():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
assert tracker.commit_if_latest_after_pending_reopen("turn_1", 0)
assert tracker.is_committed("turn_1", 0)
def test_try_is_latest_after_pending_reopen_reports_pending_without_blocking():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
assert tracker.has_pending_reopen("turn_1", 0)
assert tracker.try_is_latest_after_pending_reopen("turn_1", 0) is None
assert tracker.try_commit_if_latest_after_pending_reopen("turn_1", 0) is None
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
assert tracker.try_is_latest_after_pending_reopen("turn_1", 0) is True
assert tracker.try_commit_if_latest_after_pending_reopen("turn_1", 0) is True
assert tracker.is_committed("turn_1", 0)
def test_try_is_latest_after_reopen_grace_reports_pending_without_blocking():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
tracker.start_reopen_grace("turn_1", 0, grace_s=0.05)
assert tracker.try_is_latest_after_reopen_grace("turn_1", 0) is None
assert tracker.try_commit_if_latest_after_reopen_grace("turn_1", 0) is None
time.sleep(0.06)
assert tracker.try_is_latest_after_reopen_grace("turn_1", 0) is True
assert tracker.try_commit_if_latest_after_reopen_grace("turn_1", 0) is True
assert tracker.is_committed("turn_1", 0)
def test_reopen_grace_wait_drops_confirmed_reopen():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
tracker.start_reopen_grace("turn_1", 0, grace_s=0.2)
result: dict[str, bool] = {}
def wait_for_grace():
result["is_latest"] = tracker.is_latest_after_reopen_grace("turn_1", 0)
thread = Thread(target=wait_for_grace)
thread.start()
time.sleep(0.02)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
thread.join(timeout=1.0)
assert not thread.is_alive()
assert result == {"is_latest": False}
def test_is_latest_after_stability_window_catches_reopen_started_during_wait():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
def reopen_turn():
time.sleep(0.02)
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
thread = Thread(target=reopen_turn)
thread.start()
assert not tracker.is_latest_after_stability_window("turn_1", 0, settle_s=0.2)
thread.join(timeout=1.0)
def test_vad_direct_reopen_path_uses_tracker_candidate_protocol():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
handler = object.__new__(VADHandler)
handler.enable_realtime_transcription = True
handler._speech_started_emitted = False
handler._current_turn_id = "turn_1"
handler._current_turn_revision = 0
handler._last_final_audio_ms = 1000
handler.speculative_reopen_ms = 1200
handler.unanswered_reopen_ms = 1200
handler.speculative_turns = tracker
handler._pending_reopen_candidate = None
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
assert (turn_id, revision, reopened) == ("turn_1", 1, True)
assert not tracker.is_latest("turn_1", 0)
assert tracker.is_latest("turn_1", 1)
assert tracker._pending_reopen == {}
def test_vad_reopens_speculative_turn_when_live_transcription_disabled():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
handler = object.__new__(VADHandler)
handler.enable_realtime_transcription = False
handler._speech_started_emitted = False
handler._current_turn_id = "turn_1"
handler._current_turn_revision = 0
handler._last_final_audio_ms = 1000
handler.speculative_reopen_ms = 1200
handler.unanswered_reopen_ms = 1200
handler.speculative_turns = tracker
handler._pending_reopen_candidate = None
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
assert (turn_id, revision, reopened) == ("turn_1", 1, True)
assert not tracker.is_latest("turn_1", 0)
assert tracker.is_latest("turn_1", 1)
def test_vad_starts_new_turn_after_committed_turn_would_have_reopened():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 0)
tracker.commit("turn_1", 0)
handler = object.__new__(VADHandler)
handler.enable_realtime_transcription = False
handler._speech_started_emitted = False
handler._current_turn_id = "turn_1"
handler._current_turn_revision = 0
handler._turn_counter = 1
handler._last_final_audio_ms = 1000
handler.speculative_reopen_ms = 1200
handler.speculative_turns = tracker
handler._pending_reopen_candidate = None
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
assert (turn_id, revision, reopened) == ("turn_2", 0, False)
assert tracker.is_committed("turn_1", 0)
assert tracker.is_latest("turn_2", 0)
def test_vad_realtime_path_does_not_emit_progressive_when_live_transcription_disabled():
class FakeIterator:
buffer = [object()]
handler = object.__new__(VADHandler)
handler.enable_realtime_transcription = False
handler.iterator = FakeIterator()
assert list(handler._process_realtime(None)) == []
class _StaticVADIterator:
def __init__(
self,
*,
triggered: bool,
vad_output: list[torch.Tensor] | None,
buffer_chunks: list[torch.Tensor] | None = None,
speech_chunks: list[torch.Tensor] | None = None,
active_speech_samples: int = 0,
last_utterance_active_speech_samples: int = 0,
) -> None:
self.triggered = triggered
self._vad_output = vad_output
self.buffer = buffer_chunks or []
self._speech_chunks = speech_chunks or self.buffer
self.active_speech_samples = active_speech_samples
self.last_utterance_active_speech_samples = last_utterance_active_speech_samples
def __call__(self, _chunk: torch.Tensor) -> list[torch.Tensor] | None:
return self._vad_output
def speech_buffer(self) -> list[torch.Tensor]:
return self._speech_chunks
def _vad_handler_for_iterator(iterator: _StaticVADIterator) -> VADHandler:
handler = object.__new__(VADHandler)
handler.should_listen = Event()
handler.should_listen.set()
handler.sample_rate = 16000
handler.min_silence_ms = 300
handler.min_speech_ms = 384
handler.min_speech_continuation_ms = handler.min_speech_ms
handler.max_speech_ms = float("inf")
handler.enable_realtime_transcription = False
handler.realtime_processing_pause = 0.5
handler.text_output_queue = Queue()
handler.speculative_turns = SpeculativeTurnTracker()
handler.speculative_reopen_ms = 1000
handler.unanswered_reopen_ms = handler.speculative_reopen_ms
handler._last_turn_detection = None
handler.iterator = iterator
handler.audio_enhancement = False
handler.last_process_time = 0.0
handler._total_samples = 0
handler._last_log_time = time.time()
handler._log_chunks = 0
handler._log_speech_starts = 0
handler._log_speech_ends = 0
handler._log_progressive_yields = 0
handler._speech_started_emitted = False
handler._turn_counter = 0
handler._current_turn_id = None
handler._current_turn_revision = None
handler._speculative_audio_prefix = None
handler._last_final_wall_time = None
handler._last_final_audio_ms = None
handler._pending_reopen_candidate = None
handler.short_segment_merge_ms = 0
handler._pending_short_segment = None
return handler
def _audio_bytes(samples: int = 512) -> bytes:
return np.zeros(samples, dtype=np.int16).tobytes()
def _drain_text_events(handler: VADHandler) -> None:
while not handler.text_output_queue.empty():
handler.text_output_queue.get_nowait()
def test_vad_interruption_uses_active_speech_duration_not_padded_segment():
chunks = [torch.zeros(512) for _ in range(20)]
iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=chunks,
speech_chunks=chunks,
active_speech_samples=10 * 512,
)
handler = _vad_handler_for_iterator(iterator)
handler.enable_realtime_transcription = True
assert list(handler.process(_audio_bytes())) == []
assert handler.text_output_queue.empty()
assert handler._speech_started_emitted is False
def test_vad_pending_reopen_starts_before_active_speech_threshold():
chunks = [torch.zeros(512) for _ in range(12)]
iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=chunks,
speech_chunks=chunks,
active_speech_samples=8 * 512,
)
handler = _vad_handler_for_iterator(iterator)
tracker = handler.speculative_turns
tracker.observe("turn_1", 0)
handler._current_turn_id = "turn_1"
handler._current_turn_revision = 0
handler._last_final_audio_ms = 0
assert list(handler.process(_audio_bytes())) == []
assert tracker.has_pending_reopen("turn_1", 0)
tracker.commit("turn_1", 0)
assert not tracker.is_committed("turn_1", 0)
assert handler.text_output_queue.empty()
assert handler._speech_started_emitted is False
def test_vad_interruption_emits_after_active_speech_threshold():
chunks = [torch.zeros(512) for _ in range(20)]
iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=chunks,
speech_chunks=chunks,
active_speech_samples=12 * 512,
)
handler = _vad_handler_for_iterator(iterator)
assert list(handler.process(_audio_bytes())) == []
event = handler.text_output_queue.get_nowait()
assert isinstance(event, SpeechStartedEvent)
assert event.interrupt_response is True
assert handler._speech_started_emitted is True
def test_vad_live_transcription_without_speculative_turns_stops_listening_on_final():
final_chunks = [torch.zeros(512) for _ in range(31)]
iterator = _StaticVADIterator(
triggered=False,
vad_output=final_chunks,
last_utterance_active_speech_samples=12 * 512,
)
handler = _vad_handler_for_iterator(iterator)
handler.enable_realtime_transcription = True
handler.speculative_turns = None
outputs = list(handler.process(_audio_bytes()))
assert len(outputs) == 1
assert not handler.should_listen.is_set()
def test_vad_discards_final_segment_when_active_speech_is_short():
final_chunks = [torch.zeros(512) for _ in range(31)]
iterator = _StaticVADIterator(
triggered=False,
vad_output=final_chunks,
last_utterance_active_speech_samples=11 * 512,
)
handler = _vad_handler_for_iterator(iterator)
outputs = list(handler.process(_audio_bytes()))
assert outputs == []
assert handler.text_output_queue.empty()
def _drive_final_segment(handler: VADHandler, active_chunks: int = 12, segment_chunks: int = 31) -> list:
handler.iterator = _StaticVADIterator(
triggered=False,
vad_output=[torch.zeros(512) for _ in range(segment_chunks)],
last_utterance_active_speech_samples=active_chunks * 512,
)
return list(handler.process(_audio_bytes()))
def _handler_after_soft_ended_turn() -> VADHandler:
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
outputs = _drive_final_segment(handler, active_chunks=12, segment_chunks=12)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
_drain_text_events(handler)
return handler
def test_continuation_start_confirms_at_lower_bar():
handler = _handler_after_soft_ended_turn()
handler.min_speech_continuation_ms = 192
chunks = [torch.zeros(512) for _ in range(8)]
handler.iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=chunks,
speech_chunks=chunks,
active_speech_samples=8 * 512,
)
assert list(handler.process(_audio_bytes())) == []
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
assert handler._speech_started_emitted is True
def test_trailing_continuation_fragment_accepted_at_finalization():
handler = _handler_after_soft_ended_turn()
handler.min_speech_continuation_ms = 192
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
def test_continuation_bar_inactive_when_turn_committed():
handler = _handler_after_soft_ended_turn()
handler.min_speech_continuation_ms = 192
tracker = handler.speculative_turns
tracker.commit("turn_1", 0)
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
assert outputs == []
assert handler.text_output_queue.empty()
assert handler._current_turn_id == "turn_1"
assert handler._current_turn_revision == 0
assert tracker.is_committed("turn_1", 0)
def test_entry_bar_unchanged_for_new_speech():
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
handler.min_speech_continuation_ms = 192
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
assert outputs == []
assert handler.text_output_queue.empty()
assert handler._current_turn_id is None
assert handler._turn_counter == 0
def test_confirmed_segment_not_discarded_at_finalization():
handler = _handler_after_soft_ended_turn()
handler.min_speech_continuation_ms = 192
chunks = [torch.zeros(512) for _ in range(8)]
handler.iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=chunks,
speech_chunks=chunks,
active_speech_samples=8 * 512,
)
assert list(handler.process(_audio_bytes())) == []
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
handler.iterator = _StaticVADIterator(
triggered=False,
vad_output=chunks,
last_utterance_active_speech_samples=8 * 512,
)
outputs = list(handler.process(_audio_bytes()))
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
def test_continuation_threshold_clamping():
assert VADHandler._resolve_min_speech_continuation_ms(384, 0) == 384
assert VADHandler._resolve_min_speech_continuation_ms(384, 50) == 100
assert VADHandler._resolve_min_speech_continuation_ms(384, 500) == 384
assert VADHandler._resolve_min_speech_continuation_ms(384, 192) == 192
def test_vad_reopens_unanswered_turn_after_grace_window():
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
handler.unanswered_reopen_ms = 8000
tracker = handler.speculative_turns
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
assert handler._last_final_audio_ms is not None
while not handler.text_output_queue.empty():
handler.text_output_queue.get_nowait()
# Advance the audio clock so the resumed speech starts well past
# speculative_reopen_ms (1000) but within unanswered_reopen_ms (8000).
handler._total_samples = 16000 * 3
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
assert not tracker.is_latest("turn_1", 0)
def test_vad_does_not_reopen_committed_turn():
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
handler.unanswered_reopen_ms = 8000
tracker = handler.speculative_turns
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
while not handler.text_output_queue.empty():
handler.text_output_queue.get_nowait()
tracker.commit("turn_1", 0)
handler._total_samples = 16000 * 3
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_2", 0)
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert started.reopened is False
def test_vad_new_turn_after_unanswered_cap():
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
handler.unanswered_reopen_ms = 8000
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
while not handler.text_output_queue.empty():
handler.text_output_queue.get_nowait()
# Advance the audio clock so the resumed speech starts past the cap.
handler._total_samples = 16000 * 12
outputs = _drive_final_segment(handler)
assert len(outputs) == 1
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_2", 0)
started = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert started.reopened is False
def test_vad_does_not_hold_sub_floor_fragments():
handler = _vad_handler_for_iterator(
_StaticVADIterator(
triggered=False,
vad_output=[torch.zeros(512)],
last_utterance_active_speech_samples=512,
)
)
handler.short_segment_merge_ms = 384
outputs = list(handler.process(_audio_bytes()))
assert outputs == []
assert handler._pending_short_segment is None
assert handler.text_output_queue.empty()
def test_vad_stitches_adjacent_short_segments_before_discarding():
first_chunks = [torch.zeros(512) for _ in range(7)]
second_chunks = [torch.zeros(512) for _ in range(8)]
handler = _vad_handler_for_iterator(
_StaticVADIterator(
triggered=False,
vad_output=first_chunks,
last_utterance_active_speech_samples=4 * 512,
)
)
handler.short_segment_merge_ms = 384
assert list(handler.process(_audio_bytes())) == []
assert handler.text_output_queue.empty()
assert handler._pending_short_segment is not None
handler.iterator = _StaticVADIterator(
triggered=False,
vad_output=second_chunks,
last_utterance_active_speech_samples=8 * 512,
)
outputs = list(handler.process(_audio_bytes()))
assert len(outputs) == 1
assert len(outputs[0].audio) == 15 * 512
started = handler.text_output_queue.get_nowait()
stopped = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert started.interrupt_response is False
assert isinstance(stopped, SpeechStoppedEvent)
assert handler._pending_short_segment is None
def test_vad_pending_short_segment_contributes_to_early_speech_start():
first_chunks = [torch.zeros(512) for _ in range(7)]
current_chunks = [torch.zeros(512) for _ in range(8)]
handler = _vad_handler_for_iterator(
_StaticVADIterator(
triggered=False,
vad_output=first_chunks,
last_utterance_active_speech_samples=4 * 512,
)
)
handler.short_segment_merge_ms = 384
assert list(handler.process(_audio_bytes())) == []
handler.iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=current_chunks,
speech_chunks=current_chunks,
active_speech_samples=8 * 512,
)
assert list(handler.process(_audio_bytes())) == []
event = handler.text_output_queue.get_nowait()
assert isinstance(event, SpeechStartedEvent)
assert event.interrupt_response is True
assert handler._speech_started_emitted is True
def test_vad_pending_short_segment_does_not_start_on_sub_floor_current_fragment():
first_chunks = [torch.zeros(512) for _ in range(10)]
current_chunks = [torch.zeros(512) for _ in range(3)]
handler = _vad_handler_for_iterator(
_StaticVADIterator(
triggered=False,
vad_output=first_chunks,
last_utterance_active_speech_samples=9 * 512,
)
)
handler.short_segment_merge_ms = 384
assert list(handler.process(_audio_bytes())) == []
assert handler._pending_short_segment is not None
# Pending holds 288ms active speech; the live fragment has only 96ms,
# below the noise floor, so the combined 384ms must not start speech.
handler.iterator = _StaticVADIterator(
triggered=True,
vad_output=None,
buffer_chunks=current_chunks,
speech_chunks=current_chunks,
active_speech_samples=3 * 512,
)
assert list(handler.process(_audio_bytes())) == []
assert handler.text_output_queue.empty()
assert handler._speech_started_emitted is False
def test_vad_stitching_preserves_silence_gap_between_segments():
first_chunks = [torch.zeros(512) for _ in range(7)]
second_chunks = [torch.zeros(512) for _ in range(8)]
handler = _vad_handler_for_iterator(
_StaticVADIterator(
triggered=False,
vad_output=first_chunks,
last_utterance_active_speech_samples=4 * 512,
)
)
handler.short_segment_merge_ms = 384
assert list(handler.process(_audio_bytes())) == []
assert handler._pending_short_segment is not None
assert handler._pending_short_segment.end_ms == 32
# Advance the audio clock so the second segment starts 32ms after the
# pending one ends; the stitched audio must include that silent gap.
handler._total_samples = 9 * 512
handler.iterator = _StaticVADIterator(
triggered=False,
vad_output=second_chunks,
last_utterance_active_speech_samples=8 * 512,
)
outputs = list(handler.process(_audio_bytes()))
assert len(outputs) == 1
assert len(outputs[0].audio) == 16 * 512
def test_vad_final_synthetic_start_does_not_interrupt_response():
final_chunks = [torch.zeros(512) for _ in range(31)]
iterator = _StaticVADIterator(
triggered=False,
vad_output=final_chunks,
last_utterance_active_speech_samples=12 * 512,
)
handler = _vad_handler_for_iterator(iterator)
outputs = list(handler.process(_audio_bytes()))
assert len(outputs) == 1
started = handler.text_output_queue.get_nowait()
stopped = handler.text_output_queue.get_nowait()
assert isinstance(started, SpeechStartedEvent)
assert started.interrupt_response is False
assert isinstance(stopped, SpeechStoppedEvent)
def test_vad_keeps_single_speculative_audio_prefix():
handler = object.__new__(VADHandler)
handler._speculative_audio_prefix = None
first_segment = np.array([1.0, 2.0], dtype=np.float32)
second_segment = np.array([3.0], dtype=np.float32)
third_segment = np.array([4.0], dtype=np.float32)
first_output = handler._combined_turn_audio(first_segment)
handler._speculative_audio_prefix = first_output
second_output = handler._combined_turn_audio(second_segment)
handler._speculative_audio_prefix = second_output
third_output = handler._combined_turn_audio(third_segment)
assert first_output is first_segment
np.testing.assert_array_equal(second_output, np.array([1.0, 2.0, 3.0], dtype=np.float32))
np.testing.assert_array_equal(third_output, np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
def _vad_audio(
turn_id: str = "turn_1",
revision: int = 0,
mode: Literal["progressive", "final"] | None = "progressive",
) -> VADAudio:
return VADAudio(audio=np.zeros(512, dtype=np.float32), mode=mode, turn_id=turn_id, turn_revision=revision)
def test_vad_drops_superseded_progressive_audio_from_output_queue():
handler = object.__new__(VADHandler)
handler.queue_out = Queue()
handler.speculative_turns = None
first_progressive = _vad_audio()
final_audio = _vad_audio(mode="final")
second_progressive = _vad_audio()
other_turn_progressive = _vad_audio(turn_id="turn_2")
handler.queue_out.put(first_progressive)
handler.queue_out.put(final_audio)
handler.queue_out.put(second_progressive)
handler.queue_out.put(other_turn_progressive)
dropped = handler._drop_superseded_vad_audio(_vad_audio())
assert dropped == 2
queued_items = list(handler.queue_out.queue)
assert queued_items == [final_audio, other_turn_progressive]
def test_vad_drops_stale_progressive_revisions_from_output_queue():
tracker = SpeculativeTurnTracker()
tracker.observe("turn_1", 1)
handler = object.__new__(VADHandler)
handler.queue_out = Queue()
handler.speculative_turns = tracker
stale_progressive = _vad_audio(revision=0)
current_progressive = _vad_audio(revision=1)
handler.queue_out.put(stale_progressive)
handler.queue_out.put(current_progressive)
handler.before_emit_output(_vad_audio(revision=1))
assert handler.queue_out.empty()
def test_vad_final_audio_replaces_queued_progressive_audio_for_same_revision():
handler = object.__new__(VADHandler)
handler.queue_out = Queue()
handler.speculative_turns = None
progressive_audio = _vad_audio()
final_audio = _vad_audio(mode="final")
other_turn_progressive = _vad_audio(turn_id="turn_2")
handler.queue_out.put(progressive_audio)
handler.queue_out.put(other_turn_progressive)
handler.before_emit_output(final_audio)
queued_items = list(handler.queue_out.queue)
assert queued_items == [other_turn_progressive]
def test_vad_progressive_processing_pause_increases_with_speech_duration():
handler = object.__new__(VADHandler)
handler.realtime_processing_pause = 0.25
assert handler._progressive_processing_pause(7_999) == 0.25
assert handler._progressive_processing_pause(8_000) == 0.5
assert handler._progressive_processing_pause(15_000) == 1.0
assert handler._progressive_processing_pause(30_000) == 1.5
def test_vad_progressive_processing_pause_is_capped():
handler = object.__new__(VADHandler)
handler.realtime_processing_pause = 0.5
assert handler._progressive_processing_pause(30_000) == 2.0