Files
DPM/backend/vendor/s2s-cloud/tests/test_speculative_turns.py
T

846 lines
29 KiB
Python
Raw Normal View History

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