Skip to content

Add Vui TTS plugin (local streaming TTS on CUDA / Apple Silicon) - #7243

Open
mogwai wants to merge 5 commits into
livekit:mainfrom
mogwai:vui-plugin
Open

Add Vui TTS plugin (local streaming TTS on CUDA / Apple Silicon)#7243
mogwai wants to merge 5 commits into
livekit:mainfrom
mogwai:vui-plugin

Conversation

@mogwai

@mogwai mogwai commented Sep 11, 2026

Copy link
Copy Markdown

What

A livekit-plugins-vui TTS plugin for Vui Nano — a small, context-aware text-to-speech model trained on real conversations (219M active / 305M total parameters, Apache 2.0). It runs in-process through the vui-tts package: on an NVIDIA GPU, or on MLX on Apple Silicon. No API key; weights and the shipped voice prompts download from Hugging Face on first use (prewarm() does it ahead of the first request).

from livekit.plugins import vui
tts = vui.TTS(voice="maeve")   # maeve / abraham / rhian / harry, a prompt .safetensors, or a .wav to clone

Design

  • synthesize() renders one text and rewinds the model to the voice prompt.
  • stream() renders each sentence as the LLM produces it (via tokenize.basic.SentenceTokenizer) in one conversation row, so the model conditions each sentence on what it just said and prosody carries across the reply. The row is rewound when input ends.
  • Everything that touches the engine — load, prefill, decode, rewind — runs on a single worker thread; CUDA graphs and MLX streams are bound to the thread that created them. Renders serialise behind a lock.
  • Interruption sets a cancel event the decode loop checks at every frame.

Verified

On an M4 (MLX, int8): one-shot 5.5 s of audio in 3.1 s (1.8×); streamed input 5.5 s in 2.3 s (2.4×), first audio at 407 ms. Output transcribed with moonshine ASR. ruff check / ruff format clean; uv lock regenerated.

I'm the author of the model (Fluxions AI) and will maintain the plugin.

Update — CUDA verified too (RTX 5090, torch 2.11, plain pip install without flash-attn, sharing the GPU with another job): one-shot 6.6 s of audio in 2.7 s (2.4×); streamed 8.2 s in 3.3 s (2.5×) with first audio at 48 ms; both 0.0% WER by moonshine. Devin Review flags addressed — see the comment below.

Scenario verification (exact PR head, MLX on M4 and CUDA on a 5090): one-shot; a three-sentence streamed reply in one row; a stream interrupted after 6 frames (aclose()) followed by a one-shot on the same row; two concurrent synthesize() calls (serialised, both complete); a voice switch via update_options; and aclose() cancelled mid-render then retried (engine released, executor shut down, no live streams). Every rendered wav transcribed by moonshine: 0.0% WER on both backends apart from one ASR-normalisation "OK"/"okay" and one single-word slip in the 40-word streamed reply on CUDA. First audio 48 ms on CUDA, ~400 ms on M4.

@mogwai
mogwai requested a review from a team as a code owner September 11, 2026 23:57
@CLAassistant

CLAassistant commented Sep 11, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

Vui Nano is a small, context-aware text-to-speech model trained on real
conversations (219M active / 305M total parameters, Apache 2.0). The plugin
runs the model in-process via the `vui-tts` package — on an NVIDIA GPU, or on
MLX on Apple Silicon — with no API key; weights and voice prompts download
from Hugging Face on first use (prewarm() does it ahead of time).

- synthesize(): one-shot render, rewound to the voice prompt afterwards.
- stream(): sentences are rendered as the LLM produces them, in one
  conversation row, so prosody carries across the reply.
- All engine work (load, prefill, decode, rewind) runs on one worker thread —
  CUDA graphs and MLX streams are bound to the thread that created them.
- voice= is one of the shipped voices, a prompt .safetensors baked with Vui's
  scripts/build_prompts.py, or a .wav to clone.

Measured on an M4 (MLX, int8): one-shot 5.5s of audio in 3.1s; streamed input
5.5s in 2.3s with first audio at 407ms.
@mogwai

mogwai commented Sep 12, 2026

Copy link
Copy Markdown
Author

