Skip to content

feat(sarvam): add STTRealtime for realtime streaming STT - #2414

Open
devbm7 wants to merge 4 commits into
livekit:mainfrom
devbm7:feat/sarvam-realtime-stt
Open

feat(sarvam): add STTRealtime for realtime streaming STT#2414
devbm7 wants to merge 4 commits into
livekit:mainfrom
devbm7:feat/sarvam-realtime-stt

Conversation

@devbm7

@devbm7 devbm7 commented Sep 3, 2026

Copy link
Copy Markdown

Motivation

Sarvam's realtime speech-to-text API (saaras:v3-realtime) has been available in the Python SDK since livekit/agents#6562 (STTRealtime), but the Node.js plugin only exposes the legacy STT class (REST + a non-realtime WebSocket streaming mode). This PR closes that gap.

Change

Adds STTRealtime and RealtimeSpeechStream to plugins/sarvam/src/stt_realtime.ts, exported alongside the existing STT/SpeechStream — the legacy class is untouched.

  • Single-attempt WebSocket connection: stream() forces connOptions.maxRetry to 0, since this endpoint bills per connection and must never silently reconnect on a socket failure.
  • Full event mapping from Sarvam's realtime protocol to LiveKit SpeechEventTypes: session.begin, vad.speech_start/vad.speech_end, transcript.partial/transcript.final, session.end, config.updated, error, pong.
  • endpointing: 'vad' (default) buffers transcript.final until the matching vad.speech_end supplies the turn boundary, emitting END_OF_SPEECH + FINAL_TRANSCRIPT together; endpointing: 'manual' emits FINAL_TRANSCRIPT immediately and derives turn boundaries from flush()/pushFrame() instead of server VAD.
  • Usage/billing: periodic local RECOGNITION_USAGE reporting (5s), reconciled against the server's authoritative audio_duration_s on session.end to avoid double-billing.
  • Live config.update support for mid-session option changes (endpointing switches apply only at the next utterance boundary, gated by the server's ack).
  • All four wire encodings supported: linear16, linear32, mulaw, alaw.

Also adds plugins/sarvam/src/_utils.ts (PeriodicCollector, matching the existing pattern already duplicated in deepgram/xai/elevenlabs), new types in models.ts (STTRealtimeLanguages correctly uses or-IN for Odia, unlike the legacy od-IN), and a README section documenting the no-reconnect/per-connection-billing behavior.

Usage

import * as sarvam from '@livekit/agents-plugin-sarvam';

const stt = new sarvam.STTRealtime({
  language: 'en-IN',
  streamType: 'balanced',
  endpointing: 'vad',
});

Validation

  • pnpm --filter @livekit/agents-plugin-sarvam build | lint | api:check and prettier --check all pass; API report regenerated.
  • pnpm vitest run plugins/sarvam — new stt_realtime.test.ts (vad-mode final-transcript gating, manual-mode immediate finals, no-reconnect guarantee, streaming-only contract) plus existing suite, all green.
  • Manually verified end-to-end against the live Sarvam realtime API: all four wire encodings connect and complete a full session cleanly; a real speech sample (English) produces accurate partial/final transcripts with correctly gated VAD-mode turn boundaries and usage reconciliation.

Notes

Companion Python PR: livekit/agents#6562.

Ports Sarvam's realtime speech-to-text API (saaras:v3-realtime) to
Node.js, following the existing legacy STT plugin. Adds STTRealtime
and RealtimeSpeechStream alongside the existing STT/SpeechStream
classes, exported separately.

Key behavior: this endpoint bills per connection, so stream() forces
connOptions.maxRetry to 0 -- the stream never silently reconnects on
a socket failure.
@devbm7
devbm7 requested a review from a team as a code owner September 3, 2026 13:58
@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49cd76b

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

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

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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread plugins/sarvam/src/stt_realtime.ts Outdated
Comment thread plugins/sarvam/src/stt_realtime.ts Outdated
Comment thread plugins/sarvam/src/stt_realtime.ts
Comment on lines +914 to +930
#handleErrorEvent(data: RealtimeServerEvent): void {
if (!data.is_fatal) {
this.#logger.warn(
{ code: data.code, message: data.message },
'non-fatal Sarvam realtime STT error',
);
return;
}
const statusCode = typeof data.status_code === 'number' ? data.status_code : -1;
throw new APIStatusError({
message: `Sarvam realtime STT error: ${data.message ?? data.code ?? 'unknown'}`,
options: {
statusCode,
requestId: this.#requestId || null,
body: data as unknown as object,
retryable: data.code === 'model_unavailable',
},

@devin-ai-integration devin-ai-integration Bot Sep 3, 2026

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.

🟨 Provider errors bypass PII redaction

Malformed provider text enters an APIStatusError message unchanged. Error listeners and automatic logs can expose customer or transcript content.

Devin Review

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 221b9636eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/sarvam/src/stt_realtime.ts Outdated
Comment on lines +666 to +668
if ((code === 1000 || code === 1001) && !looksLikeErrorText(reason)) {
resolve();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Terminate the stream when the peer cleanly closes

If the peer sends a clean 1000/1001 close before the caller has ended input, this resolves #processMessages, but run() is still awaiting Promise.all with #processAudio, which remains blocked in its for await (this.input) loop. The output queue therefore never closes and no connection error is surfaced until another frame is pushed or endInput() is called; an idle server-side close leaves a realtime stream hanging indefinitely.

Useful? React with 👍 / 👎.

Comment thread plugins/sarvam/src/stt_realtime.ts Outdated
- Replace the hand-rolled G.711 mu-law/A-law encoders with a faithful
  port of the standard ITU-T reference algorithm. The previous A-law
  encoder had an inverted sign check that corrupted positive-amplitude
  samples; while fixing it, the rest of the derived implementation
  turned out to diverge from the reference too, so both encoders are
  now ported directly from the canonical algorithm instead.
- Fix a hang: if the server ends the session or closes the socket
  before the caller calls endInput()/flush(), the audio pump no longer
  blocks forever waiting for a frame that may never come -- the two
  connection tasks now share an abort controller so either side
  finishing unblocks the other.
- Streams that complete naturally (not via close()) now unregister
  from STTRealtime's tracking set, so updateOptions() stops being
  forwarded to dead connections.
- Tag provider-supplied error text as lk.pii.* in logs, matching this
  repo's PII-redaction convention, instead of embedding it in the
  thrown error's own message/body.
- Reject non-mono audio frames instead of silently corrupting them
  (the wire framer is hardcoded to mono).

Verified against the live API: all four wire encodings now produce
correct transcripts (previously alaw silently distorted audio).
@devbm7

devbm7 commented Sep 3, 2026

Copy link
Copy Markdown
Author

Addressed all 5 findings from the automated reviews in c065849:

  1. A-law sign inversion (Devin + Codex) — fixed. While correcting it, discovered the rest of my hand-derived G.711 encoder also diverged from the reference algorithm, so replaced both mulaw/alaw encoders with a direct port of the standard ITU-T reference (linear2ulaw/linear2alaw) instead of patching further. Verified live against the API: all four encodings now produce identical, correct transcripts (previously alaw silently distorted audio).
  2. Hang on server-initiated close before endInput() (Devin + Codex) — fixed. The audio pump and message loop now share an AbortController scoped to the connection attempt; whichever side settles first aborts the other instead of leaving it blocked on this.input.next().
  3. Streams not removed from STTRealtime's tracking set on natural completion — fixed with an idempotent #notifyClosed() called from both close() and run()'s finally.
  4. Untagged PII in error logs — fixed. Provider-supplied error text is now tagged lk.pii.message per this repo's convention, and no longer embedded in the thrown error's own message/body.
  5. Non-mono audio silently corrupted (Codex) — fixed. #processAudio now rejects a non-mono AudioFrame with a clear error instead of feeding it through the mono-hardcoded framer.

Added regression tests for all 5 (stt_realtime.test.ts), including independent-reference round-trip tests for both G.711 encoders.

@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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 new potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +827 to +830
} else if (!this.#eosEmittedForUtterance) {
this.#emitEndOfSpeech();
if (this.#finalReceivedForUtterance) {
this.#tryCommitUtterance();

@devin-ai-integration devin-ai-integration Bot Sep 3, 2026

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.

🔴 Turn completion precedes final transcript

When #handleSpeechEnd receives a boundary, it emits completion before the buffered final transcript. The voice pipeline can commit stale words before the final text arrives.

Devin Review

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

Comment thread plugins/sarvam/src/stt_realtime.ts
Comment thread plugins/sarvam/src/stt_realtime.ts
- Emit a buffered final transcript before END_OF_SPEECH in vad
  endpointing mode, not after. AudioRecognition's runEOUDetection
  fires on END_OF_SPEECH, so committing it first left the turn
  missing its own final text.
- Treat encoding as connection-only in updateOptions -- the server's
  decoder is fixed by the connect-time query param, so switching it
  mid-stream desynced client/server framing.
- Flush any audio still buffered in the framer when input ends
  without a trailing flush(), instead of dropping the tail.
@devbm7

devbm7 commented Sep 3, 2026

Copy link
Copy Markdown
Author

Addressed the second round of Devin findings in 09b7ea7:

  1. Turn completion precedes final transcript — real bug, confirmed against AudioRecognition: runEOUDetection fires on END_OF_SPEECH, and in turnDetectionMode: 'stt' it uses whatever transcript has been processed so far. Since events from one stream are consumed in order, emitting END_OF_SPEECH before the buffered FINAL_TRANSCRIPT meant the turn could commit before its own final text arrived. Fixed by committing the buffered final first (#tryCommitUtterance already emits FINAL_TRANSCRIPT then END_OF_SPEECH in the right order) instead of emitting END_OF_SPEECH unconditionally.
  2. Live encoding updates corrupt audio — confirmed: the server's decoder is fixed by the encoding query param at connect time, but updateOptions had no guard stopping a caller from switching #opts.encoding mid-stream, which would send a wire format the server never agreed to. Added encoding to the connection-only field list alongside sampleRate/returnTimestamps/vadPrefixPaddingMs.
  3. Final audio tail discarded — confirmed: #processAudio's loop just breaks when endInput() is called without a preceding flush(), leaving up to one chunk (~50ms) of buffered PCM in the framer unsent. Now drains it before sending end.

Added 4 regression tests total for these two rounds' findings (13 passing now), and reverified live against the real API that audio/transcription still works end-to-end after the encoding-guard change.

devin-ai-integration[bot]

This comment was marked as resolved.

The endInput()-without-flush() tail drain added in the previous
commit called #sendAudioFrames directly, which starts a manual-mode
turn (speech_start) but never ends it -- that only happened inside
the main loop's isFlush branch. Send speech_end / #endManualUtterance
there too.
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.

1 participant