Skip to content

fix(amd): synthesize the speech start when the end arrives without one - #6639

Open
biztex wants to merge 3 commits into
livekit:mainfrom
biztex:fix/amd-end-before-start
Open

fix(amd): synthesize the speech start when the end arrives without one#6639
biztex wants to merge 3 commits into
livekit:mainfrom
biztex:fix/amd-end-before-start

Conversation

@biztex

@biztex biztex commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #5616

Problem

AMD.execute() held playout for 7-16s on outbound human calls. The reported trace shows the verdict landing at +19.4s — the 20s detection_timeout — with speech_duration=0.00 and a negative AMDResult.delay, plus the tell-tale WARNING: on_user_speech_ended called before on_user_speech_started.

Root cause

When AMD attaches after the SIP participant connects (per examples/telephony/amd.py, and as @chenghao-mou hypothesized on the issue), the callee may already be mid-greeting. The start-of-speech event fires before start_listening() opens the gate, so the _listening_guard drops it. The later end-of-speech event then finds _speech_started_at is None and was discarded entirely — no silence timer armed, no classification started, nothing scheduled at all. The classifier sat idle until detection_timeout forced an uncertain verdict, and speech-playout authorization was held the whole time.

Change

Synthesize the missing start instead of dropping the end signal: _speech_started_at is set to the moment listening began — the earliest speech the classifier could have observed (a conservative lower bound on the real duration) — the no-speech timer is cancelled (speech clearly happened), and the normal short-greeting / long-speech verdict paths run. Ordinary event orderings are untouched, and events arriving before start_listening() remain gated as documented.

Tests

Three new cases in tests/test_amd_classifier.py on the existing harness: end-without-start now arms the verdict path and emits a HUMAN/short_greeting verdict (previously nothing until timeout); events before start_listening() stay no-ops; and a real start timestamp is never overwritten by the synthesis.

When AMD attaches after the callee has already started speaking (outbound
calls answered mid-greeting), the start-of-speech event fires before
start_listening() opens the gate and is dropped. The later end-of-speech
event then found no start timestamp and was discarded with a warning -
leaving no timers armed, no classification running, and the verdict stalled
until the 20s detection_timeout. The reported symptom: 7-16s of held playout
on human calls, AMDResult.delay negative, speech_duration=0.00.

Synthesize the missing start from the moment listening began (the earliest
speech we could have observed) instead of dropping the signal: the no-speech
timer is cancelled since speech clearly happened, and the normal
short-greeting/long-speech verdict paths run. Ordinary orderings are
unchanged, and events before start_listening() stay gated as documented.

Fixes livekit#5616
@biztex
biztex requested a review from a team as a code owner July 31, 2026 06:57
devin-ai-integration[bot]

This comment was marked as resolved.

The VAD reports end-of-speech only after a minimum trailing silence, so the
computed end points back in time; when the listening gate opened during that
pause the synthesized start was later than the end and the reported
speech_duration went negative in the emitted AMDPredictionEvent.
devin-ai-integration[bot]

This comment was marked as resolved.

the session VAD runs independently of the AMD gate, so ringback/early-media
audio can produce an end event that lands just after start_listening() while
the speech itself finished before the gate opened. Clamping that into a
zero-length greeting cancelled the no-speech timer and committed an instant
HUMAN/short_greeting verdict from zero observed speech. Such a segment is
now dropped like its start event was: the start is only synthesized when
the speech actually overlaps the listening window, which also keeps the
synthesized duration strictly positive.
@biztex

biztex commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Note on the red unit-tests: the failure is unrelated to this PR and is a pre-existing flake.

It is a teardown error in tests/test_plugin_google_realtime.py::test_late_content_after_generation_complete_is_dropped, where fail_on_leaked_tasks reports pending BaseApiClient.aclose / AsyncClient.aclose tasks from google-genai and httpx. Those tasks aren't created by the test body: google/genai/client.py:188 and google/genai/_api_client.py:2248 both do asyncio.get_running_loop().create_task(self.aclose()) inside __del__, and the test builds a RealtimeModel().session() that is never closed. So the clients are collected at a nondeterministic point and the leak check attributes the tasks to whichever test happens to be finishing at that moment.

Evidence it isn't this branch: the previous commit here (b343925) passed the same workflow, this PR only touches voice/amd/classifier.py and tests/test_amd_classifier.py, and the full --unit suite passes locally on the current head (1276 passed), including that file both alone and alongside the AMD tests. A re-run should clear it — I don't have permission to trigger one.

Happy to send a separate PR closing the session in that test (or giving it a fixture that does) so the genai clients stop being left to the GC, if that's wanted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AMD.execute() takes 7-16s on outbound human calls; first-utterance latency caused by VAD event ordering + 20s detection_timeout

1 participant