Skip to content

fix(inference): report Ink-2 speech onset when the provider detects it - #2182

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
fix/inference-ink2-speech-onset
Open

fix(inference): report Ink-2 speech onset when the provider detects it#2182
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
fix/inference-ink2-speech-onset

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Ports livekit/agents#6630.

Summary

  • recognize the inference gateway start_of_speech event and emit STT onset immediately
  • preserve transcript-based onset as a fallback and suppress duplicate onset events
  • add the source PR's six onset regression tests and a patch changeset

Validation

  • pnpm test agents (1515 passed, 5 skipped)
  • pnpm build (40 packages)
  • pnpm lint (40 packages; existing warnings only)
  • pnpm format:check
  • pnpm --filter @livekit/agents typecheck
  • cue-cli voice-mode run with live cartesia/ink-2: observed user-speaking before final transcription; assertion resolved in 2.257s
Source diff coverage
  • livekit-agents/livekit/agents/inference/stt.py: adapted to agents/src/inference/stt.ts for equivalent event dispatch, deduplicated onset handling, and transcript fallback. Also adapted to agents/src/inference/api_protos.ts because agents-js validates gateway messages with a typed Zod union; adding start_of_speech there is required to prevent the event from being discarded as unknown.
  • tests/test_inference_stt_start_of_speech.py: adapted to agents/src/inference/stt.test.ts, preserving all six source test cases and assertions in Vitest form.

Ported from livekit/agents#6630

Original PR description

Companion to livekit/agent-gateway#1134, which makes the gateway send the message; this one handles it.

Independent of, and complementary to, #6629: that PR stops inference.STT claiming word alignment for Ink-2 (which was falsely enabling adaptive interruption and disabling the fast VAD path). Together the three PRs cover both causes of the slow Ink-2 barge-in. No file overlap — the hunks are in different regions of stt.py.

The bug

Customers reported that Ink-2 barge-in through LiveKit Inference is "consistently over 1 sec late versus Silero VAD", while the same model via cartesia.STT is fine. That reproduces, but the delay is in speech onset, not end-of-turn.

Cartesia's Turns API emits turn.start about 130ms after speech begins. The gateway forwarded it as an interim_transcript whose transcript is the empty string, since turn.start carries no text. _process_transcript drops exactly that:

if not text and not is_final:
    return

So the onset signal vanished, and START_OF_SPEECH was instead synthesized from the first interim that carried words — roughly a second in, once a word had been decoded. Interruption keys off onset, so barge-in inherited the full delay. cartesia.STT bypasses this translation and maps turn.start straight to START_OF_SPEECH, which is why the plugin looked fine and inference didn't.

The fix

Handle a start_of_speech message and report onset from it. The transcript-based path stays as a fallback for providers that send no onset event, so their behaviour is unchanged.

Fixing this SDK-side alone isn't possible: inferring onset from an empty interim would fire for any provider that emits empty interims for unrelated reasons, which is why the gateway needs to name the event explicitly.

Measurements

Same 5-turn fixture, real Cartesia, locally built gateway, pre-fix binary vs both halves of the fix. Numbers are ms after Silero VAD's onset:

turn before after cartesia.STT first interim (old onset point)
1 1014 363 271 957
2 954 134 156 919
3 810 157 153 778
4 1004 162 117 959
5 1100 260 264 1059

Median onset: 1004ms to 162ms, within 6ms of the plugin instead of ~880ms behind. Three of five turns previously exceeded a second, matching the report.

Unchanged, as expected for an onset-only fix: end_of_speech lateness (368ms vs the plugin's 363ms) and preemptive-generation reuse (5/5 turns).

Rollout

The two halves are independent. The gateway side can land first: this dispatch is an if/elif chain with no else, so an SDK without this change ignores start_of_speech silently and keeps today's behaviour, having already discarded the empty interim it replaces.

Test plan

  • tests/test_inference_stt_start_of_speech.py — 6 tests: onset without waiting for a transcript, no duplicate onset, the fallback for providers with no onset event, that an empty interim alone never reports onset, and that onset resets across turns
  • End-to-end against real Cartesia through a locally built gateway (table above)

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner July 30, 2026 22:06
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0d9ced

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +1013 to +1017
private processStartOfSpeech(): void {
if (this.speaking) return;
this.speaking = true;
this.queue.put({ type: SpeechEventType.START_OF_SPEECH });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Speech-start notification can throw an error when the transcription stream is shutting down

The new speech-onset notification is pushed onto the closed output queue (this.queue.put(...) at agents/src/inference/stt.ts:1016) without first checking whether the stream has already been shut down, so an error can be raised during teardown instead of the event being quietly dropped.
Impact: If a provider onset message arrives while the transcription stream is closing, the receive loop can fail with an error instead of shutting down cleanly.

Missing closed-queue guard compared to the transcript path

processTranscript deliberately guards against this race: it returns early when this.queue.closed (agents/src/inference/stt.ts:1007, comment "Check if queue is closed to avoid race condition during disconnect") and wraps all queue.put calls in a try/catch that specifically tolerates Queue is closed (agents/src/inference/stt.ts:1082-1092). The new processStartOfSpeech is invoked directly from recv()'s switch (agents/src/inference/stt.ts:924-926) with neither guard, and AsyncIterableQueue.put throws Error('Queue is closed') when closed (agents/src/utils.ts:327-331). The queue is closed by close() (agents/src/stt/stt.ts:547) and after mainTask() completes (agents/src/stt/stt.ts:339), which can happen between recv()'s loop condition check and the put. The thrown error propagates out of the recv task and out of run(); unless the abort flag is already set, mainTask treats it as a non-API error and emits a non-recoverable error event (agents/src/stt/stt.ts:392-395). Additionally this.speaking is set to true before the put, so a failed put leaves the flag inconsistent.

Suggested change
private processStartOfSpeech(): void {
if (this.speaking) return;
this.speaking = true;
this.queue.put({ type: SpeechEventType.START_OF_SPEECH });
}
private processStartOfSpeech(): void {
// Check if queue is closed to avoid race condition during disconnect
if (this.queue.closed) return;
if (this.speaking) return;
this.speaking = true;
this.queue.put({ type: SpeechEventType.START_OF_SPEECH });
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

0 participants