Skip to main content

🎙️ Audio Pipeline

The Audio Pipeline manages the flow of audio through your agent, handling speech-to-text (STT) and text-to-speech (TTS) conversion.

Overview​

Audio Input → STT → Text Processing → TTS → Audio Output

The pipeline handles:

  • Receiving audio from LiveKit
  • Converting speech to text (STT)
  • Sending text to your agent for processing
  • Converting responses to speech (TTS)
  • Publishing audio back to LiveKit

Basic Usage​

from stella_sdk import AudioPipeline

# Create pipeline with defaults
pipeline = AudioPipeline()

# Or configure providers
pipeline = AudioPipeline(
stt_provider="sherpa",
tts_provider="kokoro"
)

STT Providers​

Sherpa (Default)​

Local, open-source speech recognition:

pipeline = AudioPipeline(
stt_provider="sherpa",
stt_config={
"model": "sherpa-onnx-whisper-tiny.en",
"sample_rate": 16000
}
)

Pros:

  • No API costs
  • Low latency
  • Privacy (runs locally)

Cons:

  • Requires more CPU
  • Less accurate than cloud services

Whisper (OpenAI)​

OpenAI's Whisper API:

pipeline = AudioPipeline(
stt_provider="whisper",
stt_config={
"model": "whisper-1",
"language": "en"
}
)

Pros:

  • High accuracy
  • Multi-language support

Cons:

  • Requires API key
  • Per-usage cost
  • Network latency

Deepgram​

Deepgram's real-time transcription:

pipeline = AudioPipeline(
stt_provider="deepgram",
stt_config={
"model": "nova-2",
"language": "en-US",
"smart_format": True
}
)

Pros:

  • Real-time streaming
  • High accuracy
  • Speaker diarization

TTS Providers​

Kokoro (Default)​

Local TTS using Kokoro:

pipeline = AudioPipeline(
tts_provider="kokoro",
tts_config={
"voice": "af_heart",
"speed": 1.0
}
)

Pros:

  • No API costs
  • Low latency
  • Privacy

Cons:

  • Limited voices

ElevenLabs​

Premium voice synthesis:

pipeline = AudioPipeline(
tts_provider="elevenlabs",
tts_config={
"voice_id": "21m00Tcm4TlvDq8ikWAM",
"model_id": "eleven_turbo_v2",
"stability": 0.5,
"similarity_boost": 0.75
}
)

Pros:

  • High-quality voices
  • Voice cloning
  • Emotional range

Cons:

  • Per-character cost
  • Requires API key

OpenAI TTS​

OpenAI's text-to-speech:

pipeline = AudioPipeline(
tts_provider="openai",
tts_config={
"model": "tts-1",
"voice": "alloy" # alloy, echo, fable, onyx, nova, shimmer
}
)

Qwen3-TTS (opt-in, real-time GPU, in-process)​

Local Qwen3-TTS via faster-qwen3-tts — a CUDA-graph-optimized runtime that hits ~156 ms TTFA on an RTX 4090 with the 0.6B-Base variant. The Qwen3-TTS weights are Apache-2.0, so this provider is the recommended GPU option for commercial deployments.

  • Runs in-process in the tts-service container (no sidecar, no HTTP hop). Selected at build time via --build-arg TTS_PROVIDER=qwen3; the resulting image carries only Qwen3's dependency tree (torch 2.5.1+cu124
    • faster-qwen3-tts).
  • Model variant is wizard-selectable via QWEN3_MODEL_ID:
    • Qwen/Qwen3-TTS-12Hz-0.6B-Base (~2 GB VRAM, 156 ms TTFA on 4090) — recommended starting point
    • Qwen/Qwen3-TTS-12Hz-1.7B-Base (~5 GB VRAM, higher quality)
    • Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice (voice cloning from a reference clip)
    • Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign (instruction-based voice design)
  • Voice-clone API requires a reference clip + transcript for every variant (including Base). The transcript lives in a sibling .txt file next to the audio (ref_audio.mp3 → ref_audio.txt), so swapping voices is just "drop two files on the PVC, no env edits".

To enable Qwen3-TTS:

  1. Pick TTS_PROVIDER=qwen3 in the wizard and choose a QWEN3_MODEL_ID variant. HF_TOKEN is optional for these Apache-2.0 weights.

  2. STELLA bundles a German reference clip + transcript at tts-service/assets/ref_audio.mp3 + ref_audio.txt. The init container copies both onto the PVC at /models/qwen3/ on first deploy. To use a different voice, drop your own ref_audio.mp3 + ref_audio.txt (same basename, matching transcript) at that path and restart the pod — no env edits, no rebuild.

  3. Deploy with ./scripts/start-k8s.sh. The build step compiles a Qwen3-only tts-service image; the init container downloads the weights to the shared PVC; the container loads them and captures CUDA graphs at startup (typically 30–90 s on an L4, including warm-up).

  4. Tuning knobs (all optional):

    QWEN3_LANGUAGE=Auto # Auto = model autodetects; pin to override
    QWEN3_CHUNK_SIZE=2 # codec frames per yield (lower = lower TTFB)
    QWEN3_DTYPE=bfloat16 # bfloat16 / float16 / float32
pipeline = AudioPipeline(
tts_provider="qwen3",
)

Pros:

  • Apache-2.0 weights — commercially usable.
  • Real-time: 156 ms TTFA / 4.78 RTF on a 4090 with the 0.6B variant.
  • Small VRAM footprint (~2 GB for 0.6B) leaves room next to STT on a 24 GB L4.
  • In-process: no sidecar, no HTTP hop, no extra container to manage.

