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

550 lines
21 KiB
Python

"""Tests for the AMD classifier silence-timer state machine.
Focuses on the trigger-tagged silence timer logic: pre-baked HUMAN timers for
short greetings can be cancelled and replaced when a transcript arrives, while
long-speech timers (and postpone-termination timers) are left alone.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from livekit.agents.llm import FunctionToolCall
from livekit.agents.voice.amd.classifier import (
AMDCategory,
AMDPredictionEvent,
_AMDClassifier,
)
from .fake_llm import FakeLLM, FakeLLMResponse
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
def _make_classifier(
llm: FakeLLM | None = None,
*,
human_speech_threshold: float = 2.5,
human_silence_threshold: float = 0.1,
machine_silence_threshold: float = 0.3,
no_speech_threshold: float = 10.0,
timeout: float = 10.0,
wait_until_finished: bool = False,
max_endpointing_delay: float = 6.0,
) -> _AMDClassifier:
return _AMDClassifier(
llm or FakeLLM(),
human_speech_threshold=human_speech_threshold,
human_silence_threshold=human_silence_threshold,
machine_silence_threshold=machine_silence_threshold,
no_speech_threshold=no_speech_threshold,
timeout=timeout,
wait_until_finished=wait_until_finished,
max_endpointing_delay=max_endpointing_delay,
)
def _machine_vm_response(transcript: str = "voicemail greeting") -> FakeLLMResponse:
return FakeLLMResponse(
input=transcript,
content="",
ttft=0.0,
duration=0.05,
tool_calls=[
FunctionToolCall(
name="save_prediction",
arguments='{"label": "machine-vm"}',
call_id="c1",
)
],
)
class TestAMDClassifier:
"""Tests for ``_AMDClassifier`` silence-timer behaviour."""
async def test_short_greeting_no_transcript_emits_pre_baked_human(self) -> None:
"""Short utterance + no STT text => HUMAN/short_greeting verdict."""
clf = _make_classifier(human_silence_threshold=0.1)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer_trigger == "short_speech"
assert clf._silence_timer is not None
await asyncio.sleep(0.2)
assert len(results) == 1
assert results[0].category == AMDCategory.HUMAN
assert results[0].reason == "short_greeting"
assert clf._silence_timer is None
assert clf._silence_timer_trigger is None
assert clf._silence_reached is True
await clf.close()
async def test_push_text_cancels_pre_baked_human_and_flips_trigger(self) -> None:
"""A transcript arriving during the short_speech window must cancel the
pre-baked HUMAN timer and replace it with a long_speech timer anchored at
speech_ended + machine_silence_threshold."""
clf = _make_classifier(human_silence_threshold=0.1, machine_silence_threshold=0.3)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer_trigger == "short_speech"
clf.push_text("hello")
assert clf._silence_timer_trigger == "long_speech"
assert clf._silence_timer is not None
# Past the would-be HUMAN deadline (0.1s), well before machine deadline (0.3s).
await asyncio.sleep(0.18)
assert results == [], "pre-baked HUMAN must not fire after a transcript arrives"
assert clf._silence_reached is False
# Past the machine_silence deadline.
await asyncio.sleep(0.2)
assert clf._silence_reached is True
# No verdict was provided by the (empty) FakeLLM, so nothing emits yet.
assert results == []
await clf.close()
async def test_push_text_replacement_timer_preserves_original_deadline(self) -> None:
"""The replacement timer fires near speech_ended + machine_silence_threshold,
not push_text + machine_silence_threshold."""
clf = _make_classifier(human_silence_threshold=0.05, machine_silence_threshold=0.3)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.on_user_speech_ended(silence_duration=0.0)
t_end = clf._speech_ended_at
assert t_end is not None
push_delay = 0.04 # under human_silence_threshold so trigger is still short_speech
await asyncio.sleep(push_delay)
clf.push_text("hello")
assert clf._silence_timer_trigger == "long_speech"
expected_fire = t_end + 0.3
deadline = expected_fire + 0.3
while not clf._silence_reached and time.time() < deadline:
await asyncio.sleep(0.01)
fired_at = time.time()
assert clf._silence_reached
# Allow generous slack for event-loop jitter; the key assertion is that the
# fire time is ~0.3s after t_end, not ~0.34s (which would mean we
# re-armed for a full machine_silence_threshold from push_text).
assert fired_at - t_end < 0.3 + 0.15, (
f"timer fired at {fired_at - t_end:.3f}s after t_end; "
f"expected ~0.30s, never ~0.34s+ (push_text-anchored)"
)
await clf.close()
async def test_long_speech_push_text_does_not_replace_timer(self) -> None:
"""During the long_speech timer, push_text must leave the existing timer
handle intact so the original 1.5s machine deadline is not extended."""
clf = _make_classifier(
human_speech_threshold=0.1,
machine_silence_threshold=0.3,
)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.15)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer_trigger == "long_speech"
handle_before = clf._silence_timer
assert handle_before is not None
clf.push_text("hello world")
assert clf._silence_timer_trigger == "long_speech"
assert clf._silence_timer is handle_before
await clf.close()
async def test_short_greeting_with_existing_transcript_uses_long_speech_trigger(
self,
) -> None:
"""If a transcript is already present when speech ends (push_text before
on_user_speech_ended), the short branch picks the long_speech trigger."""
clf = _make_classifier(human_silence_threshold=0.1, machine_silence_threshold=0.3)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.push_text("hi")
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer_trigger == "long_speech"
handle_before = clf._silence_timer
assert handle_before is not None
# A second transcript while in the long_speech window must not replace the timer.
clf.push_text("there")
assert clf._silence_timer is handle_before
assert clf._silence_timer_trigger == "long_speech"
await clf.close()
async def test_on_user_speech_started_clears_trigger(self) -> None:
"""on_user_speech_started cancels the silence timer and nulls the trigger."""
clf = _make_classifier(human_silence_threshold=1.0)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer is not None
assert clf._silence_timer_trigger == "short_speech"
clf.on_user_speech_started()
assert clf._silence_timer is None
assert clf._silence_timer_trigger is None
await clf.close()
async def test_silence_callback_clears_trigger_on_fire(self) -> None:
"""When the silence timer fires, both handle and trigger are nulled out."""
clf = _make_classifier(human_silence_threshold=0.05)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.02)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._silence_timer_trigger == "short_speech"
await asyncio.sleep(0.12)
assert clf._silence_timer is None
assert clf._silence_timer_trigger is None
await clf.close()
async def test_short_greeting_transcript_emits_llm_verdict(self) -> None:
"""End-to-end: short greeting + transcript => LLM verdict emits at the
machine_silence deadline (gated on both verdict and silence_reached)."""
llm = FakeLLM(
fake_responses=[
FakeLLMResponse(
input="hello",
content="",
ttft=0.0,
duration=0.05,
tool_calls=[
FunctionToolCall(
name="save_prediction",
arguments='{"label": "human"}',
call_id="c1",
)
],
)
]
)
clf = _make_classifier(llm=llm, human_silence_threshold=0.1, machine_silence_threshold=0.3)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.05)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("hello")
await asyncio.wait_for(clf._verdict_ready.wait(), timeout=2.0)
assert len(results) == 1
assert results[0].category == AMDCategory.HUMAN
assert results[0].reason == "llm"
assert results[0].transcript == "hello"
await clf.close()
async def test_machine_verdict_waits_for_eot(self) -> None:
"""Machine verdict is gated on BOTH silence_reached AND eot_reached."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail greeting")])
clf = _make_classifier(llm=llm, human_speech_threshold=0.05, machine_silence_threshold=0.3)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1) # past human_speech_threshold → long_speech path
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail greeting")
# silence timer fires (verdict already set by LLM); EOT has not.
await asyncio.sleep(0.4)
assert clf._silence_reached is True
assert clf._eot_reached is False
assert clf._verdict_result is not None
assert clf._verdict_result.is_machine
assert results == [], "machine verdict must wait for EOT"
# EOT lands → emit.
clf.on_end_of_turn()
assert len(results) == 1
assert results[0].category == AMDCategory.MACHINE_VM
await clf.close()
async def test_machine_verdict_eot_before_silence(self) -> None:
"""Order independence: EOT before silence still emits at silence."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail greeting")])
clf = _make_classifier(llm=llm, human_speech_threshold=0.05, machine_silence_threshold=0.3)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail greeting")
# EOT lands while silence timer is still running.
await asyncio.sleep(0.05)
clf.on_end_of_turn()
assert clf._eot_reached is True
assert clf._silence_reached is False
assert results == [], "must still wait for silence timer"
await asyncio.sleep(0.4)
assert len(results) == 1
assert results[0].category == AMDCategory.MACHINE_VM
await clf.close()
async def test_human_verdict_emits_without_eot(self) -> None:
"""Human/uncertain verdicts emit on silence alone (snappy)."""
llm = FakeLLM(
fake_responses=[
FakeLLMResponse(
input="hello there",
content="",
ttft=0.0,
duration=0.05,
tool_calls=[
FunctionToolCall(
name="save_prediction",
arguments='{"label": "human"}',
call_id="c1",
)
],
)
]
)
clf = _make_classifier(llm=llm, human_speech_threshold=0.05, machine_silence_threshold=0.3)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("hello there")
await asyncio.wait_for(clf._verdict_ready.wait(), timeout=2.0)
assert results[0].category == AMDCategory.HUMAN
assert clf._eot_reached is False, "human must not require EOT"
await clf.close()
async def test_set_verdict_keeps_timers_armed(self) -> None:
"""Preemptive verdict must not cancel detection/no_speech timers; they
still bound the overall AMD lifetime."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail")])
clf = _make_classifier(
llm=llm, human_speech_threshold=0.05, machine_silence_threshold=0.3, timeout=5.0
)
clf.start_detection_timer()
clf.start_listening()
assert clf._detection_timeout_timer is not None
assert clf._no_speech_timer is not None
clf.on_user_speech_started()
# speech-started already cancels no_speech_timer; we care about detection_timer
assert clf._detection_timeout_timer is not None
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail")
# let LLM commit verdict
await asyncio.sleep(0.2)
assert clf._verdict_result is not None
# detection_timeout must remain armed post-verdict
assert clf._detection_timeout_timer is not None
await clf.close()
async def test_emit_cancels_timers(self) -> None:
"""Timers are cancelled at successful emission, not at verdict-set."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail")])
clf = _make_classifier(
llm=llm, human_speech_threshold=0.05, machine_silence_threshold=0.3, timeout=5.0
)
clf.start_listening()
clf.start_detection_timer()
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail")
await asyncio.sleep(0.4) # silence timer fires
assert clf._detection_timeout_timer is not None # still armed, EOT pending
clf.on_end_of_turn()
await asyncio.sleep(0)
# emit happened → timer cancelled
assert clf._detection_timeout_timer is None
await clf.close()
async def test_wait_until_finished_extends_detection_timeout(self) -> None:
"""With wait_until_finished=True and speech heard, detection_timeout
does not force emission — AMD keeps waiting for EOT."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail")])
clf = _make_classifier(
llm=llm,
human_speech_threshold=0.05,
machine_silence_threshold=0.3,
timeout=0.4,
wait_until_finished=True,
)
clf.start_listening()
clf.start_detection_timer()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail")
# well past detection_timeout — but speech was heard and EOT not yet
await asyncio.sleep(0.7)
assert results == [], "detection_timeout must not force emit when waiting"
assert clf._verdict_result is not None
assert clf._eot_reached is False
clf.on_end_of_turn()
assert len(results) == 1
await clf.close()
async def test_no_speech_timeout_always_forces_emit(self) -> None:
"""no_speech_timeout fires regardless of wait_until_finished — there
is nothing to wait for if no audio was ever heard."""
clf = _make_classifier(no_speech_threshold=0.2, wait_until_finished=True)
clf.start_listening()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
await asyncio.wait_for(clf._verdict_ready.wait(), timeout=1.0)
assert len(results) == 1
assert results[0].category == AMDCategory.UNCERTAIN
assert results[0].reason == "no_speech_timeout"
await clf.close()
async def test_eot_backstop_emits_machine_without_turn_detector(self) -> None:
"""The synthetic end-of-turn backstop (max_endpointing_delay) lets a
machine verdict emit even if on_end_of_turn() is never called."""
llm = FakeLLM(fake_responses=[_machine_vm_response("voicemail")])
clf = _make_classifier(
llm=llm,
human_speech_threshold=0.05,
machine_silence_threshold=0.2,
timeout=5.0,
wait_until_finished=True,
max_endpointing_delay=0.4,
)
clf.start_listening()
clf.start_detection_timer()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
clf.push_text("voicemail")
# silence (0.2) elapses but eot has not yet → still gated
await asyncio.sleep(0.3)
assert clf._silence_reached is True
assert clf._eot_reached is False
assert results == []
# the eot backstop (0.4) fires without any on_end_of_turn() call → emit
await asyncio.wait_for(clf._verdict_ready.wait(), timeout=1.0)
assert clf._eot_reached is True
assert len(results) == 1
assert results[0].category == AMDCategory.MACHINE_VM
await clf.close()
async def test_detection_timeout_emits_uncertain_when_eot_reached_no_verdict(self) -> None:
"""wait_until_finished + speech but the LLM never commits a verdict:
once eot is reached the greeting has ended, so the detection timeout
must emit the uncertain fallback instead of deferring forever."""
clf = _make_classifier(
human_speech_threshold=0.05,
machine_silence_threshold=0.2,
timeout=0.4,
wait_until_finished=True,
max_endpointing_delay=6.0, # backstop won't fire; eot comes from on_end_of_turn
)
clf.start_listening()
clf.start_detection_timer()
results: list[AMDPredictionEvent] = []
clf.on("amd_prediction", results.append)
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
# no transcript / no verdict produced; turn ends
clf.on_end_of_turn()
assert clf._eot_reached is True
assert clf._verdict_result is None
# detection timeout fires with eot already reached → emit uncertain fallback
await asyncio.wait_for(clf._verdict_ready.wait(), timeout=1.0)
assert len(results) == 1
assert results[0].category == AMDCategory.UNCERTAIN
assert results[0].reason == "detection_timeout"
await clf.close()
async def test_speech_restart_cancels_eot_backstop(self) -> None:
"""on_user_speech_started cancels the eot backstop and resets the gate."""
clf = _make_classifier(human_speech_threshold=0.05, max_endpointing_delay=0.3)
clf.start_listening()
clf.on_user_speech_started()
await asyncio.sleep(0.1)
clf.on_user_speech_ended(silence_duration=0.0)
assert clf._eot_timer is not None
# user speaks again before the backstop fires
clf.on_user_speech_started()
assert clf._eot_timer is None
assert clf._eot_reached is False
# well past the original backstop deadline → still not reached
await asyncio.sleep(0.4)
assert clf._eot_reached is False
await clf.close()