Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions livekit-agents/livekit/agents/voice/amd/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,10 @@ def on_end_of_turn(self) -> None:
The commit may be positive (predicted end of turn) or a negative
prediction whose max endpointing delay elapsed — either one counts.

For every verdict except a confident human, both an end-of-turn and the
post-speech silence timer must fire before it is released (whichever
lands last unblocks the wait). Humans only require the silence timer so
we can respond quickly.
When VAD provides a speech end, every verdict except a confident human
requires both end-of-turn and the post-speech silence timer. If VAD does
not provide a valid speech end, end-of-turn also satisfies the silence
gate. Humans only require the silence gate so we can respond quickly.

When the turn detector never calls this, the synthetic EOT timer
provides the backstop. This gate matters most under
Expand All @@ -282,6 +282,9 @@ def _on_eot_reached(self) -> None:
if self._eot_timer is not None:
self._eot_timer.cancel()
self._eot_timer = None
if self._speech_active or self._speech_ended_at is None:
self._speech_active = False
self._silence_reached = True
self._eot_reached = True
self._try_emit_result()

Expand All @@ -299,7 +302,8 @@ def _arm_eot_timer(self, *, delay: float | None = None) -> None:
def _can_emit(self, verdict: AMDPredictionEvent) -> bool:
"""Release gate for a verdict (which verdict it is, is decided elsewhere).

- post-speech silence is required for every verdict
- post-speech silence, or the EOT fallback when VAD misses speech end,
is required for every verdict
- end-of-turn is additionally required for everything except a human
(machine and uncertain wait for the greeting to finish; humans
release on silence alone so we can respond quickly)
Expand Down Expand Up @@ -370,7 +374,7 @@ def settle(
speech_duration=speech_duration or self.speech_duration,
category=category,
reason=reason,
transcript="",
transcript=self._transcript,
delay=(time.time() - self._speech_ended_at) if self._speech_ended_at else 0.0,
)
)
Expand Down
25 changes: 13 additions & 12 deletions livekit-agents/livekit/agents/voice/amd/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class AMD(EventEmitter[Literal["amd_prediction"]]):
if LiveKit inference credentials are available in the environment
it uses ``"google/gemini-3.1-flash-lite"`` via the
inference gateway; otherwise it falls back to the session's own
LLM.
LLM. Pass ``None`` to always reuse the session's LLM.
interrupt_on_machine: If ``True`` (default), interrupt any pending
agent speech immediately when a machine is detected.
ivr_detection: If ``True`` (default), automatically start IVR
Expand All @@ -140,7 +140,8 @@ class AMD(EventEmitter[Literal["amd_prediction"]]):
``"cartesia/ink-whisper"``). When omitted, AMD auto-selects:
if LiveKit inference credentials are available it uses
``"cartesia/ink-whisper"`` via the inference gateway; otherwise
it reuses the session's existing STT transcripts.
it reuses the session's existing STT transcripts. Pass ``None`` to
always reuse the session's STT transcripts.
suppress_compatibility_warning: If ``True``, do not log a warning when
the resolved STT or LLM is not among the bundled AMD-tested model
strings. Has no effect on classification behavior.
Expand Down Expand Up @@ -169,8 +170,8 @@ def __init__(
self,
session: AgentSession,
*,
llm: NotGivenOr[LLM | LLMModels | str] = NOT_GIVEN,
stt: NotGivenOr[STT | str] = NOT_GIVEN,
llm: NotGivenOr[LLM | LLMModels | str | None] = NOT_GIVEN,
stt: NotGivenOr[STT | str | None] = NOT_GIVEN,
interrupt_on_machine: bool = True,
ivr_detection: bool = True,
participant_identity: NotGivenOr[str] = NOT_GIVEN,
Expand All @@ -189,18 +190,18 @@ def __init__(
is_cloud(os.getenv("LIVEKIT_URL", "")) and bool(api_key) and bool(api_secret)
)
if not is_given(llm):
llm = self._DEFAULT_LLM_MODEL if auto_select else NOT_GIVEN
llm = self._DEFAULT_LLM_MODEL if auto_select else None
if not is_given(stt):
stt = self._DEFAULT_STT_MODEL if auto_select else NOT_GIVEN
stt = self._DEFAULT_STT_MODEL if auto_select else None

self._llm_config: NotGivenOr[LLM | LLMModels | str] = llm
self._llm_config: LLM | LLMModels | str | None = llm
self._session: AgentSession = session
self._interrupt_on_machine = interrupt_on_machine
self._ivr_detection = ivr_detection
self._wait_until_finished = wait_until_finished
self._suppress_compatibility_warning = suppress_compatibility_warning
self._participant_identity: NotGivenOr[str] = participant_identity
self._stt: NotGivenOr[_STT] = _InferenceSTT(stt) if isinstance(stt, str) else stt
self._stt: _STT | None = _InferenceSTT(stt) if isinstance(stt, str) else stt

self._classifier: _AMDClassifier | None = None
self._result: AMDPredictionEvent | None = None
Expand All @@ -216,7 +217,7 @@ def __init__(
}

if not self._suppress_compatibility_warning:
if is_given(self._stt):
if self._stt is not None:
_warn_if_not_evaluated(
self._stt.model,
EVALUATED_STT_MODELS,
Expand Down Expand Up @@ -446,7 +447,7 @@ async def _setup(self, session: AgentSession) -> None:
else:
self._start_listening()

if is_given(self._stt) and not self._closed:
if self._stt is not None and not self._closed:
logger.debug("starting amd stt pipeline")
await self._run_stt()

Expand Down Expand Up @@ -482,7 +483,7 @@ async def _wait_for_sip_answer(self, room: rtc.Room, identity: str) -> None:
self._start_listening()

async def _run_stt(self) -> None:
assert is_given(self._stt)
assert self._stt is not None
assert self._classifier

self._audio_ch = aio.Chan[rtc.AudioFrame]()
Expand Down Expand Up @@ -614,7 +615,7 @@ def _resolve_classifier(
no_speech_threshold=self._opts["no_speech_threshold"],
timeout=self._opts["timeout"],
prompt=self._opts["prompt"],
source="amd_stt" if is_given(self._stt) else "stt",
source="amd_stt" if self._stt is not None else "stt",
wait_until_finished=self._wait_until_finished,
max_endpointing_delay=max_endpointing_delay,
)
Expand Down
Loading