Cons:

  • GPU-only. No CPU fallback path — use Piper or Kokoro for non-GPU deploys.
  • The fast-path runtime is newer than vllm and has fewer eyes on it in production; expect to lean on logs for the first few rollouts.
  • Requires an operator-supplied reference audio + transcript.

See NOTICE.md for the full license split between STELLA's permissive code, the MIT-licensed faster-qwen3-tts engine, and the Apache-2.0 weights.

Pipeline Methods​

speech_to_text​

# Transcribe audio buffer
transcript = await pipeline.speech_to_text(audio_bytes)

# With streaming callback
async def on_partial(text: str, is_final: bool):
print(f"{'Final' if is_final else 'Partial'}: {text}")

await pipeline.speech_to_text(
audio_bytes,
callback=on_partial
)

text_to_speech​

# Generate audio from text
audio = await pipeline.text_to_speech("Hello, world!")

# Streaming TTS
async for audio_chunk in pipeline.text_to_speech_stream("Hello, world!"):
await agent.publish_audio(audio_chunk)

Voice Activity Detection (VAD)​

The pipeline includes VAD to detect when someone is speaking:

from stella_sdk import AudioPipeline, VADConfig

pipeline = AudioPipeline(
vad_config=VADConfig(
threshold=0.5, # Detection sensitivity (0-1)
min_speech_ms=250, # Minimum speech duration
min_silence_ms=500, # Silence before end of speech
sample_rate=16000
)
)

# VAD events
pipeline.on_speech_start = lambda: print("Speech started")
pipeline.on_speech_end = lambda audio: print(f"Speech ended: {len(audio)} bytes")

Turn Management (Transcript Gating)​

The SDK enforces turn-based conversation flow at the pipeline level. Every agent using audio_in() gets this automatically — no agent-side code required.

How It Works​

User speaks → Final transcript yielded → Gate CLOSES
→ Agent processes (LLM) → Agent narrates (TTS) →
Gate OPENS → User can speak again

When the gate is closed:

What happensTTS enabled (TTS_ENABLED=true)TTS disabled (TTS_ENABLED=false)
Partial transcriptsSuppressed (not shown in frontend)Still published to LiveKit
Final transcriptsDiscardedDiscarded
Barge-in callbacksSkipped (when INTERRUPT_MODE=none)Skipped (when INTERRUPT_MODE=none)
STT streamStays alive (no reconnection)Stays alive (no reconnection)
Data channel textQueues for next turnQueues for next turn

With TTS enabled, the user's speech is completely invisible during the agent's turn — no partial transcripts appear in the frontend while the agent is processing or narrating. With TTS disabled, the gate opens quickly after processing, so brief partial display is acceptable.

Configuration​

Environment VariableDefaultDescription
TTS_ENABLEDtrueControls whether TTS is active. When true, full transcription suppression during agent turn. When false, lighter gating
INTERRUPT_MODEnonenone = strict turn-based (default). smart = reserved for future barge-in with re-prompting
TRANSCRIPT_DEBOUNCE_MS300Debounce window for aggregating rapid successive finals before they reach the agent

SDK-Level Enforcement​

The gating lives inside audio_in() in the AudioPipeline, so every agent gets it automatically:

# Inside AudioPipeline.audio_in() — agents don't need to do anything
async for event in self.audio.audio_in():
# Gate closes automatically when this yields
# Gate opens automatically when the loop resumes
response = await self.generate_response(event.text)
await self.audio.speak(response)
# ← gate opens here, user can speak again

Custom agents that override run_audio_loop() still get gating as long as they use self.audio.audio_in(). The gate methods (close_transcript_gate(), open_transcript_gate()) are also public for manual control.

Interruption Handling​

Handle when the user interrupts the agent:

class MyAgent(BaseAgent):
async def on_transcript(self, text: str, is_final: bool):
if not is_final:
# User is speaking - stop agent audio
await self.stop_speaking()
return

# Process final transcript
response = await self.generate_response(text)
await self.speak(response)

async def stop_speaking(self):
"""Stop any currently playing agent audio."""
await self.pipeline.cancel_tts()
await self.send_status("listening")

Audio Format​

Default audio format:

PropertyValue
Sample Rate16000 Hz
Channels1 (mono)
Bit Depth16-bit
FormatPCM

Configure in the pipeline:

pipeline = AudioPipeline(
sample_rate=16000,
channels=1,
bit_depth=16
)

Complete Example​

from stella_sdk import BaseAgent, AudioPipeline, VADConfig

class VoiceAgent(BaseAgent):
def __init__(self):
super().__init__()
self.pipeline = AudioPipeline(
stt_provider="sherpa",
tts_provider="kokoro",
vad_config=VADConfig(
threshold=0.5,
min_silence_ms=700
)
)
self.is_speaking = False

async def on_connect(self):
# Start listening for audio
self.pipeline.on_speech_end = self.on_speech_detected
await self.send_status("listening")

async def on_speech_detected(self, audio: bytes):
"""Called when VAD detects end of speech."""
if self.is_speaking:
# User interrupted - stop speaking
await self.pipeline.cancel_tts()
self.is_speaking = False

# Transcribe
transcript = await self.pipeline.speech_to_text(audio)
await self.send_transcript(transcript, speaker="user")

# Generate response
await self.send_status("thinking")
response = await self.generate_response(transcript)

# Speak response
await self.send_status("speaking")
self.is_speaking = True

await self.send_transcript(response, speaker="assistant")

async for chunk in self.pipeline.text_to_speech_stream(response):
if not self.is_speaking:
break # Interrupted
await self.publish_audio(chunk)

self.is_speaking = False
await self.send_status("listening")

async def generate_response(self, text: str) -> str:
# Your LLM logic
pass

See Also​