Skip to content

Harden NVIDIA Speech STT and TTS support - #6706

Open
Yashi11 wants to merge 3 commits into
livekit:mainfrom
Yashi11:contrib/nvidia-speech-plugin-hardening
Open

Harden NVIDIA Speech STT and TTS support#6706
Yashi11 wants to merge 3 commits into
livekit:mainfrom
Yashi11:contrib/nvidia-speech-plugin-hardening

Conversation

@Yashi11

@Yashi11 Yashi11 commented Aug 5, 2026

Copy link
Copy Markdown

Extend the NVIDIA plugin with production-ready streaming and offline speech support, configurable recognition behavior, improved error handling, and compatibility across supported NVIDIA Riva client versions.

Changes:

  • Add streaming, offline, and backward-compatible automatic STT inference modes.
  • Add offline STT recognition and combine multipart results into complete hypotheses.
  • Keep LiveKit STT streams reusable across flush boundaries by restarting the backend recognition RPC per segment.
  • Replay buffered segment audio when retrying recoverable streaming failures.
  • Stop retrying deterministic and explicitly non-retryable API errors.
  • Add online and offline TTS inference modes.
  • Support the argument differences between nvidia-riva-client 2.16 and 2.26.
  • Restrict TTS output to LINEAR_PCM until additional encodings are decoded correctly.
  • Normalize TTS voice listings into individual locale entries.
  • Improve NVIDIA Speech error mapping and stream lifecycle handling.
  • Add NVIDIA Apache-2.0 SPDX notices to contributed files.
  • Document configuration, inference modes, local deployment, and supported client versions.

Testing

  • uv run --locked pytest tests/test_plugin_nvidia.py tests/test_stt_base.py --unit -q
  • Result: 33 passed.
  • uv run --locked ruff check --output-format=github .
  • Result: passed.
  • uv run --locked ruff format --check .
  • Result: 915 files already formatted.
  • Manually verified hosted STT with Parakeet CTC, Parakeet RNNT, Parakeet TDT, Nemotron, Whisper, and Canary models.
  • Manually verified hosted TTS with Magpie and Chatterbox using chunked and token-fed synthesis.
  • Validated compatibility with nvidia-riva-client 2.16 and 2.26.

@Yashi11
Yashi11 requested a review from a team as a code owner August 5, 2026 09:14

@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 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +49 to 57
start_history: int | None = None
start_threshold: float | None = None
stop_history: int | None = None
stop_threshold: float | None = None
stop_history_eou: int | None = None
stop_threshold_eou: float | None = None


@dataclass

@devin-ai-integration devin-ai-integration Bot Aug 5, 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.

🟡 New public configuration class ships without any documentation

The newly exported configuration object is added (EndpointingConfig at livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/stt.py:49-57) with no explanation of what any of its settings do, so users of the published API docs get an undocumented knob.
Impact: People adopting the NVIDIA plugin cannot tell from the generated documentation what the new tuning options mean or what units they use.

Repository rule requiring documentation for new public classes

CONTRIBUTING.md states: "If writing new methods/enums/classes, document them. This project uses pdoc3 for automatic API documentation generation, and every new addition has to be properly documented."

EndpointingConfig is exported from the package (livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/__init__.py:44-56, added to __all__), so pdoc will render it, but neither the class nor any of its seven fields carry a docstring. The same rule applies to the other new public additions in this PR — ChunkedStream (livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/tts.py:214) and the many new STT/TTS constructor keyword arguments (inference_mode, endpointing, options, boosted_lm_words, quality, audio_prompt_file, ...), which are also undocumented (no Google-style docstrings as required by AGENTS.md).

