STT & TTS Providers¶
RoomKit's voice pipeline uses pluggable Speech-to-Text (STT) and Text-to-Speech (TTS) providers. This guide covers all built-in providers, their configuration, and streaming capabilities.
STT Provider ABC¶
from __future__ import annotations
from roomkit.voice.stt import STTProvider, TranscriptionResult
class STTProvider(ABC):
@property
def name(self) -> str: ...
@property
def supports_streaming(self) -> bool: ...
@property
def supports_language_override(self) -> bool: ...
"""Whether transcribe/transcribe_stream honour a per-call language."""
@property
def supports_diarization(self) -> bool: ...
"""Whether final results say who spoke (TranscriptionResult.segments)."""
async def transcribe(self, audio, *, language=None) -> TranscriptionResult:
"""Batch transcription — send all audio, get full text."""
async def transcribe_stream(
self, audio_stream, *, language=None
) -> AsyncIterator[TranscriptionResult]:
"""Streaming transcription — get partial results in real time."""
async def warmup(self) -> None:
"""Pre-load models (optional)."""
async def close(self) -> None:
"""Release resources."""
language on a call overrides the provider's configured language for that
call only. A provider that cannot honour it answers False to
supports_language_override, and VoiceChannel never passes a language to
such a provider — an implementation written against the older signature
keeps working unchanged.
TranscriptionResult¶
@dataclass
class TranscriptionResult:
text: str
is_final: bool = True
confidence: float | None = None
language: str | None = None
words: list[dict[str, Any]] = []
is_speech_start: bool = False
segments: list[SpeakerSegment] = [] # who said what (RFC §12.2.3)
@property
def speaker(self) -> str | None: ... # the label all segments share
@dataclass(frozen=True)
class SpeakerSegment:
speaker: str | None # provider label, e.g. "A"; None = unattributed
text: str
start_ms: int | None = None
end_ms: int | None = None
language is what the provider reports — what it detected when it was
asked to detect — never an echo of the language it was configured with. A
Deepgram stream pinned to fr-CA reports nothing; one opened in multi
reports the language most words carried.
Speaker labels from the STT¶
A recogniser that diarizes itself reports supports_diarization = True and
gives every final the segments its text is made of, each with one speaker.
A label is the provider's ("A", "B"…), opaque, and holds within one
stream only: another stream may give the same voice another label.
async for result in stt.transcribe_stream(audio):
if result.is_final:
print(f"{result.speaker}: {result.text}") # A: Bonjour Julie…
Through a VoiceChannel (RFC §12.2.3), in continuous mode — a streaming
STT and no VAD in the pipeline:
- The channel keeps one stream across turns, so the labels compare. It keeps the stream's audio level with the clock: whenever the audio pauses and the stream is behind — a microphone muted during playback, packets lost over a long call — it fills the gap with silence, since Meta Muse ends a stream whose audio falls behind real time (1008 after ~15 s). An echo transcript it discards does not end the stream either.
- Each segment is its own room message. The sender stays the session's
participant, the owner of the audio stream; the speaker is metadata:
speaker_label("A"),speaker_epochandsender_name, which the AI channel uses to attribute turns (see Multi-Speaker Rooms).sender_nameis"Speaker A","Speaker A#1"once a new stream (epoch) has started, and"Unknown speaker"for words nobody was attributed. ON_TRANSCRIPTIONfires per segment, its event carryingspeaker,speaker_epochandsender_name. A hook returning the event with anothersender_namenames that segment; every segment starts from the default name, so the hook names the label each time it sees it:
@kit.hook(HookTrigger.ON_TRANSCRIPTION)
async def name_known_voices(event, ctx):
if event.speaker == "A" and event.speaker_epoch == 0:
return HookResult.modify(dataclasses.replace(event, sender_name="Sylvain"))
return HookResult.allow()
ON_SPEAKER_CHANGEfires withsource="stt"when a segment's label differs from the last one routed, and on the first label of each epoch; the event carries the label'sspeaker_epoch, andconfidenceisNone. A turn detector never joins two speakers' segments: a change of voice routes the pending turn first. A session's segments reach the room in the order they were said, and the reply to one never holds back the next.- A new stream — the provider ended it, an error, a language change — starts a new epoch: the same letter afterwards is not assumed to be the same person.
A VoiceChannel behind a VAD (a stream per utterance) or in batch mode refuses
a diarizing provider at construction, and so does a ConferenceChannel, which
knows who spoke from each participant's track and transcribes utterance by
utterance.
Three providers fill segments: MetaSTTProvider in DIARIZATION mode
(labels per turn), DeepgramSTTProvider with diarize_model (labels per word)
and GeminiSTTProvider with speaker_segments (batch only). Labels are
normalised by roomkit.voice.base.speaker_label: a string, None for an
unattributed speaker, and without a leading "speaker" word ("Speaker 1" →
"1"). Deepgram's older diarize=True keeps its ids in words and does not
report supports_diarization.
This is the STT's own diarization, aligned to its words. The pipeline's
DiarizationProvider (see Audio Pipeline Stages)
is the other source: it labels audio frames and fires ON_SPEAKER_CHANGE with
source="pipeline". With both, the STT's label is the one on the transcript.
Speakers from the pipeline stage¶
When the STT labels nobody, VoiceChannel(pipeline_speakers=True) names each
transcript from the pipeline's DiarizationProvider instead (RFC §12.2.3):
voice = VoiceChannel(
"voice",
stt=stt, # any STT; a diarizing one keeps its own labels
backend=backend,
pipeline=AudioPipelineConfig(vad=vad, diarization=diarization),
pipeline_speakers=True,
)
- The transcript's speaker is the one the stage heard the longest over it:
the utterance behind a VAD, the audio since the last final in continuous
mode. Each result counts for the audio since the stage's previous one, so
a verdict sherpa-onnx gives over 2 s of speech outweighs one over a short
tail. The room message carries
speaker_label,speaker_epoch(always0: the stage keeps its labels for the session) andsender_name, andON_TRANSCRIPTIONcarries the speaker, so the hook above renames it the same way. - A voice the stage matched to nobody (sherpa-onnx says
"unknown"below itssearch_threshold) is"Unknown speaker". An utterance the stage gave no result for carries no speaker, as without the option. ON_SPEAKER_CHANGEstill comes from the stage alone, withsource="pipeline", and never for a voice it matched to nobody.- Refused without a diarization stage, and in batch mode, where one flush may hold several voices.
SherpaOnnxDiarizationProvider identifies enrolled voices, and only
behind a VAD: it extracts an embedding when an utterance ends (and every 2 s
of speech), so it names nobody in continuous mode. Its label is the enrolled
name: enroll "Sylvain" and the message reads "Speaker Sylvain" until a
hook names it. On a French two-voice dialogue (TitaNet embeddings, TEN VAD,
pauses lengthened so the VAD cut at every turn), 9 utterances of 9 went to the
right voice at search_threshold=0.4; at the default 0.5 one short
utterance scored 0.48 and came out as "Unknown speaker". On a laptop
microphone, the wrong voice never scored above 0.21 while the right one scored
0.36 to 0.66: a search_threshold around 0.35 suits it. Sub-second
utterances score low whoever speaks.
Deepgram (Cloud API)¶
The most feature-rich cloud STT provider. Real-time streaming with interim results, keyword boosting, and entity detection.
from __future__ import annotations
from roomkit.voice.stt.deepgram import DeepgramConfig, DeepgramSTTProvider
stt = DeepgramSTTProvider(
config=DeepgramConfig(
api_key="your-api-key",
model="nova-3",
language="en",
punctuate=True,
smart_format=True,
interim_results=True,
endpointing=300, # Silence duration (ms) before endpoint
vad_events=True, # Emit speech_start events
)
)
| Parameter | Default | Description |
|---|---|---|
model |
"nova-2" |
Model name ("nova-3" for multi and keyterms) |
language |
"en" |
Language code — "multi" detects and code-switches (Nova-3) |
punctuate |
True |
Add punctuation |
smart_format |
True |
Smart formatting (dates, numbers) |
numerals |
False |
Convert numbers to digits |
interim_results |
True |
Include partial results while speaking |
endpointing |
300 |
Silence ms before utterance end, or False to disable |
utterance_end_ms |
None |
Additional utterance end signal |
vad_events |
True |
Emit VAD events |
diarize |
False |
Deepgram's older diarizer; the ids stay in words |
diarize_model |
None |
Speaker segments ("latest"): every final carries segments; exclusive with diarize |
filler_words |
False |
Include "um", "uh" |
keywords |
[] |
Keywords to boost recognition |
keyterm |
[] |
Key terms (Nova-3) |
profanity_filter |
False |
Filter profanity |
redact |
[] |
Redaction rules (e.g., ["pci", "ssn"]) |
detect_entities |
False |
Detect named entities |
Streaming events: SpeechStarted, Results (partial + final), UtteranceEnd
Batch mode: HTTP POST to /listen endpoint.
Streaming mode: WebSocket with real-time partials and finals.
Speaker segments (diarize_model)¶
diarize_model="latest" makes the provider report supports_diarization and
fill TranscriptionResult.segments on every final, in batch and in streaming
(see Speaker labels from the STT). Deepgram
labels each word, so a final spanning a change of voice becomes two segments;
labels are "0", "1"…, and the offsets are Deepgram's word times.
stt = DeepgramSTTProvider(
DeepgramConfig(api_key="...", model="nova-3", language="fr", diarize_model="latest")
)
result = await stt.transcribe(recording)
for segment in result.segments:
print(f"{segment.speaker}: {segment.text}")
Measured on a two-voice French dialogue (2026-09-27):
- Batch attributed the 5 turns correctly, with their offsets.
- Streaming labelled every word
0for about the first 30 seconds, then told the voices apart, mostly correctly. Through a continuousVoiceChannelthe same dialogue played three times gave one stream, "Speaker 0" throughout the first pass and alternating speakers after. For live attribution from the first sentence, Meta Muse'sDIARIZATIONdid better on the same audio. diarize=True, the older diarizer, labelled every word0in both modes. It is kept as it was — ids inwords, no segments, nosupports_diarization— so an existing configuration behaves the same, aVoiceChannelbehind a VAD included.
diarize_model travels as a query parameter, so it works with every SDK the
deepgram extra allows.
Language: detect, then lock¶
Nova-3 transcribes code-switched speech with language="multi", and reports
what it heard — TranscriptionResult.language carries the language most words
were tagged with. A stream pinned to the speaker's language (fr-CA) is
measurably better than multi, but Deepgram fixes the language in the
WebSocket URL: it holds for the life of a stream. RoomKit opens one stream per
utterance (VAD mode) or per turn (continuous mode), so the language can change
between them — never inside one.
VoiceChannel.set_stt_language chooses the language for one session from its
next stream on; None returns to the provider's configuration. The typical
caller is an ON_TRANSCRIPTION hook reading event.language:
stt = DeepgramSTTProvider(DeepgramConfig(api_key="...", model="nova-3", language="multi"))
voice = VoiceChannel("voice", stt=stt, backend=backend, pipeline=AudioPipelineConfig())
@kit.hook(HookTrigger.ON_TRANSCRIPTION)
async def pin_language(event, ctx):
# A code while the stream detects, None once it is pinned
if event.language == "fr":
voice.set_stt_language(event.session, "fr-CA")
return HookResult.allow()
STTLanguageLock packages that loop, with a way back:
from roomkit import STTLanguageLock
voice = VoiceChannel(
"voice",
stt=stt,
backend=backend,
pipeline=AudioPipelineConfig(),
stt_language_lock=STTLanguageLock(
detect_language="multi", # every session starts here
prefer={"fr": "fr-CA"}, # reported -> locked
lock_after=1, # agreeing finals before locking
release_after=2, # consecutive misses before detecting again
min_confidence=0.5, # a final below this is a miss
),
)
| Parameter | Default | Description |
|---|---|---|
detect_language |
"multi" |
Language every session starts in, and returns to |
prefer |
{} |
Reported code → locked code (Deepgram reports fr, you want fr-CA) |
lock_after |
1 |
Consecutive finals reporting the same language before locking |
release_after |
2 |
Consecutive misses before releasing back to detect_language |
min_confidence |
0.5 |
A final below this confidence is a miss |
A miss is a final with no text, a confidence below min_confidence, or a
reported language other than the lock; a fitting final resets the count. A
locked stream reports no language, so the way back is noise: a caller who
switches to English on a fr-CA stream produces empties and low-confidence
finals, and two of those in a row reopen the session in multi.
Where the change lands:
- VAD mode — the next utterance. A stream already open stays open, so the utterance in progress is not cut in two.
- Continuous mode — right away: the current cycle is ended and the loop reconnects with the new language. Audio arriving in the gap is kept.
- Batch mode — the next
flush_stt().
Runnable: examples/voice_deepgram_language_lock.py.
SherpaOnnx (Local/Offline)¶
Run STT locally without API calls using ONNX models. Supports transducer (streaming) and Whisper (batch) modes.
from __future__ import annotations
from roomkit.voice.stt.sherpa_onnx import SherpaOnnxSTTConfig, SherpaOnnxSTTProvider
# Streaming transducer mode
stt = SherpaOnnxSTTProvider(
config=SherpaOnnxSTTConfig(
mode="transducer",
tokens="path/to/tokens.txt",
encoder="path/to/encoder.onnx",
decoder="path/to/decoder.onnx",
joiner="path/to/joiner.onnx",
sample_rate=16000,
num_threads=2,
provider="cpu", # or "cuda"
enable_endpoint_detection=True,
rule1_min_trailing_silence=2.4, # Seconds
rule2_min_trailing_silence=1.2,
rule3_min_utterance_length=20.0,
)
)
# Batch Whisper mode (no streaming)
stt_whisper = SherpaOnnxSTTProvider(
config=SherpaOnnxSTTConfig(
mode="whisper",
tokens="path/to/tokens.txt",
encoder="path/to/encoder.onnx",
decoder="path/to/decoder.onnx",
language="en",
task="transcribe", # or "translate" for English translation
)
)
| Parameter | Default | Description |
|---|---|---|
mode |
required | "transducer" (streaming) or "whisper" (batch only) |
tokens |
required | Path to tokens.txt |
encoder |
required | Path to encoder ONNX model |
decoder |
required | Path to decoder ONNX model |
joiner |
None |
Path to joiner ONNX (transducer only) |
sample_rate |
16000 |
Expected input sample rate |
num_threads |
2 |
CPU threads for inference |
provider |
"cpu" |
ONNX runtime: "cpu" or "cuda" |
enable_endpoint_detection |
True |
Detect utterance endpoints |
rule1_min_trailing_silence |
2.4 |
Silence threshold (seconds) for rule 1 |
rule2_min_trailing_silence |
1.2 |
Silence with text threshold |
rule3_min_utterance_length |
20.0 |
Min utterance length for rule 3 |
Gradium (Cloud API)¶
Cloud STT with built-in server-side VAD and pre-connect buffering to avoid lost first words.
from __future__ import annotations
from roomkit.voice.stt.gradium import GradiumSTTConfig, GradiumSTTProvider
stt = GradiumSTTProvider(
config=GradiumSTTConfig(
api_key="your-api-key",
region="us",
model_name="default",
input_format="pcm",
language="en",
connect_buffer_ms=300, # Buffer audio before WebSocket opens
delay_in_frames=7, # Processing delay (7-48, each = 80ms)
vad_threshold=0.9, # VAD inactivity threshold
vad_steps=10, # Steps above threshold to confirm end
timeout_s=3.0, # Server inactivity timeout
)
)
Streaming events: text (partial), end_text (segment done), step (VAD heartbeat)
Pre-connect buffering: Accumulates real audio before opening the WebSocket, then sends a burst — avoids losing the first few words.
Qwen3 ASR (Local/GPU)¶
HuggingFace-based ASR with optional vLLM backend for streaming.
from __future__ import annotations
from roomkit.voice.stt.qwen3 import Qwen3ASRConfig, Qwen3ASRProvider
stt = Qwen3ASRProvider(
config=Qwen3ASRConfig(
model_id="Qwen/Qwen3-ASR-0.6B",
backend="vllm", # "vllm" for streaming, "transformers" for batch
device_map="auto",
dtype="bfloat16",
language=None, # None = auto-detect
chunk_size_sec=2.0, # Streaming chunk duration
gpu_memory_utilization=0.3,
max_new_tokens=2048,
)
)
| Parameter | Default | Description |
|---|---|---|
model_id |
"Qwen/Qwen3-ASR-0.6B" |
HuggingFace model ID |
backend |
"transformers" |
"transformers" (batch) or "vllm" (batch + streaming) |
dtype |
"bfloat16" |
Model precision |
language |
None |
Language code or None for auto-detect |
chunk_size_sec |
2.0 |
Streaming chunk duration (vLLM only) |
gpu_memory_utilization |
0.3 |
GPU memory fraction (vLLM only) |
Gemini (Cloud API, batch only)¶
The API takes a complete recording and answers in one pass, in seconds, so this provider is batch by construction. For live turn-taking use Gemini Transcribe below, which drives Google's dedicated recogniser over a WebSocket.
What the batch shape buys is what a streaming recogniser structurally cannot give. The model sees the whole recording before it answers, so one pass returns the transcript, the speaker turns and the timestamps together — no diarization stage, no merge. This is the provider for meeting recordings, voicemail and imported audio files.
from __future__ import annotations
from roomkit.voice.stt.gemini import GeminiSTTConfig, GeminiSTTProvider
stt = GeminiSTTProvider(
config=GeminiSTTConfig(
api_key="your-gemini-api-key",
model="gemini-3.8-flash", # any multimodal model that accepts audio
language="fr-CA", # optional hint; detected otherwise
diarize=True, # ask for speaker labels
prompt="The product is spelled RoomKit.", # optional vocabulary/format
)
)
transcript = await stt.transcribe_recording("meeting.wav")
print(transcript.language) # "fr-CA"
for turn in transcript.segments:
print(f"[{turn.start}-{turn.end}] {turn.speaker}: {turn.text}")
| Parameter | Default | Description |
|---|---|---|
api_key |
(required) | Gemini API key (GEMINI_API_KEY) |
model |
"gemini-3.8-flash" |
A multimodal model that accepts audio input |
language |
None |
BCP-47 hint; unset, the model identifies and reports it |
diarize |
True |
Ask for Speaker 1, Speaker 2, … labels |
prompt |
None |
Extra instruction: formatting rules. Refused on the recogniser |
timeout |
600.0 |
Per-request timeout in seconds |
max_inline_bytes |
15 MiB |
Above this, the recording is uploaded via the Files API |
mode |
"verbatim" |
"smart" for the recogniser's cleaned-up transcript (recogniser only) |
custom_vocabulary |
[] |
Terms to spell as written, up to 1000; in the prompt of a multimodal model |
word_timestamps |
True |
Time every word (recogniser only) |
speaker_segments |
False |
Put the turns on transcribe()'s result as segments; needs diarize |
Speaker turns in the shared contract¶
transcribe_recording() returns Gemini's own Transcript, whose turns carry
MM:SS strings. transcript.speaker_segments() gives the same turns as the
shared SpeakerSegment (label "1", "2"…, offsets in milliseconds — to the
100 ms word time on the recogniser, to the second otherwise), the type Meta
and Deepgram fill too. With speaker_segments=True, transcribe() returns
them directly in TranscriptionResult.segments and the provider reports
supports_diarization:
stt = GeminiSTTProvider(GeminiSTTConfig(api_key="...", speaker_segments=True))
result = await stt.transcribe(recording)
for segment in result.segments:
print(f"{segment.speaker}: {segment.text}")
It is off by default because diarize has always been on: a diarizing STT is
refused by a VoiceChannel behind a VAD, where labels would not compare across
utterances (see Speaker labels from the STT).
Both kinds of model attributed the two-voice French dialogue's 5 turns
correctly (2026-09-27).
Two kinds of model¶
model picks how the transcript is obtained:
- A multimodal model (the default,
gemini-3.8-flash) is instructed: a prompt asks for the transcript and a JSON schema shapes it. Turns are timed to the second, and long recordings are fine. - The dedicated recogniser,
gemini-3.5-transcribe, is configured: it refuses a prompt and a response schema, and takes atranscription_configinstead. It answers about twice as fast and times every word to 100 ms, intranscript.words.
Measured against the live API on 2026-09-27, on a 12-second, two-speaker French dialogue:
gemini-3.8-flash |
gemini-3.5-transcribe |
|
|---|---|---|
| Time | 2.9–4.0 s | 1.7–2.7 s |
| Speaker turns | 4, correct | 4, correct |
| Timing | per turn, MM:SS |
per word, 100 ms |
| Longest recording | long files | 1 hour; 30 minutes with speakers or word timing |
stt = GeminiSTTProvider(
GeminiSTTConfig(api_key="...", model="gemini-3.5-transcribe", language="fr-CA")
)
transcript = await stt.transcribe_recording("meeting.wav")
for word in transcript.words:
print(f"{word.start:.1f}-{word.end:.1f}s {word.speaker}: {word.text}")
The recogniser's options exclude each other the way the service does, and the config refuses a forbidden pair at construction rather than on the first call:
| Option | Needs | Excludes |
|---|---|---|
diarize=True |
word_timestamps=True — speakers ride on the words |
|
mode="smart" |
diarize, word_timestamps |
|
custom_vocabulary |
language — without one the service answers the first sentence alone |
diarize, word_timestamps |
prompt |
always refused |
Two more differences to know. The recogniser detects the language but never
reports it, so transcript.language is empty unless you set language
(short codes such as "fr" are accepted). And raw PCM is sent to every model
inside a WAV header, because the recogniser refuses bare audio/l16.
Two methods, two shapes¶
transcribe() is the STTProvider contract and returns flat text, dropping the
structure. transcribe_recording() returns the whole Transcript — the
language plus one TranscriptSegment per speaker turn, with .text (labelled)
and .plain_text (words only) helpers, and, from the recogniser, the timed
words. It also accepts a file path, which transcribe() does not.
Input paths¶
Raw AudioChunk/AudioFrame audio is sent inline as WAV; a data: URL is sent
inline as-is; a local path is inlined below max_inline_bytes and uploaded
through the Files API above it (and deleted afterwards, rather than left to
expire). Arbitrary http(s) URLs are refused, not fetched: dereferencing a
caller-supplied URL would make the provider an SSRF vector. Upload the file or
pass the bytes.
Limits worth knowing¶
supports_streaming is False, so a VoiceChannel transcribes on SPEECH_END
instead of streaming partials — mechanically fine, but seconds of model latency
after every utterance is not a conversation. Use batch_mode=True or transcribe
a finished recording.
Speaker labels are the model's judgement, not an acoustic decision. On a live
run against a four-turn recording with two voices, the model labelled the fourth
turn Speaker 3 although it was the second voice again (2026-08-07). Where the
speakers are already separated — a conference records one track per
participant — transcribe each track with diarize=False and merge on the
timestamps instead. The labels earn their keep on a single mixed file.
A multimodal model's timestamps are its reading, not a forced alignment: good for navigating a recording, not for syncing against anything. The recogniser's word offsets are measured, at 100 ms.
See examples/meeting_transcription.py — a recording becomes speaker turns,
enters a room, and an AI channel writes the minutes. Run it with
GEMINI_STT_MODEL=gemini-3.5-transcribe for the recogniser and its word
timing.
Gemini Transcribe (Cloud API, streaming)¶
gemini-3.5-transcribe-live is a dedicated recogniser reached over the Live
API, not a chat model asked to write down what it hears. It takes a PCM stream
over a WebSocket and answers with interim transcripts while the speaker is
still talking, then a final one per utterance — the shape a VoiceChannel
wants for live turn-taking, and the opposite of the batch provider above.
from __future__ import annotations
from roomkit.voice.stt.gemini_transcribe import (
GeminiTranscribeConfig,
GeminiTranscribeProvider,
)
stt = GeminiTranscribeProvider(
GeminiTranscribeConfig(
api_key="your-gemini-key",
# Empty is not a missing value: it asks the model to identify the
# language itself, across the 85+ locales it covers.
language_codes=[],
custom_vocabulary=["RoomKit", "Aubervilliers"],
mode="VERBATIM",
)
)
| Option | Default | Meaning |
|---|---|---|
api_key |
(required) | Gemini API key (GEMINI_API_KEY) |
model |
gemini-3.5-transcribe-live |
Live transcription model id |
language_codes |
[] |
BCP-47 hints; empty means automatic detection |
custom_vocabulary |
[] |
Up to 1000 terms to bias towards; best below ~100 |
mode |
VERBATIM |
VERBATIM keeps what was said, SMART removes disfluencies and formats |
timeout |
60.0 |
Read budget in seconds |
connect_timeout |
5.0 |
TCP connect timeout in seconds |
Install with pip install roomkit[realtime-gemini] — the Live API extra, the
same one Gemini Live uses.
Limits worth knowing¶
Audio must be raw 16-bit PCM, 16 kHz, mono, little-endian. Another rate is sent
as it is, with its true rate in the MIME type and one warning: guessing a
resample inside the provider would hide a pipeline that is misconfigured
upstream. Resample before you get here — examples/stt_gemini_transcribe_live.py
shows it with RoomKit's own resampler.
Speaker diarization and word-level timestamps are not available over the Live API, whatever the batch model offers, so the config carries no knob for either. A session is capped at ten minutes. Both are reasons a long recording belongs to the batch provider above, which also returns the speaker turns.
transcribe() works and runs the audio through the same socket, which keeps
one client, one auth path and one config; the ten-minute cap applies to it too.
Two examples. examples/stt_gemini_transcribe_mic.py is the one to run first:
speak into your microphone and the caption line rewrites itself while you talk,
committing on a pause. That is what the streaming shape buys, and real speech
exercises the hesitation, accent and language detection a synthesized sentence
cannot. examples/stt_gemini_transcribe_live.py does the same from a file or a
synthesized sentence, for a machine with no audio device.
Meta Muse Voice Transcribe (Cloud API, streaming + batch)¶
muse-voice-transcribe-1.0 is Meta's recogniser on the Meta Model API. One
provider speaks both of its endpoints: a realtime WebSocket that answers
interim transcripts while the speaker talks and, when asked, finds where each
turn ends by itself; and a REST endpoint for one finished clip.
from __future__ import annotations
from roomkit.voice.stt.meta import MetaSTTConfig, MetaSTTProvider
stt = MetaSTTProvider(
MetaSTTConfig(
api_key="your-meta-model-api-key",
mode="ENDPOINTING", # no pipeline VAD: the model ends each turn
language_bias=["French"],
keywords=["RoomKit", "Tremblay"],
)
)
| Option | Default | Meaning |
|---|---|---|
api_key |
(required) | Meta Model API key |
model |
muse-voice-transcribe-1.0 |
Transcription model id |
mode |
ENDPOINTING |
How a stream ends — see below |
keywords |
[] |
Terms to bias recognition toward; a bias, not a guaranteed spelling |
language_bias |
[] |
Language names to bias toward ("French", not "fr") |
base_url |
https://api.meta.ai/v1 |
REST endpoint root |
realtime_url |
wss://api.meta.ai/v1/asr/realtime |
Realtime WebSocket endpoint |
timeout_s |
60.0 |
REST transcription timeout |
handshake_timeout_s |
10.0 |
How long a stream waits for the service to accept its configuration |
Install with pip install roomkit[meta-stt] (websockets for streams,
httpx for transcribe()).
Pick the mode from the channel¶
The provider cannot see how the VoiceChannel is set up, and the right mode
depends on it:
| Channel | Mode | What the model does |
|---|---|---|
| No pipeline VAD — continuous STT | ENDPOINTING |
Signals speech start (ON_SPEECH_START fires), sends interims, and ends each turn itself after about 550 ms of silence |
| Pipeline VAD delimits utterances | PUSH_TO_TALK |
Sends interims, then one final when the channel closes the utterance's stream |
| No pipeline VAD, speakers wanted | DIARIZATION |
ENDPOINTING plus a speaker per turn; a change of voice also ends a turn |
ENDPOINTING behind a pipeline VAD would hand the channel several finals for
one utterance, of which it keeps the last. The endpointing silence is not
configurable, and two voices separated by less than that silence make one
turn.
Limits worth knowing¶
- Audio: mono 16-bit PCM at 16 or 24 kHz goes through as it is; any other rate is resampled to 24 kHz (the model's native rate) by the provider, so an 8 kHz telephony leg works without a pipeline resampler.
- Language:
language_biasbiases, it does not pin, so the provider reportssupports_language_override = Falseand the channel never passes it a per-call language. The names are checked at construction against the 25 Meta documents (SUPPORTED_LANGUAGES): the service itself accepts a misspelt one silently, and the bias then does nothing. Meta reports no detected language and no confidence. - Sessions: a stream lasts at most 60 minutes, then the service closes it
with code 1011. Every failure raises
MetaSTTError, which carries Meta'scodeanderror_type(e.g.billing_not_configured/billing_error) andretryable: true for a server fault (1011), a rate limit (1013, HTTP 429), a 5xx and a dropped connection, false for a refused request (1008, HTTP 4xx). - Backlog: the service refuses a stream sent 7 s or more ahead of real
time at once (
Audio processing backlog too large; 6 s passes, measured 2026-10-07). That happens after a reconnect: a handshake the service does not answer holds the stream forhandshake_timeout_s, and the microphone audio queues up meanwhile. The provider paces what it sends (at most 3 s at once, then twice real time until it has caught up), and a continuousVoiceChannelcarries at most the last 5 s of audio into the next stream, logging a warning with the seconds it dropped (RFC §12.2). - REST (
transcribe()): one WAV of at most 10 minutes and 32 MB, sent inPUSH_TO_TALKmode; the provider wraps the PCM in the WAV itself. AnAudioContentcarrying a WAVdata:URI is decoded locally; an http(s) URL is refused rather than fetched. - Speakers: in
DIARIZATIONmode every final carries its turn as oneSpeakerSegmentlabelled"A","B"…, with the turn's offsets; a turn Meta could not attribute hasspeaker=None.transcribe()answers the clip's turns as segments the same way. On a two-voice French dialogue (2026-09-27) both paths attributed 5 turns out of 5. Meta documents the mode as not tuned for low latency. AVoiceChannelin continuous mode carries the labels to the room (see Speaker labels from the STT); through one on the live service, the same dialogue gave 5 room messages on one stream, and a 20 s silence did not end it.
Two examples. examples/stt_meta_mic.py is the one to run first: speak into
your microphone and the caption line reacts as the model hears you start,
rewrites itself while you talk, and commits once you pause — the model's own
endpointing, with no VAD on RoomKit's side. Add --diarize and talk in turn
with someone: each committed line says who spoke (> A: …, > B: …).
examples/voice_meta_diarization.py does the same through a VoiceChannel:
each turn becomes a room message with its sender_name, and SPEAKER_NAMES
("A=Sylvain,B=Julie") names the voices you know. examples/stt_meta_live.py streams
a WAV file in real time in either mode and then sends it over REST, for a
machine with no audio device.
Microsoft MAI-Transcribe (Cloud API, streaming)¶
MAI-Transcribe-2-Streaming is Microsoft's realtime recogniser, served by a
Microsoft Foundry resource over a WebSocket modelled on the OpenAI Realtime
transcription protocol. It returns a transcript that firms up while the
speaker talks, in 57 languages including French, and detects the language by
itself unless told one.
from __future__ import annotations
import os
from roomkit.voice.pipeline import AudioPipelineConfig
from roomkit.voice.pipeline.vad.energy import EnergyVADProvider
from roomkit.voice.stt.azure_mai import AzureMAISTTConfig, AzureMAISTTProvider
stt = AzureMAISTTProvider(
AzureMAISTTConfig(
endpoint="https://<resource>.services.ai.azure.com",
api_key=os.environ["AZURE_SPEECH_KEY"],
language="fr-CA", # sent as "fr"; None detects
)
)
pipeline = AudioPipelineConfig(vad=EnergyVADProvider()) # required, see below
| Option | Default | Meaning |
|---|---|---|
endpoint |
(required) | The Foundry resource URL, as the portal shows it |
api_key |
(required) | The resource's key |
deployment |
MAI-Transcribe-2-Streaming |
The model's deployment name on the resource |
language |
None |
BCP-47 tag or service code; None lets the service detect it |
handshake_timeout_s |
10.0 |
How long a stream waits for the session to open and accept its settings |
final_timeout_s |
10.0 |
How long a stream waits for the final once its audio is committed |
Install with pip install roomkit[azure-speech] (websockets, no Azure SDK).
A pipeline VAD is required¶
The service detects no turns: it finalises the audio it has received when the
client commits it. Behind a pipeline VAD, a VoiceChannel opens one stream
per utterance and the provider commits when the utterance ends, so the final
follows the end of speech. Without a VAD the channel runs continuous STT, the
stream never ends, and no final comes. This also keeps silence off the wire:
the service bills per hour of audio sent.
Limits worth knowing¶
- Preview: the model is in public preview (October 2026), without an SLA.
- Measured (2026-10-08, from Québec to Sweden Central): an English and a French sentence, rendered by MAI-Voice and played back in real time, came back word for word, the French one with its language detected; the final arrived 125 to 146 ms after the end of speech, and partials every 250 ms or so while it spoke. The service writes numbers as figures ("quatorze heures trente" comes back as "14h30"). French accuracy on real speech is not measured yet.
- Regions: Microsoft serves the model from Sweden Central, Central US, East US 2 and Southeast Asia, and routes requests to them; there is no Canadian region.
- Audio: mono 16-bit PCM at 16 or 24 kHz goes through as it is; any other rate is resampled to 16 kHz by the provider. Audio leaves in appends of at most 100 ms.
- Language: the service takes language codes, not regions, so
fr-CAis sent asfr; the provider reportssupports_language_override = True. The provider takes the 57 codes inSUPPORTED_LANGUAGES: Microsoft lists 60, and the endpoint refuses three of them (as,or,pa). A code outside the list is refused at construction or before the stream opens, because the endpoint takes some the model does not know (no,tl,cy) and the model then detects instead. Detection is asked by leaving the language out: the endpoint refuses an explicitnull. The service reports no detected language, no confidence and no speaker. - Sessions: one stream is one session, and the service caps a session at
one hour; a stream per utterance never comes near it. Every failure raises
AzureMAISTTError, with the service'scodeanderror_typeandretryable: true for a server error, a rate limit, a 5xx or a 429 at the upgrade, a timeout and a dropped connection. - Batch (
transcribe()): there is no batch endpoint for this model, so a clip goes through one session, committed once. It takes anAudioChunkor anAudioFrame; anAudioContentis refused.
Two examples. examples/voice_azure_mai_agent.py is the one to run first: a
voice agent through your microphone, MAI-Transcribe hearing you behind a VAD,
Claude Haiku 5.5 answering in the language you spoke, MAI-Voice speaking
(VOICE=fr-FR-Soleil:MAI-Voice-2.1-Flash for French) at +15%. Its VAD
waits 800 ms of silence instead of 500: at 500, a pause in the middle of a
sentence ended the turn, and a word cut by it came back as two halves
transcribed out of context. Its barge-in is semantic, so an "okay" does not
stop the agent. examples/voice_azure_mai.py
needs no audio device: MAI-Voice speaks a sentence and plays it to
MAI-Transcribe in real time, logging the TTS's first audio and the STT's final
after the end of speech.
TTS Provider ABC¶
from __future__ import annotations
from roomkit.voice.tts import AudioChunk, AudioContent, TTSProvider
class TTSProvider(ABC):
@property
def name(self) -> str: ...
@property
def default_voice(self) -> str | None: ...
@property
def supports_streaming_input(self) -> bool: ...
async def synthesize(self, text, *, voice=None) -> AudioContent:
"""Batch synthesis — full text in, complete audio out."""
async def synthesize_stream(self, text, *, voice=None) -> AsyncIterator[AudioChunk]:
"""Streaming output — yields audio chunks as they're generated."""
async def synthesize_stream_input(self, text_stream, *, voice=None) -> AsyncIterator[AudioChunk]:
"""Streaming input — accepts async text stream, yields audio."""
async def warmup(self) -> None:
"""Pre-load models (optional)."""
async def close(self) -> None:
"""Release resources."""
AudioChunk¶
@dataclass
class AudioChunk:
data: bytes
sample_rate: int = 16000
channels: int = 1
format: str = "pcm_s16le"
timestamp_ms: int | None = None
is_final: bool = False
ElevenLabs (Cloud API)¶
High-quality cloud TTS with streaming output, and request stitching from the conversation context.
from __future__ import annotations
from roomkit.voice.tts.elevenlabs import ElevenLabsConfig, ElevenLabsTTSProvider
tts = ElevenLabsTTSProvider(
config=ElevenLabsConfig(
api_key="your-api-key",
voice_id="21m00Tcm4TlvDq8ikWAM", # Rachel
model_id="eleven_multilingual_v2",
stability=0.5,
similarity_boost=0.75,
style=0.0,
use_speaker_boost=True,
output_format="mp3_44100_128",
optimize_streaming_latency=3, # 0-4, higher = faster; v2 / v2.5 models only
use_context=True, # request stitching (see below)
)
)
# List the account's voices, its own clones included, as VoiceInfo
for v in await tts.list_voices(language="fr"):
print(f"{v.id}: {v.name} ({v.attributes.get('category')})")
| Parameter | Default | Description |
|---|---|---|
voice_id |
"21m00Tcm4TlvDq8ikWAM" |
Voice ID (Rachel) |
model_id |
"eleven_multilingual_v2" |
TTS model |
stability |
0.5 |
Voice stability (0–1) |
similarity_boost |
0.75 |
Voice similarity (0–1) |
style |
0.0 |
Style exaggeration (0–1) |
output_format |
"mp3_44100_128" |
<codec>_<rate>[_<bitrate>]: mp3_44100_128, pcm_16000, ulaw_8000, opus_48000_64... Chunks declare the codec and the rate it names. wav_* is for synthesize() only: ElevenLabs refuses it on a streamed request. On a VoiceChannel, use pcm_<rate>: it plays PCM only and refuses MP3, Opus or G.711 chunks |
optimize_streaming_latency |
None |
Latency optimization level (0–4, higher is faster at some cost of quality). Sent to the v2 / v2.5 models only: v3 and v4 refuse it, and a value set for them is left out with a warning. None sends nothing |
expressive |
False |
Eleven v4 Turbo with inline audio tags (see below) |
use_context |
True |
Request stitching from the conversation context |
stream_input |
False |
Streaming text input over WebSocket for streaming AI responses (see below) |
Models. model_id takes any ElevenLabs text-to-speech model id; the module
exports the common ones as constants:
| Constant | Model id | Use |
|---|---|---|
MODEL_V4_TURBO |
eleven_v4_turbo |
Voice agents: audio tags, 90+ languages, ~150 ms to first speech (ElevenLabs' figure) |
MODEL_V4 |
eleven_v4 |
Highest quality for pre-generated audio, 10,000 characters per request |
MODEL_FLASH_V2_5 |
eleven_flash_v2_5 |
Lowest latency (~75 ms), 32 languages, no audio tags |
MODEL_MULTILINGUAL_V2 |
eleven_multilingual_v2 |
The default |
MODEL_V3 |
eleven_v3 |
Previous expressive model, without request stitching |
MODEL_TURBO_V2_5 is kept for existing code; ElevenLabs deprecated it in favour
of Flash v2.5.
Expressive mode. expressive=True selects eleven_v4_turbo, unless
model_id already names a v3 or v4 model, which is kept. The text may then
carry audio tags ([laughs], [whispers], [sighs], [excited], [pause]),
alone or stacked; ElevenLabs renders them instead of reading them out. v3 and
v4 do not take SSML <break> tags: use [pause] or an ellipsis. Do not set
StripBrackets as the channel's TTS filter, which would remove the tags.
examples/voice_expressive.py runs a voice assistant in this mode.
Three synthesis modes:
synthesize(): batch, returns complete audio as a base64 data URLsynthesize_stream(): streaming output, yields audio chunks over HTTPsynthesize_stream_input(): streaming input and output over a WebSocket
Streaming input. With stream_input=True (off by default) the provider
declares supports_streaming_input, so a Voice Channel speaks a streaming AI
response from its first sentence instead of waiting for the whole text. Each
sentence is sent and flushed as the LLM produces it, over one WebSocket per
response. Measured with an LLM writing a sentence every 0.8 s, first audio came
after 140 ms on v4 Turbo and flash v2.5 and 350 ms on multilingual v2, against
2.6 to 3.9 s on the HTTP path. The model picks the socket:
| Models | Socket | Voice settings |
|---|---|---|
eleven_v4_turbo, eleven_v4 (expressive mode included) |
Text to Dialogue | Not applied: the socket takes none. The provider warns at construction when stability, similarity_boost, style or use_speaker_boost differ from their defaults |
eleven_multilingual_v2, eleven_flash_v2*, eleven_turbo_v2* |
Text to Speech | Applied, with optimize_streaming_latency when set |
eleven_v3* |
None: supports_streaming_input is False |
It is opt-in because a streamed response is handled differently from one synthesized whole:
- the Voice Channel runs
BEFORE_TTSon each sentence before the TTS reads it, not once on the whole text: a hook judges one sentence at a time (aBLOCKdrops that sentence, aMODIFYreplaces it, a failing hook drops it) and sees it after the text filter;AFTER_TTSreceives the text as spoken; - a TTS failure (a 401, a 429, a network error) stops that session as a
barge-in does:
tts_errornames the session once its playback is released, and once every session has stopped the response is stored as it stood (withmetadata.cancelledwhen the generation was still running, which then ends) and nothing is replayed;ON_ERRORdoes not fire, the AI did not fail, andAFTER_TTSfires only if a session was served. The HTTP path reports a failed session the same way; - it is not stitched to the previous responses (below): neither socket takes a request id or a previous text.
say() and non-streaming AI responses keep the HTTP path either way.
Request stitching. With use_context=True (the default) the provider
declares TTSContextLevel.SELF and receives its own previous turns in the
voice session (TTS Conversation Context). Stitching applies
to the HTTP path, not to a response spoken over the socket. Each HTTP request then
carries the request_id of the up to three previous responses the user heard
to the end, in the same voice (previous_request_ids, ids younger than two
hours), so ElevenLabs continues the voice from one response to the next. When
no id is usable, the last response's text is passed as previous_text. After
a response the user cut off with a barge-in, nothing is sent and the next
response starts afresh. The user's words are never sent. v3 models (an eleven_v3*
model_id) do not support stitching and receive no context; v4 models, expressive
mode included, are stitched.
examples/voice_elevenlabs_context.py writes the same conversation with and
without stitching to two WAV files for comparison.
SherpaOnnx TTS (Local/Offline)¶
Local TTS using VITS/Piper ONNX models. No API calls required.
from __future__ import annotations
from roomkit.voice.tts.sherpa_onnx import SherpaOnnxTTSConfig, SherpaOnnxTTSProvider
tts = SherpaOnnxTTSProvider(
config=SherpaOnnxTTSConfig(
model="path/to/model.onnx",
tokens="path/to/tokens.txt",
data_dir="path/to/espeak-ng-data", # For Piper models
speaker_id=0, # Multi-speaker models
speed=1.0, # < 1.0 = faster, > 1.0 = slower
sample_rate=22050,
num_threads=2,
provider="cpu", # or "cuda"
)
)
Text splitting: Automatic sentence-based chunking (max 300 chars per chunk) with short-fragment merging.
Qwen3 TTS (Local/GPU, Voice Cloning)¶
LLM-based TTS with zero-shot voice cloning from reference audio.
from __future__ import annotations
from roomkit.voice.tts.qwen3 import Qwen3TTSConfig, Qwen3TTSProvider, VoiceCloneConfig
tts = Qwen3TTSProvider(
config=Qwen3TTSConfig(
model_id="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
device_map="auto",
dtype="bfloat16",
language="English",
voices={
"default": VoiceCloneConfig(
ref_audio="reference.wav", # 3s+ clean speech
ref_text="Transcript of the reference audio.",
),
"french": VoiceCloneConfig(
ref_audio="french_ref.wav",
ref_text="Transcription de l'audio de reference.",
),
},
temperature=0.6,
top_p=0.8,
repetition_penalty=1.05,
max_new_tokens=4096,
)
)
# Pre-load model and encode reference audio
await tts.warmup()
| Parameter | Default | Description |
|---|---|---|
model_id |
"Qwen/Qwen3-TTS-12Hz-1.7B-Base" |
HuggingFace model |
voices |
{} |
Voice name → VoiceCloneConfig mapping |
language |
"English" |
Default synthesis language |
temperature |
0.6 |
Sampling temperature |
top_p |
0.8 |
Nucleus sampling probability |
max_new_tokens |
4096 |
Max output tokens |
Voice cloning: Provide a 3+ second reference WAV and its transcript. The model learns the voice characteristics at warmup time.
Output: Fixed 24kHz PCM.
Grok TTS (Cloud API)¶
xAI Grok TTS with REST and bidirectional WebSocket streaming. 5 voices, 20 languages, expressive speech tags.
from __future__ import annotations
from roomkit.voice.tts.grok import GrokTTSConfig, GrokTTSProvider
tts = GrokTTSProvider(
config=GrokTTSConfig(
api_key="your-xai-api-key",
voice_id="eve", # eve, ara, rex, sal, leo
language="en", # BCP-47 code or "auto"
codec="pcm", # pcm, wav, mp3, mulaw, alaw
sample_rate=24000, # 8000–48000
)
)
Configuration¶
| Parameter | Default | Description |
|---|---|---|
api_key |
(required) | xAI API key |
voice_id |
"eve" |
Voice — eve, ara, rex, sal, leo |
language |
"en" |
BCP-47 language code or "auto" |
codec |
"pcm" |
Output codec: pcm, wav, mp3, mulaw, alaw. wav applies to synthesize(); a streamed request asks for pcm, since a WAV stream opens with a header that would be played as audio |
sample_rate |
24000 |
Sample rate in Hz |
bit_rate |
128000 |
MP3 bit rate (only used with codec="mp3") |
base_url |
"https://api.x.ai/v1" |
REST API base URL |
ws_url |
"wss://api.x.ai/v1/tts" |
WebSocket streaming URL |
timeout |
60.0 |
HTTP request timeout in seconds |
Streaming modes¶
synthesize_stream()— HTTP chunked streaming from the REST endpointsynthesize_stream_input()— Bidirectional WebSocket: sendtext.delta/text.done, receiveaudio.delta/audio.done. Starts speaking while the AI is still generating text.
Expressive speech tags¶
Grok TTS supports inline tags like [pause], [laugh], [sigh] and wrapping tags like <whisper>, <soft>, <loud>, <slow>, <fast>, <high-pitch>, <low-pitch>.
Gradium TTS (Cloud API)¶
Cloud TTS with streaming input support and fine-grained voice control.
from __future__ import annotations
from roomkit.voice.tts.gradium import GradiumTTSConfig, GradiumTTSProvider
tts = GradiumTTSProvider(
config=GradiumTTSConfig(
api_key="your-api-key",
voice_id="default",
region="us",
model_name="default",
output_format="pcm_16000", # "wav" applies to synthesize(); streams ask for "pcm"
temperature=0.7,
cfg_coef=2.0, # Voice similarity (1.0–4.0)
padding_bonus=0.0, # Speed: negative = faster, positive = slower
rewrite_rules="en", # Language-specific text rewriting
)
)
Gemini TTS (Cloud API)¶
Google's generative speech models. They perform the text rather than read it,
so a natural-language direction steers delivery — that is what style_prompt
is for. 30 prebuilt voices and more than 70 documented languages.
from __future__ import annotations
from roomkit.voice.tts.gemini import GeminiTTSConfig, GeminiTTSProvider
tts = GeminiTTSProvider(
config=GeminiTTSConfig(
api_key="your-gemini-api-key",
model="gemini-3.8-flash-tts", # the default
voice="Kore", # 30 prebuilt voices
language="fr-CA", # optional BCP-47 hint
style_prompt="calm and reassuring", # optional direction
)
)
Install with pip install roomkit[gemini] — the same extra the Gemini AI
provider and Gemini Live use. It requires google-genai>=2.25.0, the first
release that carries the 3.8 speech_metadata annotation.
Latency: not a conversational TTS¶
Gemini TTS trades latency for expressiveness. Measured against the live API for a one-sentence prompt, three runs per model:
| Model | Time to first audio | Streams incrementally |
|---|---|---|
gemini-3.8-flash-tts |
1.1–1.6 s (2026-09-27) | Yes |
gemini-3.8-flash-lite-tts |
~0.7 s (2026-09-27) | Yes |
gemini-3.1-flash-tts-preview |
~5.1 s median, 1.2–8.3 s (2026-08-06); 0.7–0.9 s (2026-09-27) | Yes — 40 ms frames |
gemini-2.5-flash-preview-tts |
~3.4 s median (2026-08-06) | No — one clip |
gemini-2.5-pro-preview-tts |
~5.3 s median (2026-08-06) | No — one clip |
A second of dead air before every reply still does not work for live turn-taking. Use Gemini TTS for prompts, announcements, voicemail and generated audio messages; for conversation reach for a low-latency engine (ElevenLabs, Gradium) or skip the text round trip entirely with Gemini Live speech-to-speech.
The default is gemini-3.8-flash-tts, Google's replacement for the 3.1
preview; gemini-3.8-flash-lite-tts is its low-latency, high-throughput
variant. Both stream as they generate, so playback starts on the first frame
instead of waiting for the whole clip.
Configuration¶
| Parameter | Default | Description |
|---|---|---|
api_key |
(required) | Gemini API key (GEMINI_API_KEY) |
model |
"gemini-3.8-flash-tts" |
One of the five models above |
voice |
"Kore" |
Prebuilt voice name |
language |
None |
BCP-47 hint; unset, the model infers it from the text |
style_prompt |
None |
Delivery direction for the whole utterance. Sent as speech_metadata from 3.8 on; written into the prompt on 3.1 and 2.5 (see below) |
timeout |
120.0 |
Per-request timeout in seconds |
Output format¶
Always 24 kHz, 16-bit, mono PCM — fixed by the service. The request accepts a
sample_rate field but the service ignores it, so the provider does not expose
the knob; attach a resampler stage when the transport needs
another rate.
synthesize() returns a WAV data: URL on every model. Up to 3.1 the service
answers bare PCM, which the provider wraps in a WAV header. From 3.8 it answers
a whole WAV file with a C2PA content-credentials chunk after the audio; the
provider passes that file through unchanged, so the provenance manifest reaches
you intact, and measures its duration from the audio alone.
synthesize_stream() yields bare PCM on every model.
Voices¶
GeminiTTSProvider.available_voices() returns the 30 prebuilt voices as
VoiceInfo records, offline — the same catalog Gemini Live native audio draws
from, so a voice chosen for one works in the other.
list_voices() reads Google's whole catalog instead: 2,089 voices on
2026-09-27, among them 68 Québec French (fr-CA, "Montreal French" accent) and
French from France. Each entry says its language, gender and accent, a
description, and under attributes Google's own fields (persona,
region_code, type). The filters behave as for every provider (RFC §12.2):
Google's own mean something else — its language filter wants an exact tag, so
fr finds nothing, and its search for Kore answers Korean voices — so the
provider reads everything (three pages, 0.4 s) and filters itself.
for v in await tts.list_voices(language="fr-CA", gender="female"):
print(v.id, "—", v.description)
# fr-ca-advisor-1 — 31-year-old Lawyer from Montreal. Speaks Montreal French. …
await tts.synthesize("Bonjour!", voice="fr-ca-advisor-1") # any listed id plays
Dialogue¶
synthesize_dialogue() voices a scripted exchange of two speakers in one clip,
each line with its own direction — a support-call role-play, a two-host
podcast. The speakers are bound to voices by name; a third speaker, or one the
map does not name, is refused before any request. The 3.1 and 2.5 models voice
no dialogue (max_dialogue_speakers is 0).
from roomkit.voice.voices import DialogueTurn
audio = await tts.synthesize_dialogue(
[
DialogueTurn(speaker="Client", text="Bonjour, ma facture me semble trop élevée."),
DialogueTurn(speaker="Conseillère", text="Je vérifie ça avec vous.", style="calme"),
],
{"Client": "fr-ca-advisor-2", "Conseillère": "fr-ca-advisor-1"},
)
Measured on 2026-09-27 with two fr-CA voices, a three-line call came back as
one 8.9 s WAV, and gemini-3.5-transcribe heard the three lines as two
alternating speakers. See examples/gemini_tts_voices.py.
Custom voices¶
GeminiVoiceLibrary makes voices the TTS then speaks with by their id (RFC
§12.2.4):
- Designed —
design_voice("a warm Montreal narrator in her forties…")returns a storedvoice_…in about 15 s, with a WAV preview. Google stores every designed voice, for a year:store=Falseis refused. Delete it withdelete_voice(). - Replicated —
replicate_voice(sample, consent)clones a person's voice from 10 to 30 s of their speech and a recording of them reading Google's consent statement word for word (CONSENT_STATEMENTS, e.g. fr-CA: « Je suis le propriétaire de cette voix et j'autorise Google à utiliser cette voix pour créer un modèle de voix synthétique. »). Both are 16-bit, 24 kHz mono WAV. Google refuses anything else, and the refusal is raised asVoiceConsentErrorwith Google's reason — it arrives as a 500 wrappingConsent flow failed.store=Falsereturns avoicekey_…good for seven days, which Google keeps nothing addressable for.
from roomkit.voice.tts.gemini_library import GeminiVoiceLibrary, GeminiVoiceLibraryConfig
library = GeminiVoiceLibrary(GeminiVoiceLibraryConfig(api_key="..."))
narrator = await library.design_voice("Une narratrice québécoise chaleureuse", name="Narratrice")
await tts.synthesize("Bienvenue!", voice=narrator.voice.id)
await library.delete_voice(narrator.voice.id)
The recordings go to Google for the call and nothing of them is kept or logged
(RFC §17.6); the library logs only which voice was made and that Google
verified the consent. See examples/gemini_voice_design.py.
Expressive audio tags¶
The delivery is steerable from the text itself. Bracketed cues placed inline in the transcript are performed, not read out — this is what a conventional concatenative engine cannot do, and what the latency buys:
await tts.synthesize("[laughs] Okay, that one was actually funny.")
await tts.synthesize("[whispers] Can you keep a secret? [excitedly] We shipped!")
Two kinds, both inline: non-verbal sounds ([laughs], [sighs], [gasp],
[cough]) and delivery modifiers ([whispers], [shouting], [excitedly],
[bored], [very slowly], [singing], [asmr]). Google documents no closed
list — any descriptive cue is interpreted — so listen to an unusual one before
relying on it: an unrecognised cue can be spoken aloud instead of performed.
With a non-English transcript, keep the tags in English.
For the 3.8 models Google documents an angle-bracket spelling — <laugh>,
<sigh>, <short pause>, and |mhm| for a listener's reaction in a dialogue.
Measured on 2026-09-27, both 3.8 models perform the square-bracket tags above
as well, so text written for 3.1 keeps working.
How style_prompt reaches the model depends on the model family:
- 3.8 and newer — the transcript goes out alone, and the direction rides as
the
styleof aspeech_metadataannotation on it, a field the model takes as direction and never speaks. - 3.1 and 2.5 — the API has no style field, so the direction is only
expressible inside
input. The provider writes a labelledDelivery direction:line above a<transcript>block holding the text, which is what stops the model from reciting the direction along with the words. The text cannot close the block, so it can neither end the transcript nor open another (RFC §6.4, §12.2). Measured 2026-10-08 on a text holdingDelivery direction:andTranscript:lines of its own: behind the formerTranscript:label,gemini-2.5-flash-preview-ttsspoke only its first sentence in 2 runs of 3; as a block it spoke all of it in 4 of 4, never the tag, and[laughs]and[whispers]are still performed.gemini-3.1-flash-tts-previewanswers such a text with a 400 ("the audio stream could not be completed") in some runs whichever the prompt (1 in 7 before, 3 in 8 as a block), never a text without directions.
On every Gemini TTS model the text itself can steer delivery, whatever frame
holds it: whisper very slowly, then say the account is closed was
whispered in every run that answered on 3.1, 2.5 and 3.8, as an audio tag is
performed. When the text comes from somewhere you do not trust (an agent
answering what a participant wrote), remove such cues before it reaches the
provider: in a BEFORE_TTS hook where a voice channel speaks it, in your own
code before kit.synthesize() or a direct provider call (no hook runs
there), and before an assistant line is injected into a realtime session.
The 3.8 models read the 3.1 prompt aloud (measured with its earlier
Transcript: label form) — its instructions in place of, or on top of, the
transcript: 3 runs in 6 on gemini-3.8-flash-tts and 6 in 6 on
the Lite model, measured 2026-09-27, against 0 in 36 with the bare transcript.
That is why the two families get different requests.
Tags and style_prompt are therefore different tools: a tag steers a word or a
phrase from inside the transcript, style_prompt steers the whole utterance
from the line above it.
Google's own guidance also frames a full direction in three parts — audio
profile (who is speaking), scene (where, what mood), and director's notes
(style, accent, pacing) — which is the shape style_prompt is for. See
Google's prompt guide.
Tags travel through a room like any other text, so anything written into a room
attached to a voice channel is performed: examples/gemini_tts_room.py is a
room that speaks what you type at the CLI.
The writer can be the model. examples/voice_gemini.py is a voice assistant
that is Gemini end to end — gemini-3.5-transcribe-live hears you,
gemini-3.8-flash answers, gemini-3.8-flash-lite-tts speaks — and its
system prompt lets the model put tags in its replies. Run against a recorded
French question on 2026-09-27, the model answered
[whispers] Promets-moi de ne le répéter à personne… <chuckle>, and the voice
whispered and laughed without saying either tag. Keep tts_filter unset on
such a channel: StripBrackets would remove the [...] tags before the TTS
sees them.
SSML is not an input mode here¶
SSML belongs to Cloud Text-to-Speech, which accepts it in a dedicated field
(SynthesisInput(ssml=...)). The Gemini API endpoint this provider calls has a
single free-text input field and no ssml field, and Google's Gemini-TTS page
documents prompting rather than SSML.
Sent anyway, the markup is not ignored: the model reads it as an instruction and follows the intent, not the timing. Longest silence produced, measured against the live API on 2026-08-07, three runs each:
| Asked for | Runs | Median |
|---|---|---|
<break time="1500ms"/> |
1.60 s, 2.46 s, 2.08 s | 2.08 s |
<break time="5000ms"/> |
3.36 s, 5.70 s, 4.72 s | 4.72 s |
[long pause], no duration given |
3.48 s, 4.02 s, 4.46 s | 4.02 s |
Directionally right, never exact — the same request drifts by seconds between identical runs, and a bracketed cue gets you the same effect without pretending to a contract. Anything that has to line up with something else (a beep, a prompt, a recording) needs the silence assembled in the outbound audio instead, not requested from the model.
Streaming¶
synthesize_stream() forwards audio deltas as they arrive.
synthesize_stream_input() is not supported: the API takes a complete
prompt, so there is no seam for token deltas. A VoiceChannel detects this and
delivers the finished reply through synthesize_stream() instead.
Gemini 3.1 TTS remains a preview model. Google documents rare cases where it reads prompt directions aloud or returns a transient HTTP 500 instead of audio; keep prompts explicit, split outputs longer than a few minutes, and apply retry at the calling workflow boundary when a failed generation is safe to repeat. The 3.8 models are generally available.
See examples/gemini_tts.py.
NeuTTS (Local/GPU, Voice Cloning)¶
GGUF-quantized LLM-based TTS with native streaming and voice cloning.
from __future__ import annotations
from roomkit.voice.tts.neutts import NeuTTSConfig, NeuTTSProvider, NeuTTSVoiceConfig
tts = NeuTTSProvider(
config=NeuTTSConfig(
backbone_repo="neuphonic/neutts-nano-french-q8-gguf",
codec_repo="neuphonic/neucodec",
device="cpu", # or "cuda"
voices={
"default": NeuTTSVoiceConfig(
ref_audio="reference.wav", # 3-15s, 16kHz mono
ref_text="Transcript of reference audio.",
),
},
streaming_pre_buffer=2, # Chunks to buffer before yielding
)
)
Pre-buffering: On CPU, accumulates 2 chunks (~1 second) before yielding to prevent playback underruns when inference is slower than real-time.
Output: Fixed 24kHz PCM.
Pocket TTS (Local/CPU or GPU, Multilingual)¶
Kyutai and Gradium's 100M-parameter model: 24 kHz streaming in 80 ms chunks,
faster than real time on two CPU cores, English, French, German, Portuguese,
Italian, Spanish and Dutch (pocket-tts 3.3+), voice cloning from a clip.
Install with pip install roomkit[pocket-tts].
from roomkit.voice.tts.pocket import PocketTTSConfig, PocketTTSProvider
tts = PocketTTSProvider(PocketTTSConfig(
language="french", # one language per loaded model
voices={"estelle": "estelle"}, # pre-made name, clip, hf:// or .safetensors
device="cpu", # or "cuda"
))
See the Pocket TTS guide for voices, CPU/GPU numbers and the local French assistant example.
Vui Nano (Local/GPU, Conversational)¶
Vui Nano (fluxions.ai, Apache 2.0) is a
305M-parameter TTS that generates each reply inside the conversation: the
dialogue so far, including the audio of the user's turns, lives in its KV
cache. The provider declares TTSContextLevel.AUDIO and keeps that cache in
step with the voice session (TTS Conversation Context).
from __future__ import annotations
from roomkit import TTSContextConfig, VoiceChannel
from roomkit.voice.tts.vui import VuiTTSConfig, VuiTTSProvider, VuiVoice
tts = VuiTTSProvider(
VuiTTSConfig(
voices={
"maeve": VuiVoice(preset="maeve"), # maeve, abraham, rhian, harry
"me": VuiVoice(ref_audio="me.wav", ref_text="Exact transcript of me.wav."),
},
temperature=0.7,
)
)
await tts.warmup() # loads the model, the codec and the voices
voice = VoiceChannel(
"voice", stt=stt, tts=tts, backend=backend,
tts_context=TTSContextConfig(include_audio=True), # let Vui hear the user
)
What the provider does with the context:
- each user turn is written to the cache as its transcript, then its audio
(re-encoded with the Qwen3-TTS-12Hz codec) when the context carries audio;
without
include_audio, as text only; - after a barge-in, the cache is cut back to the frame the user stopped
hearing (
played_ms, 80 ms frames), and a reply nobody heard is dropped; - a call for another voice session, another voice, or
synthesize()(no conversation), and a dialogue about to exceed the 6 minutes of audio the model was trained on (prompt included), restart it from the voice prompt with the user turns awaiting a reply; earlier turns are not replayed; - when the session is unbound, the cache is emptied (
release_context), user audio included.
Constraints:
- English only.
- Python 3.12 only (a
vui-ttsrequirement):pip install roomkit[vui]on another Python installs nothing, and the provider says so atwarmup(). - A CUDA GPU for real-time streaming (about 3.4 GB of VRAM, measured at 2.6x real time on an RTX 4070 without flash-attn).
- One active conversation per provider:
Engine(max_rows=1)gives it a single cache, and calls are serialized. - A reply longer than
max_secs(60 s by default) is cut off there, with a warning in the log. - Vui invents syllables at a line break and after a text that ends on a comma, so the provider joins a reply's lines into running sentences before speaking it: a poem written one line per verse came out with 15 % of its words wrong, 4 % once joined. The hosted Vui (Fluxions) reads line breaks cleanly on its own.
vui-tts>=1.2.3,<1.3, through its public API only:Row.truncatefor the cut after a barge-in, which takes the unheard frames out of the codec as well as the cache,load_official_promptandRow.prefill(cond_bias=...)for a preset's speaker token and conditioning bias, as Vui's own server applies them.vui-ttskeeps 40 s of codec tail. A cut that drops more than 30 s of unheard audio (a long reply cut early) leaves the next reply without its decoder context until the next 10 s boundary, in step again after it.vui-ttslogs the first 40 characters of every user turn at DEBUG on thevui.enginelogger. Keep that logger above DEBUG in production if transcripts must not reach the logs.
Paralinguistic tags: Vui was trained with inline tags such as [breath],
[laugh], [sigh], [hesitate] or [tut] ("So [breath] the thing is...").
They reach the model as written, so an LLM asked to emit them makes the voice
breathe, laugh or hesitate. VUI_TAGS holds the ones Vui's prompting guide
lists (breath, laugh, sigh, gasp, cough, hesitate); any other
bracketed word is read aloud or garbled. A small model invents others
([nod], [smiles]) and adds emoji even when the prompt forbids them, so
filter what reaches Vui, keeping its own tags:
from __future__ import annotations
from roomkit.voice.tts.filters import StripBrackets, StripEmoji, TTSFilterChain
from roomkit.voice.tts.vui import VUI_TAGS
voice = VoiceChannel(
"voice", stt=stt, tts=tts, backend=backend,
tts_filter=TTSFilterChain(StripEmoji(), StripBrackets(keep=VUI_TAGS)),
)
# "Oh [smiles] sure 😊 [laugh]" is spoken as "Oh sure [laugh]"
Troubleshooting: CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH from the codec
means a system cuDNN (for example under /lib/x86_64-linux-gnu) is loaded
instead of PyTorch's. Set torch.backends.cudnn.enabled = False before
warmup(), or remove the system copy from the loader path.
Output: Fixed 24kHz PCM. examples/voice_vui_context.py plays a dialogue
with a barge-in and writes what the user heard to a WAV file.
Fluxions (Cloud API, Hosted Vui)¶
fluxions.ai hosts the Vui model behind an API key, with no GPU: Vui Nano's four presets are among its hosted voices. Each text is rendered on its own, streamed as 24 kHz PCM.
from __future__ import annotations
import os
from roomkit.voice.tts.fluxions import FluxionsTTSConfig, FluxionsTTSProvider
tts = FluxionsTTSProvider(
FluxionsTTSConfig(
api_key=os.environ["FLUXIONS_API_KEY"],
voice="maeve", # a short id from list_voices(), or a full id
)
)
await tts.warmup() # fetches the voice list
async for chunk in tts.synthesize_stream("Hello from Vui, hosted by Fluxions."):
...
Local or hosted:
Vui Nano (roomkit[vui]) |
Fluxions (roomkit[fluxions]) |
|
|---|---|---|
| Runs on | Your CUDA GPU (about 3.4 GB), Python 3.12 | Fluxions' servers, any Python |
| Conversation context | AUDIO: replies generated inside the dialogue, the user's voice included, cut to what was heard |
NONE: each text on its own |
| Voices | The four presets, or a clip you clone | The hosted voices (presets included) and the account's cloned voices |
| Cost | Free | Per character |
Voices: list_voices() returns the hosted voices by their short id
(maeve), then the account's cloned voices by their u-... id; voice
accepts either. A render needs the voice's full id, which carries the model's
checkpoint (maeve.h8ff7e07da) and changes when Fluxions ships a new model: the
provider resolves the short id from the voice list, and lists again once when
the server no longer knows the id it cached. A full id (or a cloned voice's
u-... id) is passed as given.
Constraints:
- No conversation context: the speech API takes no previous turns, no user audio and no session. For context-aware replies, use Vui Nano.
- The local model speaks English; Fluxions documents no other language.
- A cold start after an idle period can take up to about 30 s before the first audio; the read timeout is 60 s by default.
- Errors come back as
httpx.HTTPStatusError: 402 when the account is out of credit, 429 when requests are rate-limited, 502 or 503 when Fluxions is at capacity. - Fluxions keeps every render in the account's history; it documents no retention period or opt-out.
Output: Fixed 24kHz PCM. examples/voice_fluxions.py lists the voices,
streams a sentence, renders another whole and writes both to a WAV file.
Azure Speech (Cloud API, MAI-Voice)¶
Azure Speech renders SSML over REST. The voice name picks the model:
fr-FR-Soleil:MAI-Voice-2.1-Flash is Microsoft's MAI voice Soleil on
MAI-Voice-2.1-Flash, the low-latency model, and fr-CA-SylvieNeural one of
Azure's neural voices, so one provider serves both.
from __future__ import annotations
import os
from roomkit.voice.tts.azure_speech import AzureSpeechTTSConfig, AzureSpeechTTSProvider
tts = AzureSpeechTTSProvider(
AzureSpeechTTSConfig(
api_key=os.environ["AZURE_SPEECH_KEY"],
region="swedencentral",
voice="en-US-Harper:MAI-Voice-2.1-Flash",
style="customer_call_center", # optional, one the voice supports
)
)
async for chunk in tts.synthesize_stream("Hello from MAI Voice."):
...
| Option | Default | Meaning |
|---|---|---|
api_key |
(required) | The Speech or Foundry resource's key |
region |
None |
The resource's region (swedencentral, canadacentral…) |
endpoint |
None |
The full synthesis URL, for a custom domain or a proxy; exactly one of region and endpoint |
voice |
en-US-Harper:MAI-Voice-2.1-Flash |
Voice name as Azure spells it |
language |
None |
The SSML xml:lang; None takes the voice name's locale |
style |
None |
A speaking style wrapped around every text with mstts:express-as |
rate |
None |
Speaking rate as SSML prosody takes it: "+15%", "1.2", "fast" |
sample_rate |
24000 |
8, 16, 22.05, 24, 44.1 or 48 kHz |
timeout / connect_timeout |
30.0 / 5.0 |
Read and connect budgets, in seconds |
Install with pip install roomkit[azure-speech] (httpx, no Azure SDK).
Text is spoken, never read as markup: the provider builds the SSML
itself and escapes everything in it, so a <break/> or a </voice> in an
AI reply is spoken as text, not obeyed; characters XML cannot carry are
dropped.
Constraints:
- The MAI-Voice models are in public preview (October 2026), without an SLA.
- A render streams: 23 s of audio arrived between 0.67 and 1.39 s after the
request. First audio, measured 2026-10-08 from Québec to Sweden Central:
about 0.65 s on MAI-Voice-2.1-Flash, 1.2 s on MAI-Voice-2.1, 0.4 s on the
neural
fr-CA-SylvieNeural. Microsoft's ~45 ms for Flash is its inference alone. - A style the voice does not support is refused with a 400 and no message.
- MAI-Voice-2.1-Flash speaks slowly for a conversation, and follows
rateclosely: the same sentence took 4.72 s at its own pace, 3.84 s at+20%and 2.84 s at+40%. MAI-Voice-2.1 follows it unevenly (measured 2026-10-08). - MAI-Voice's French voices are
fr-FR(Grant, Harper, Marc, Soleil); there is nofr-CAMAI voice. Azure's neuralfr-CAvoices go through the same provider. - Microsoft serves MAI-Voice from 14 regions, Canada Central and France Central among them.
- Cloning a voice from a clip is gated by Microsoft (Limited Access review) and not exposed by the provider.
- No conversation context: each text is rendered on its own.
- A refused render raises
AzureSpeechTTSError, withstatus_codeandretryable(true for 429 and 5xx).
Output: mono 16-bit PCM at sample_rate. examples/voice_azure_mai_agent.py
is a voice agent speaking with it; examples/voice_azure_mai.py streams a
sentence and plays it back to MAI-Transcribe.
TTS Filters¶
Filters clean AI-generated text before it reaches the TTS provider. Essential for removing reasoning markers, annotations, or bracketed instructions.
StripInternalTags¶
Removes [internal]...[/internal] and [internal: ...] blocks — useful when the AI includes reasoning that shouldn't be spoken.
from __future__ import annotations
from roomkit.voice.tts.filters import StripInternalTags
f = StripInternalTags()
# Non-streaming (full text)
clean = f("[internal]Let me think about this...[/internal] Here's what I found.")
# → "Here's what I found."
# Streaming (token by token)
for token in ["[internal", "]thinking[/", "internal] The answer", " is 42."]:
result = f.feed(token)
if result:
print(result, end="")
print(f.flush())
# → "The answer is 42."
StripBrackets¶
Removes all [...] bracketed content — catches [laughs], [pause], [Respond in French], etc.
from __future__ import annotations
from roomkit.voice.tts.filters import StripBrackets
f = StripBrackets()
clean = f("Sure [laughs] I can help [pause] with that.")
# → "Sure I can help with that."
keep passes a TTS's own tags through, matched ignoring case:
f = StripBrackets(keep=("laugh", "sigh"))
f("Oh [laugh] sure [smiles] fine.")
# → "Oh [laugh] sure fine."
StripEmoji¶
Removes emoji. Language models add them to replies even when the prompt forbids
it, and a TTS then names them or makes a stray sound. Composed emoji (skin
tones, flags, families, keycaps) go whole; accents, €, ° and punctuation
stay. On a streamed reply, an emoji split across chunks is removed too. The
response stored in the conversation keeps the model's text.
from __future__ import annotations
from roomkit import VoiceChannel
from roomkit.voice.tts.filters import StripEmoji
voice = VoiceChannel("voice", stt=stt, tts=tts, backend=backend, tts_filter=StripEmoji())
# "C'est rapide et super bon ! 😊" is spoken as "C'est rapide et super bon !"
StripTechnicalText¶
Removes the technical text a model may write into a spoken reply: a JSON object
(recognised by its first key, quoted, {", or a bare name and a colon,
{agent:), such as a tool call or a tool
result written as words instead of calling the tool; a note to itself,
(Note: ...) or (NB: ...); a separator of three dashes or more. Whatever
model the application picks, a reply sometimes carries them, and removing them
from the voice does not make a call happen.
from __future__ import annotations
from roomkit import VoiceChannel
from roomkit.voice.tts.filters import StripTechnicalText
voice = VoiceChannel("voice", stt=stt, tts=tts, backend=backend, tts_filter=StripTechnicalText())
# 'I'll check. {"name": "delegate", "arguments": {"task": "Weather. Today"}} Done.'
# is spoken as "I'll check. Done."
- A nested object goes whole, and a brace inside one of its strings does not
end it. On a streamed reply the filter works on the tokens, before the reply
is cut into sentences: an object holding a full stop is removed whole, which
a
BEFORE_TTShook, judging one sentence at a time, cannot do. - Ordinary text passes:
(about ten minutes),{x},well-known,--. An object or a note still open when the stream ends is removed, and a removal leaves one space between the words around it (Searching.{…}Back soon.is spokenSearching. Back soon.). - Each object or note removed is logged as a warning with its length; its text
only at DEBUG, through
redact(). The response stored in the conversation keeps the model's text: the slip stays visible, it is just not heard. - The rules do not know who a note is for:
(Note: holidays may delay it)written for the listener goes too. Reasoning written in plain words ("Need no tool.") is not recognised: no rule tells it from an answer. The fence or the brackets around a removed object stay (a Markdown code block, a JSON array): chainStripBracketsfor the brackets.
examples/voice_strip_technical_text.py runs it on a whole reply and a
streamed one.
A voice channel streams each response through its own copy of its filter, so
replies streamed at once in several rooms never share what a filter holds open;
a filter of your own holding something that cannot be copied defines
__deepcopy__.
TTSFilterChain¶
tts_filter takes one filter; TTSFilterChain runs several in order, on a
whole text and on a streamed reply alike:
from __future__ import annotations
from roomkit.voice.tts.filters import (
StripBrackets,
StripEmoji,
StripTechnicalText,
TTSFilterChain,
)
tts_filter = TTSFilterChain(StripTechnicalText(), StripEmoji(), StripBrackets(keep=("laugh",)))
Using Filters with Streaming TTS¶
from __future__ import annotations
from roomkit.voice.tts.filters import StripInternalTags, filtered_stream
async def ai_token_stream():
"""Simulated AI output with internal reasoning."""
for token in ["[internal]", "reasoning", "[/internal]", " Hello", " there!"]:
yield token
# Wrap the token stream through a filter before TTS
clean_stream = filtered_stream(ai_token_stream(), StripInternalTags())
async for chunk in tts.synthesize_stream_input(clean_stream, voice="default"):
# Audio chunks without the internal reasoning
transport.send_audio(session, chunk)
Sentence Splitter¶
Buffers streaming tokens and yields complete sentences — prevents unnatural pauses from very short fragments.
from __future__ import annotations
from roomkit.voice.tts.sentence_splitter import split_sentences
# Buffer tokens until sentence boundaries
async for sentence in split_sentences(ai_token_stream(), min_chunk_chars=20):
async for chunk in tts.synthesize_stream(sentence, voice="default"):
transport.send_audio(session, chunk)
A SegmentBreakMarker in the stream ends the text before it: what the splitter
holds goes out at once as a sentence, however short, and filtered_stream
calls its filter's end_segment() there and passes the marker on. A voice
channel puts one where the agent's text before a tool call ends, so "Let me
check." is said while the tool runs, never once it is done, and never runs on
into the next round's text.
end_segment() lets out the text a filter has decided to speak and keeps a
span it removes open: an [internal] block the model opens before the call
and closes after it is never spoken. The default lets out nothing, so a
filter written before it keeps what it holds until the next text or the end
of the stream (flush() is called only there). A filter that holds text it
has decided to speak overrides it.
Sentence Budget¶
Asked to "explain", a language model may talk for a minute however short its
prompt asks it to be. max_sentences caps what a voice channel speaks of each
reply:
from __future__ import annotations
from roomkit import VoiceChannel
voice = VoiceChannel("voice", stt=stt, tts=tts, backend=backend, max_sentences=4)
- A streamed reply: once the budget is said, a reply that goes on stops at
its first word past it, as a barge-in stops it. The model generates nothing
more, a tool call the next sentence announced ("Let me book that table") is
never made, and the room keeps the text produced up to there (the sentences
spoken and the start of the next), marked
cancelled, rather than the whole answer nobody heard. The final transcript andAFTER_TTScarry the sentences spoken. A reply of exactly the budget runs to its end. - A text delivered whole (a reply on a TTS without streamed input, an orchestration farewell, any message the channel speaks) has its first sentences spoken; a reply was stored whole before it reached the channel.
- A sentence is counted as the TTS reads it: after the text filter and
BEFORE_TTS, so a sentence a hook drops does not count, and very short sentences gather into one, as the channel always hands them to the TTS. - A reply cut this way fires no
ON_AI_RESPONSE, like any turn its reader stopped.say(), the application's own text, has no budget, and aConferenceChannelhas none.
examples/voice_sentence_budget.py shows a twelve-sentence explanation cut at
four, and what the room keeps of it.
Choosing a Provider¶
| Provider | Type | Streaming | Latency | Cost | Best For |
|---|---|---|---|---|---|
| Deepgram | Cloud STT | Yes | Low | Per-minute | Production real-time transcription |
| Gradium | Cloud STT | Yes | Low | Per-minute | Real-time with server-side VAD |
| SherpaOnnx | Local STT | Transducer only | Medium | Free | Privacy, offline, edge |
| Qwen3 ASR | Local STT | vLLM only | Medium | Free | GPU-accelerated, multilingual |
| MAI-Transcribe | Cloud STT | Yes (behind a VAD) | Low (final ~0.13 s after speech) | Per-hour | 57 languages, preview |
| ElevenLabs | Cloud TTS | Yes + input | Low | Per-character | Highest voice quality |
| Grok | Cloud TTS | Yes + input | Low | Per-character | Expressive tags, 20 languages |
| Gradium | Cloud TTS | Yes + input | Low | Per-character | Real-time with voice control |
| SherpaOnnx | Local TTS | Yes | Medium | Free | Privacy, offline, VITS/Piper |
| Qwen3 TTS | Local TTS | Post-gen | Medium | Free | Voice cloning, GPU |
| NeuTTS | Local TTS | GGUF only | Medium | Free | Voice cloning, GGUF quantized |
| Vui Nano | Local TTS | Yes | Low (GPU) | Free | Replies conditioned on the dialogue audio, English |
| Fluxions | Cloud TTS | Yes | Low | Per-character | Vui without a GPU, English |
| Azure Speech | Cloud TTS | Yes | Medium (MAI Flash ~0.65 s to first audio) | Per-character | MAI-Voice and Azure's neural voices, preview |
| Pocket TTS | Local TTS | Yes | Low (CPU or GPU) | Free | Seven languages incl. French, no GPU needed |
Using with VoiceChannel¶
from __future__ import annotations
from roomkit.channels import VoiceChannel
from roomkit.voice.backends.mock import MockVoiceBackend
from roomkit.voice.pipeline import AudioPipelineConfig
from roomkit.voice.stt.deepgram import DeepgramConfig, DeepgramSTTProvider
from roomkit.voice.tts.elevenlabs import ElevenLabsConfig, ElevenLabsTTSProvider
stt = DeepgramSTTProvider(config=DeepgramConfig(api_key="..."))
tts = ElevenLabsTTSProvider(
config=ElevenLabsConfig(api_key="...", output_format="pcm_16000") # a VoiceChannel plays PCM
)
voice = VoiceChannel(
"voice-main",
stt=stt,
tts=tts,
backend=MockVoiceBackend(),
pipeline=AudioPipelineConfig(),
)
kit.register_channel(voice)
Testing with Mocks¶
from __future__ import annotations
from roomkit.voice.stt.mock import MockSTTProvider
from roomkit.voice.tts.mock import MockTTSProvider
stt = MockSTTProvider(transcripts=["Hello", "How are you?"], streaming=False)
tts = MockTTSProvider(voice="mock-voice")
# After usage:
assert len(stt.calls) == 1 # Audio inputs received
assert len(tts.calls) == 1 # Synthesis requests made
assert tts.calls[0]["text"] == "Hello!" # Text synthesized