Addressed the three Devin Review flags (they're hidden on the PR by the org settings, so summarising here):

  • CUDA rewind retained prior speech — real, and an engine-level issue: CUDA Row.rewind() restored the KV length but left the previous turn in the codec's streaming context (the MLX engine already re-seeded). Fixed upstream in vui-tts 1.1.3 (vui-tts 1.1.3: CUDA rewind() re-seeds the codec context from the prompt fluxions-ai/vui#38), verified on a 5090: after a rewind the codec context holds exactly the prompt frames and the next turn renders at 0.0% WER. The plugin now requires vui-tts>=1.1.3.
  • Concurrent requests shared the row — real. A SynthesizeStream now owns the engine lock from its first sentence to its end, so streams can't interleave sentences or rewind each other's context; ChunkedStream takes the lock per call.
  • Transcriptless .wav cloning — real. It now raises a clear error pointing at a sibling .txt, a baked prompt .safetensors, or vui-tts[server] for automatic transcription, instead of failing on the missing whisper import.

Rebased on current main (lock regenerated). Re-verified on an M4: one-shot and streamed synthesis both 0.0% WER by moonshine. CLA: signing.

…vs, require vui-tts 1.1.3

- A SynthesizeStream holds the engine lock from its first sentence to its
  end, so two streams can no longer interleave sentences on the single row or
  rewind each other's context; ChunkedStream takes the lock per call.
- A .wav voice without a transcript now raises a clear error (sibling .txt,
  a baked prompt .safetensors, or `vui-tts[server]` for transcription)
  instead of failing on the missing openai-whisper import.
- vui-tts >= 1.1.3: CUDA Row.rewind() now re-seeds the codec context from
  the voice prompt, so a turn no longer decodes against the previous turn's
  tail. Lock regenerated.

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

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

Devin Review

Comment on lines +289 to +290
async def aclose(self) -> None:
self._executor.shutdown(wait=False)

@devin-ai-integration devin-ai-integration Bot Sep 12, 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.

🔴 Synthesis can outlive shutdown

When synthesize() or stream() runs after shutdown begins, it escapes aclose()’s stream snapshot. It can recreate _engine after cleanup clears it, leaving GPU allocations alive after executor shutdown.

Learn more

Shutdown marks the TTS instance closed and snapshots the current weak set before releasing the row and executor. The synthesis entry points do not check that state. A new stream created after the snapshot starts its base-class task immediately, but shutdown never cancels it. Once cleanup clears _row and _engine, the missed task can enter _render_blocking, initialize another engine, and leave that engine outside the row being closed.

Example: aclose() snapshots one active stream and awaits its cancellation. Another task calls synthesize("hello") during that await. Cleanup then clears the engine, while the new stream later initializes a replacement that cleanup never closes.

Recommended fix: Reject synthesize(), stream(), and prewarm() once closing starts. Also synchronize stream registration with the transition to closing so no stream can appear between the state check and the shutdown snapshot.

Devin Review

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

…errides

aclose() now cancels live streams (tracked in a WeakSet, as the other
streaming plugins do), waits for the worker thread off the event loop, closes
the row and drops the engine reference, so a session that closes mid-sentence
no longer leaves a render running or the model reachable. Idempotent.

mypy: vui-tts and torchcodec are analysed as opaque (ignore_missing_imports +
follow_imports = "skip"), matching the existing overrides for other
third-party SDKs — torchcodec re-exports its decoders without declaring them,
and vui-tts only ships py.typed from 1.1.4.
@mogwai

mogwai commented Sep 12, 2026

Copy link
Copy Markdown
Author

Two more updates:

  • Shutdown (the new Devin flag — real): aclose() now cancels live streams (tracked in a WeakSet like the other streaming plugins), waits for the worker thread off the event loop, closes the row and drops the engine reference, so a session closing mid-sentence no longer leaves a render running or the model reachable. Idempotent.
  • type-check CI: the failures were untyped-dependency noise (vui.* had no py.typed — fixed at the source in vui-tts 1.1.4 — and torchcodec.decoders re-exports AudioDecoder without declaring it). Added a mypy override for vui.* / torchcodec.* alongside the existing ones for mistralai, smithy, etc. mypy -p livekit.plugins.vui passes locally with the CI invocation.

Re-verified after the change: MLX one-shot and streamed synthesis 0.0% WER with aclose() at the end of the run; CUDA numbers are in the description.

devin-ai-integration[bot]

This comment was marked as resolved.

`_closed` was set before cleanup ran, so an aclose() cancelled part-way
left the row, worker thread and model allocated with every later call
returning immediately. Now every step is idempotent and the flag is set
only once all of them complete, under a lock, so a retry finishes the job.
Verified: cancel aclose() mid-render, retry completes with the engine
released and the executor shut down.
@mogwai

mogwai commented Sep 12, 2026

Copy link
Copy Markdown
Author

Fixed the cancelled-shutdown case: aclose() no longer sets _closed up front. Every step (close streams, close row, shut the executor) is idempotent and the flag is set only once all of them complete, under a lock, so a call cancelled part-way is finished by the next one. Verified with a test that cancels aclose() mid-render and retries: the retry completes with the engine released, the executor shut down and no live streams; a third call is a no-op. mypy and ruff clean; normal synthesis re-verified after the change.

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