Files
livekit--agents/tests/test_audio_recognition_turn_detection.py
2026-07-13 13:39:38 +08:00

675 lines
28 KiB
Python

"""Integration tests for ``AudioRecognition`` audio turn-detection wiring.
Recognition owns all streaming turn-detection policy: it holds the in-flight
inference request's future (``_turn_detector_prediction_fut``), starts
requests on VAD events only, awaits the future with the model-specific
``prediction_timeout`` in the eou bounce, and flushes the stream on turn
commits.
Covered here:
1. Resumed speech during the endpointing window: a ``START_OF_SPEECH`` mid-bounce
cancels the in-flight eou task so the prior turn doesn't ship, while a
sub-``min_speech_duration`` VAD spike (no SOS/EOS) must not block the next commit.
2. ``on_eot_prediction`` dedup across the vad-EOS and stt-final triggers that
share one resolved prediction future, and the ``update_turn_detector``
swap wiring.
3. The prediction-future lifecycle against VAD events: requests start
exclusively on the silence tick, resumed speech inside a still-open VAD
segment rearms the next pause, SOS teardown, the flushed-turn short-circuit
for late stt finals, and the predict-timeout fallback signal.
The stream-side request lifecycle lives in ``test_turn_detection_fsm.py``.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from livekit.agents import vad
from livekit.agents.utils import aio
from livekit.agents.voice.audio_recognition import AudioRecognition
from livekit.agents.voice.turn import (
TurnDetectionEvent,
_StreamingTurnDetector,
_StreamingTurnDetectorStream,
)
pytestmark = pytest.mark.audio_eot
# ---------------------------------------------------------------------------
# Resumed-speech handling during the endpointing window
# ---------------------------------------------------------------------------
def _make_full_recognition_for_eou() -> AudioRecognition:
"""Wire enough of AudioRecognition to drive `_run_eou_detection` against
a fake audio turn-detector — used by the speaking-guard tests."""
ar = AudioRecognition.__new__(AudioRecognition)
ar._session = MagicMock()
ar._hooks = MagicMock()
ar._hooks.on_end_of_turn.return_value = False # don't commit
ar._stt = None
ar._audio_transcript = ""
ar._turn_detection_mode = "vad"
# turn_detector must be an _StreamingTurnDetector for the speaking-guard
# variant to be chosen.
ar._turn_detector = MagicMock(spec=_StreamingTurnDetector)
# spec= on _StreamingTurnDetectorStream so the runtime_checkable isinstance
# narrowing in audio_recognition's streaming branch sees the mock as the
# streaming flavor.
stream_mock = MagicMock(spec=_StreamingTurnDetectorStream)
stream_mock.supports_language = AsyncMock(return_value=True)
stream_mock.unlikely_threshold = AsyncMock(return_value=0.5)
# backchannel disabled by default (server sent no thresholds); the
# backchannel-emit tests override this with a positive threshold.
stream_mock.backchannel_threshold = AsyncMock(return_value=None)
# each call hands out a fresh pending future, mirroring the real
# predict; tests install resolved/pending futures directly on
# ar._turn_detector_prediction_fut to model cached/awaiting predictions
stream_mock.predict = MagicMock(side_effect=asyncio.Future)
stream_mock.flush = MagicMock()
stream_mock.cancel_inference = MagicMock()
stream_mock.prediction_timeout = 0.01
ar._turn_detector_stream = stream_mock
ar._turn_detector_prediction_fut = None
ar._turn_detector_flushed = False
ar._turn_detector_late_prediction_warned = False
ar._agent_speaking = False
ar._interruption_enabled = False
ar._interruption_ch = None
ar._vad_base_turn_detection = False
endpointing = MagicMock()
endpointing.min_delay = 0.01
endpointing.max_delay = 0.5 # long enough for the guard to fire mid-sleep
ar._endpointing = endpointing
ar._ensure_user_turn_span = MagicMock( # type: ignore[method-assign]
return_value=MagicMock(is_recording=MagicMock(return_value=False))
)
ar._user_turn_span = None
ar._user_turn_start = None
ar._user_silence_ev = asyncio.Event()
ar._speaking = False
ar._final_transcript_confidence = []
ar._stt_request_ids = []
ar._last_speaking_time = None
ar._last_final_transcript_time = None
ar._speech_start_time = None
ar._vad_speech_started = False
ar._end_of_turn_task = None
ar._user_turn_committed = False
ar._vad = None
ar._last_language = None
ar._last_emitted_prediction = None
ar._closing = asyncio.Event()
return ar
def _make_chat_ctx_stub() -> MagicMock:
"""ChatContext stub that survives the `.copy()` + `.add_message` +
`.items[-N:]` calls inside `_run_eou_detection`."""
ctx = MagicMock()
ctx.copy = MagicMock(return_value=ctx)
ctx.add_message = MagicMock()
ctx.items = []
return ctx
def _resolved_prediction(
probability: float,
*,
inference_duration: float | None = None,
detection_delay: float | None = None,
backchannel_probability: float | None = None,
) -> tuple[asyncio.Future[TurnDetectionEvent], TurnDetectionEvent]:
"""A resolved prediction future, as if the transport already answered."""
event = TurnDetectionEvent(
type="eot_prediction",
last_speaking_time=time.time(),
end_of_turn_probability=probability,
inference_duration=inference_duration,
detection_delay=detection_delay,
backchannel_probability=backchannel_probability,
)
fut: asyncio.Future[TurnDetectionEvent] = asyncio.Future()
fut.set_result(event)
return fut, event
class TestResumedSpeechAbortsCommit:
async def test_sos_during_bounce_cancels_commit(self) -> None:
"""Regression: a VAD ``START_OF_SPEECH`` during the endpointing-delay
window cancels the in-flight bounce so the prior turn doesn't commit.
The prior speaking-guard race was replaced by this SOS teardown."""
ar = _make_full_recognition_for_eou()
chat_ctx = _make_chat_ctx_stub()
# sub-threshold prediction (0.2 < 0.5) extends endpointing to max_delay
ar._turn_detector_prediction_fut, _ = _resolved_prediction(0.2)
ar._run_eou_detection(chat_ctx, trigger="vad")
task = ar._end_of_turn_task
assert task is not None
# The bounce is parked in the ~0.5 s endpointing sleep. Resumed speech
# well inside that window tears the bounce down (audio_recognition's
# SOS handler cancels ``_end_of_turn_task``).
await asyncio.sleep(0.05)
await ar._on_vad_event(_start_of_speech())
with contextlib.suppress(asyncio.CancelledError):
await task
assert task.cancelled()
ar._hooks.on_end_of_turn.assert_not_called()
def _inference_done(*, raw_speech: float, raw_silence: float = 0.0) -> vad.VADEvent:
"""An ``INFERENCE_DONE`` event carrying the accumulated speech/silence —
the shape the silero/inference VAD emits each inference window."""
return vad.VADEvent(
type=vad.VADEventType.INFERENCE_DONE,
samples_index=0,
timestamp=0.0,
speech_duration=0.0,
silence_duration=0.0,
raw_accumulated_speech=raw_speech,
raw_accumulated_silence=raw_silence,
)
def _start_of_speech() -> vad.VADEvent:
return vad.VADEvent(
type=vad.VADEventType.START_OF_SPEECH,
samples_index=0,
timestamp=0.0,
speech_duration=0.5,
silence_duration=0.0,
)
def _end_of_speech() -> vad.VADEvent:
return vad.VADEvent(
type=vad.VADEventType.END_OF_SPEECH,
samples_index=0,
timestamp=0.0,
speech_duration=0.0,
silence_duration=0.3,
)
class TestSubThresholdSpeakingSpike:
"""A noise spike can push ``raw_accumulated_speech`` above zero on ``INFERENCE_DONE``
without ever reaching ``START_OF_SPEECH`` — so no SOS/EOS fires. Resumed speech is
gated on a real SOS, not on a momentary spike, so the spike must not block a later
``_StreamingTurnDetector`` commit (the regression that wedged the turn forever)."""
async def test_stale_spike_does_not_block_next_commit(self) -> None:
ar = _make_full_recognition_for_eou()
# spike crosses the activation threshold then subsides before
# min_speech_duration: no SOS, no EOS.
await ar._on_vad_event(_inference_done(raw_speech=0.1))
await ar._on_vad_event(_inference_done(raw_speech=0.0))
chat_ctx = _make_chat_ctx_stub()
ar._run_eou_detection(chat_ctx, trigger="vad")
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
ar._hooks.on_end_of_turn.assert_called_once()
class TestEotPredictionDedup:
"""Both EOU triggers in a turn (vad EOS + stt final) read the same resolved
prediction future. ``on_eot_prediction`` must fire exactly once for that
single prediction."""
async def test_vad_then_stt_emits_eot_prediction_once(self) -> None:
"""Regression for duplicate ``EotPredictionEvent``: the vad-trigger
bounce emits and then parks in the endpointing sleep; the stt-trigger
cancels it and runs a second bounce that reads the *same* resolved
future. Without identity dedup both bounces emit; with it, only the
first does."""
ar = _make_full_recognition_for_eou()
chat_ctx = _make_chat_ctx_stub()
# One prediction per inference request — both triggers read this event
# by reference from the held future.
fut, cached = _resolved_prediction(
0.2, # below 0.5 threshold → endpointing max_delay
inference_duration=0.05,
detection_delay=0.1,
)
ar._turn_detector_prediction_fut = fut
# vad trigger: bounce emits, then parks in the ~0.5s endpointing sleep.
ar._run_eou_detection(chat_ctx, trigger="vad")
for _ in range(5):
await asyncio.sleep(0)
await asyncio.sleep(0.02)
assert ar._hooks.on_eot_prediction.call_count == 1
# stt trigger: cancels the parked vad bounce and runs a fresh one that
# reads the same resolved future. Dedup must suppress a second emit.
ar._run_eou_detection(chat_ctx, trigger="stt")
for _ in range(5):
await asyncio.sleep(0)
await asyncio.sleep(0.02)
assert ar._hooks.on_eot_prediction.call_count == 1
assert ar._last_emitted_prediction is cached
if ar._end_of_turn_task is not None:
await aio.cancel_and_wait(ar._end_of_turn_task)
async def test_text_detector_emits_every_bounce(self) -> None:
"""A text-based detector (not an ``_StreamingTurnDetector``) has no
streaming inference window — ``last_prediction`` is ``None`` — so it
emits ``on_eot_prediction`` on every bounce, never deduped."""
ar = _make_full_recognition_for_eou()
# Swap the audio detector for a text one and give it a transcript so
# ``_run_eou_detection`` selects the text detector.
text_detector = MagicMock() # not an _StreamingTurnDetector
text_detector.supports_language = AsyncMock(return_value=True)
text_detector.predict_end_of_turn = AsyncMock(return_value=0.2)
text_detector.unlikely_threshold = AsyncMock(return_value=0.5)
ar._turn_detector = text_detector
ar._turn_detector_stream = None
ar._audio_transcript = "hello there"
chat_ctx = _make_chat_ctx_stub()
ar._run_eou_detection(chat_ctx, trigger="vad")
for _ in range(5):
await asyncio.sleep(0)
await asyncio.sleep(0.02)
assert ar._hooks.on_eot_prediction.call_count == 1
ar._run_eou_detection(chat_ctx, trigger="stt")
for _ in range(5):
await asyncio.sleep(0)
await asyncio.sleep(0.02)
assert ar._hooks.on_eot_prediction.call_count == 2
if ar._end_of_turn_task is not None:
await aio.cancel_and_wait(ar._end_of_turn_task)
async def test_clear_user_turn_allows_next_turn_to_emit(self) -> None:
"""``clear_user_turn`` resets the dedup guard so the next turn's first
prediction (a distinct object) emits again."""
ar = _make_full_recognition_for_eou()
prev = TurnDetectionEvent(
type="eot_prediction",
last_speaking_time=time.time(),
end_of_turn_probability=0.2,
)
ar._last_emitted_prediction = prev
# Wire only the bits ``clear_user_turn`` touches beyond the eou helper.
ar._audio_interim_transcript = ""
ar._audio_preflight_transcript = ""
ar._stt_request_ids = []
ar._turn_detector_stream.flush = MagicMock()
ar.update_stt = MagicMock() # type: ignore[method-assign]
ar.clear_user_turn()
assert ar._last_emitted_prediction is None
class TestBackchannelOpportunityEmit:
"""``on_agent_backchannel_opportunity`` fires whenever the backchannel
probability clears its threshold, regardless of end-of-turn; the event carries
the end-of-turn probability and threshold so AgentActivity can gauge how close
the pause is to a reply."""
@staticmethod
async def _drive(ar: AudioRecognition, chat_ctx: MagicMock) -> None:
ar._run_eou_detection(chat_ctx, trigger="vad")
for _ in range(5):
await asyncio.sleep(0)
await asyncio.sleep(0.02)
if ar._end_of_turn_task is not None:
await aio.cancel_and_wait(ar._end_of_turn_task)
async def test_emits_with_eot_context_when_turn_continues(self) -> None:
ar = _make_full_recognition_for_eou()
ar._turn_detector_stream.backchannel_threshold = AsyncMock(return_value=0.5)
ar._last_language = "en"
chat_ctx = _make_chat_ctx_stub()
# eot 0.2 < unlikely 0.5 → turn continues; backchannel 0.8 >= 0.5 → emit
ar._turn_detector_prediction_fut, _ = _resolved_prediction(0.2, backchannel_probability=0.8)
await self._drive(ar, chat_ctx)
ar._hooks.on_agent_backchannel_opportunity.assert_called_once()
ev = ar._hooks.on_agent_backchannel_opportunity.call_args.args[0]
assert ev.probability == pytest.approx(0.8)
assert ev.threshold == pytest.approx(0.5)
assert ev.language == "en"
assert ev.end_of_turn_probability == pytest.approx(0.2)
assert ev.end_of_turn_threshold == pytest.approx(0.5)
async def test_emits_with_eot_context_when_turn_ends(self) -> None:
"""The turn-continuing gate was dropped: a backchannel above threshold
still fires at end-of-turn, carrying the EOT context (probability past the
threshold) so AgentActivity can let it lead the reply."""
ar = _make_full_recognition_for_eou()
ar._turn_detector_stream.backchannel_threshold = AsyncMock(return_value=0.5)
chat_ctx = _make_chat_ctx_stub()
# eot 0.9 >= unlikely 0.5 → turn ends; backchannel 0.8 >= 0.5 → still emits
ar._turn_detector_prediction_fut, _ = _resolved_prediction(0.9, backchannel_probability=0.8)
await self._drive(ar, chat_ctx)
ar._hooks.on_agent_backchannel_opportunity.assert_called_once()
ev = ar._hooks.on_agent_backchannel_opportunity.call_args.args[0]
assert ev.end_of_turn_probability == pytest.approx(0.9)
assert ev.end_of_turn_threshold == pytest.approx(0.5)
async def test_no_emit_below_threshold(self) -> None:
ar = _make_full_recognition_for_eou()
ar._turn_detector_stream.backchannel_threshold = AsyncMock(return_value=0.7)
chat_ctx = _make_chat_ctx_stub()
# backchannel 0.4 < 0.7 → no emit (turn continues at eot 0.2)
ar._turn_detector_prediction_fut, _ = _resolved_prediction(0.2, backchannel_probability=0.4)
await self._drive(ar, chat_ctx)
ar._hooks.on_agent_backchannel_opportunity.assert_not_called()
async def test_no_emit_when_backchannel_disabled(self) -> None:
ar = _make_full_recognition_for_eou()
# default helper threshold is None (server sent no backchannel defaults)
chat_ctx = _make_chat_ctx_stub()
ar._turn_detector_prediction_fut, _ = _resolved_prediction(0.2, backchannel_probability=0.9)
await self._drive(ar, chat_ctx)
ar._hooks.on_agent_backchannel_opportunity.assert_not_called()
async def test_no_emit_for_text_detector(self) -> None:
"""A text detector produces no streaming prediction event, so there is
no backchannel probability to act on."""
ar = _make_full_recognition_for_eou()
text_detector = MagicMock() # not an _StreamingTurnDetector
text_detector.supports_language = AsyncMock(return_value=True)
text_detector.predict_end_of_turn = AsyncMock(return_value=0.2)
text_detector.unlikely_threshold = AsyncMock(return_value=0.5)
ar._turn_detector = text_detector
ar._turn_detector_stream = None
ar._audio_transcript = "hello there"
chat_ctx = _make_chat_ctx_stub()
await self._drive(ar, chat_ctx)
ar._hooks.on_agent_backchannel_opportunity.assert_not_called()
class TestPredictionFutureLifecycle:
"""The held prediction future against VAD events: requests start on the
silence tick, are rearmed by resumed speech or SOS, and the flushed-turn
flag blocks new requests until fresh speech."""
async def test_silence_tick_starts_request_once(self) -> None:
ar = _make_full_recognition_for_eou()
ar._speaking = True
await ar._on_vad_event(_inference_done(raw_speech=0.0, raw_silence=0.3))
await ar._on_vad_event(_inference_done(raw_speech=0.0, raw_silence=0.4))
assert ar._turn_detector_stream.predict.call_count == 1
assert ar._turn_detector_prediction_fut is not None
async def test_resumed_speech_without_sos_rearms_next_pause(self) -> None:
"""A short intra-segment pause can resolve a prediction before Silero
emits EOS. When speech resumes without a new SOS, the cached
prediction must be dropped so the next pause gets a fresh window."""
ar = _make_full_recognition_for_eou()
ar._speaking = True
await ar._on_vad_event(_inference_done(raw_speech=0.0, raw_silence=0.3))
first_fut = ar._turn_detector_prediction_fut
assert first_fut is not None
first_fut.set_result(
TurnDetectionEvent(
type="eot_prediction",
last_speaking_time=time.time(),
end_of_turn_probability=0.1,
)
)
await ar._on_vad_event(_inference_done(raw_speech=0.1, raw_silence=0.0))
ar._turn_detector_stream.cancel_inference.assert_called_once_with()
assert ar._turn_detector_prediction_fut is None
await ar._on_vad_event(_inference_done(raw_speech=0.0, raw_silence=0.3))
assert ar._turn_detector_stream.predict.call_count == 2
assert ar._turn_detector_prediction_fut is not None
assert ar._turn_detector_prediction_fut is not first_fut
async def test_silence_tick_starts_request_while_agent_speaking(self) -> None:
"""The agent-speaking gate was dropped: the silence tick warms a
prediction during the user's pause even while the agent is still
speaking, so an overlapping/interrupting turn still gets an EOT
window."""
ar = _make_full_recognition_for_eou()
ar._speaking = True
ar._agent_speaking = True
await ar._on_vad_event(_inference_done(raw_speech=0.0, raw_silence=0.3))
assert ar._turn_detector_stream.predict.call_count == 1
assert ar._turn_detector_prediction_fut is not None
async def test_eos_consumes_silence_tick_request_without_predicting(self) -> None:
"""EOS no longer starts an inference request — the silence tick owns
that. EOS consumes the already-armed future and runs the eou bounce."""
ar = _make_full_recognition_for_eou()
ar._speaking = True
ar._vad_base_turn_detection = True
fut, _ = _resolved_prediction(0.9)
ar._turn_detector_prediction_fut = fut
await ar._on_vad_event(_end_of_speech())
assert ar._turn_detector_stream.predict.call_count == 0
assert ar._turn_detector_prediction_fut is fut
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
ar._hooks.on_eot_prediction.assert_called_once()
async def test_eos_runs_eou_even_while_agent_speaking(self) -> None:
"""The agent-speaking gate was dropped from the EOS handler: the eou
bounce runs regardless of agent speech. Whether anything commits is
then decided downstream by the transcript/interruption guards, not by
the VAD handler."""
ar = _make_full_recognition_for_eou()
ar._speaking = True
ar._agent_speaking = True
ar._vad_base_turn_detection = True
fut, _ = _resolved_prediction(0.9)
ar._turn_detector_prediction_fut = fut
await ar._on_vad_event(_end_of_speech())
assert ar._turn_detector_stream.predict.call_count == 0
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
ar._hooks.on_eot_prediction.assert_called_once()
async def test_sos_tears_down_request_and_rearms(self) -> None:
ar = _make_full_recognition_for_eou()
ar._turn_detector_prediction_fut = asyncio.Future()
ar._turn_detector_flushed = True
await ar._on_vad_event(_start_of_speech())
ar._turn_detector_stream.cancel_inference.assert_called_once_with()
assert ar._turn_detector_prediction_fut is None
assert ar._turn_detector_flushed is False
async def test_eos_never_starts_request(self) -> None:
"""Inference requests start exclusively on the silence tick. EOS does
not start one (no-prediction turns commit on min_delay) and leaves a
held future untouched for the eou bounce."""
ar = _make_full_recognition_for_eou()
await ar._on_vad_event(_end_of_speech())
assert ar._turn_detector_stream.predict.call_count == 0
assert ar._turn_detector_prediction_fut is None
fut, _ = _resolved_prediction(0.9)
ar._turn_detector_prediction_fut = fut
await ar._on_vad_event(_end_of_speech())
assert ar._turn_detector_stream.predict.call_count == 0
assert ar._turn_detector_prediction_fut is fut
async def test_late_stt_final_after_flush_short_circuits(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A late stt final after the turn was flushed must not start an
inference request; it warns once, then logs at debug level."""
caplog.set_level(logging.WARNING, logger="livekit.agents")
ar = _make_full_recognition_for_eou()
ar._turn_detector_flushed = True
chat_ctx = _make_chat_ctx_stub()
for _ in range(2):
ar._run_eou_detection(chat_ctx, trigger="stt")
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
assert ar._turn_detector_stream.predict.call_count == 0
ar._hooks.on_eot_prediction.assert_not_called()
flush_warnings = [r for r in caplog.records if "already flushed" in r.getMessage()]
assert len(flush_warnings) == 1
async def test_predict_timeout_signals_fallback_and_drops_future(self) -> None:
"""A pending future timing out at the model-specific
``prediction_timeout`` commits without a prediction — no synthetic
emission, no threshold lookup — and reports the timeout to the stream
(first one promotes the cloud→local fallback)."""
ar = _make_full_recognition_for_eou()
ar._turn_detector_prediction_fut = asyncio.Future()
chat_ctx = _make_chat_ctx_stub()
ar._run_eou_detection(chat_ctx, trigger="vad")
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
ar._turn_detector_stream.cancel_inference.assert_called_once_with(timed_out=True)
assert ar._turn_detector_prediction_fut is None
ar._hooks.on_eot_prediction.assert_not_called()
ar._turn_detector_stream.unlikely_threshold.assert_not_called()
ar._hooks.on_end_of_turn.assert_called_once()
async def test_commit_flushes_stream_and_marks_turn_flushed(self) -> None:
ar = _make_full_recognition_for_eou()
ar._hooks.on_end_of_turn.return_value = True # commit
fut, _ = _resolved_prediction(0.9) # confident → no max_delay extension
ar._turn_detector_prediction_fut = fut
chat_ctx = _make_chat_ctx_stub()
ar._run_eou_detection(chat_ctx, trigger="vad")
assert ar._end_of_turn_task is not None
await ar._end_of_turn_task
ar._turn_detector_stream.flush.assert_called_once_with(reason="turn committed")
assert ar._turn_detector_prediction_fut is None
assert ar._turn_detector_flushed is True
class _FakeVad:
"""Read-only stand-in exposing the ``min_silence_duration`` knob that
``AudioRecognition`` validates against (read duck-typed via ``getattr``)."""
def __init__(self, min_silence_duration: float | None) -> None:
self._min_silence_duration = min_silence_duration
@property
def min_silence_duration(self) -> float | None:
return self._min_silence_duration
def _make_recognition_for_validation() -> AudioRecognition:
"""Minimal AudioRecognition wired for the VAD-silence validation — no tasks."""
ar = AudioRecognition.__new__(AudioRecognition)
ar._vad = None
ar._turn_detector = None
ar._turn_detector_stream = None
return ar
class TestVadMinSilenceRequirement:
"""``TurnDetector`` needs ~200ms of trailing silence; the VAD must report
END_OF_SPEECH no earlier than that. Rather than mutate the user's VAD,
``AudioRecognition`` fails fast when ``min_silence_duration`` is too low
for an audio-EOT pairing."""
def test_low_min_silence_with_audio_detector_raises(self) -> None:
ar = _make_recognition_for_validation()
ar._vad = _FakeVad(min_silence_duration=0.1)
ar._turn_detector = MagicMock(spec=_StreamingTurnDetector)
with pytest.raises(ValueError, match="min_silence_duration"):
ar._check_vad_silence_requirement()
def test_adequate_min_silence_passes(self) -> None:
ar = _make_recognition_for_validation()
ar._vad = _FakeVad(min_silence_duration=0.5)
ar._turn_detector = MagicMock(spec=_StreamingTurnDetector)
ar._check_vad_silence_requirement() # must not raise
def test_non_audio_detector_skips(self) -> None:
ar = _make_recognition_for_validation()
ar._vad = _FakeVad(min_silence_duration=0.05)
ar._turn_detector = MagicMock() # not an _StreamingTurnDetector
ar._check_vad_silence_requirement() # must not raise
def test_no_vad_skips(self) -> None:
ar = _make_recognition_for_validation()
ar._vad = None
ar._turn_detector = MagicMock(spec=_StreamingTurnDetector)
ar._check_vad_silence_requirement() # must not raise
def test_vad_without_min_silence_knob_skips(self) -> None:
"""A VAD that doesn't expose ``min_silence_duration`` can't be
validated, so the pairing is allowed (no raise)."""
ar = _make_recognition_for_validation()
ar._vad = MagicMock(spec=[]) # no min_silence_duration attribute
ar._turn_detector = MagicMock(spec=_StreamingTurnDetector)
ar._check_vad_silence_requirement() # must not raise
def test_update_turn_detector_validates_pairing(self) -> None:
"""Integration: attaching an audio detector over a too-low VAD raises
through the ``update_turn_detector`` call site, before any stream is
built."""
ar = _make_recognition_for_validation()
ar._tasks = set()
ar._vad = _FakeVad(min_silence_duration=0.1)
detector = MagicMock(spec=_StreamingTurnDetector)
with pytest.raises(ValueError, match="min_silence_duration"):
ar.update_turn_detector(detector)
# Aborted before building a stream — and without calling .stream().
assert ar._turn_detector_stream is None
detector.stream.assert_not_called()