Open in Devin Review

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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@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 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +578 to +606
status_codes = {
grpc.StatusCode.INVALID_ARGUMENT: 400,
grpc.StatusCode.NOT_FOUND: 404,
grpc.StatusCode.ALREADY_EXISTS: 409,
grpc.StatusCode.PERMISSION_DENIED: 403,
grpc.StatusCode.RESOURCE_EXHAUSTED: 429,
grpc.StatusCode.FAILED_PRECONDITION: 400,
grpc.StatusCode.ABORTED: 409,
grpc.StatusCode.OUT_OF_RANGE: 400,
grpc.StatusCode.UNIMPLEMENTED: 501,
grpc.StatusCode.INTERNAL: 500,
grpc.StatusCode.UNAVAILABLE: 503,
grpc.StatusCode.DATA_LOSS: 500,
grpc.StatusCode.UNAUTHENTICATED: 401,
}
return APIStatusError(
f"{operation} failed: {details}",
status_code=status_codes.get(code, -1),
retryable=code
in {
grpc.StatusCode.UNKNOWN,
grpc.StatusCode.RESOURCE_EXHAUSTED,
grpc.StatusCode.ABORTED,
grpc.StatusCode.INTERNAL,
grpc.StatusCode.UNAVAILABLE,
},
)

return stt.SpeechData(
language=LanguageCode(self._language),
start_time=start_time,
end_time=end_time,
confidence=confidence,
text=transcript,
speaker_id=speaker_id,
words=[
TimedString(
text=getattr(word, "word", ""),
start_time=getattr(word, "start_time", 0) + self.start_time_offset,
end_time=getattr(word, "end_time", 0) + self.start_time_offset,
)
for word in words
]
if words
else None,
if isinstance(error, (TypeError, ValueError)):

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.

🟡 Temporary NVIDIA speech-recognition conflicts are never retried despite being marked as retryable

Transient recognition failures are labelled as a conflict (status_code=409 at livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/stt.py:585) even though the same code explicitly lists them as worth retrying, so the retry is silently discarded and the request fails on the first attempt.
Impact: A recoverable, momentary NVIDIA speech recognition hiccup aborts the transcription instead of being retried, dropping the user's utterance.

How the 409 mapping cancels the intended retryable flag

_to_stt_api_error builds an APIStatusError with status_code=status_codes.get(code, -1) and retryable=code in {UNKNOWN, RESOURCE_EXHAUSTED, ABORTED, INTERNAL, UNAVAILABLE} (livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/stt.py:593-604).

For grpc.StatusCode.ABORTED the map yields 409. But APIStatusError.__init__ (livekit-agents/livekit/agents/_exceptions.py:72-79) hard-overrides the caller's value:

if 400 <= status_code < 500 and status_code not in (408, 429, 499):
    retryable = False

So ABORTED always comes back with retryable=False. This matters more now that this PR made both retry paths honour e.retryable:

  • STT.recognize at livekit-agents/livekit/agents/stt/stt.py:241 (if conn_options.max_retry == 0 or not e.retryable)
  • RecognizeStream._main_task at livekit-agents/livekit/agents/stt/stt.py:471
  • the plugin's own segment retry loop at livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/stt.py:407 (if not e.retryable: raise)

All three now bail out immediately on ABORTED, which contradicts the explicit intent expressed in the retryable set. RESOURCE_EXHAUSTED (429) and UNKNOWN/INTERNAL/UNAVAILABLE (-1/500/503) are unaffected; ABORTED is the only entry whose flag is silently inverted.

Prompt for agents
In livekit-plugins/livekit-plugins-nvidia/livekit/plugins/nvidia/stt.py, the helper _to_stt_api_error maps grpc.StatusCode.ABORTED to HTTP status 409 while simultaneously including ABORTED in the set of retryable gRPC codes. However, APIStatusError.__init__ in livekit-agents/livekit/agents/_exceptions.py unconditionally forces retryable=False for any 4xx status code other than 408, 429 and 499. The result is that ABORTED errors are always non-retryable, silently contradicting the explicit retryable set, and now that this PR made STT.recognize, RecognizeStream._main_task and the plugin's own _finish_recognition_segment loop all honour e.retryable, such transient aborts terminate recognition on the first attempt.

Decide which behaviour is correct. If ABORTED should be retried, avoid mapping it to a 4xx status that the base class treats as terminal (for example map it to 503/500, or use a status the base class permits such as 409 -> not applicable, or construct the error via a type that does not apply the 4xx override). If ABORTED should not be retried, remove it from the retryable set so the code reads consistently. Apply the same reasoning check to the other entries in status_codes to confirm none of them have their retryable flag silently inverted.
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.

2 participants