Bei Voice-Agent-Setups gibt es eine schmerzhafte Wahl. Entweder du nimmst Cartesia oder ElevenLabs, zahlst pro Minute und hast keine echte Voice-Cloning-Fähigkeit. Oder du gehst self-hosted, hast die volle Kontrolle, aber kannst Wochen damit verbringen, Inference-Latenz unter eine Sekunde zu drücken.
Bei meinem Setup habe ich Variante 2 gewählt. Hier ist der Stand nach drei Monaten Engineering. Round-Trip-Latenz aktuell bei 1222ms, Ziel unter 1000ms, mit klarem Pfad dahin. Der Stack ist OmniVoice plus CUDA Graphs plus Vast.ai plus LiveKit. Was wirklich gewirkt hat und was nicht.
Warum nicht Cartesia oder ElevenLabs#
Drei Gründe. Einer Geld, zwei Funktionalität.
Erstens: Voice Cloning. Cartesia hat es nicht. ElevenLabs hat es, aber teuer und mit Lizenz-Beschränkungen, die für B2B-Sales-Anwendungen problematisch sind. Wenn du mit der Stimme einer Persona „Frieda" outbound callst, willst du die Voice-Lizenz im eigenen Haus haben.
Zweitens: Latenz-Kontrolle. API-basierte TTS hat eine harte untere Grenze, weil das Audio über Netzwerk kommt. Self-hosted auf einer GPU im selben Rechenzentrum eliminiert 50 bis 150ms davon.
Drittens: Kosten. Bei 1000 Calls pro Monat à 5 Minuten zahlst du bei Cartesia rund 250 €. Eine Vast.ai-RTX-4090-Instance für GPU-Inference kostet 0,30 €/h, also bei voller Auslastung ungefähr 220 €/Monat. Mit On-Demand-Spinup nur während Sales-Hours sind das 70 bis 120 €/Monat. Einsparung 50 bis 70%.
Der Stack: OmniVoice plus LiveKit plus Vast.ai#
OmniVoice (k2-fsa/OmniVoice) ist ein non-autoregressive Flow-Matching-TTS auf 0.6B Qwen3-Backbone und HiggsAudioV2-Tokenizer. Das relevante Detail: anders als autoregressive Modelle wie Qwen3-TTS oder GPT-4o generiert OmniVoice alle Audio-Tokens parallel und entmasked sie schrittweise über num_step Iterationen.
Vorteil: kürzere Texte brauchen proportional weniger Zeit. Ein 20-Wörter-Satz mit num_step=8 braucht etwa 200ms TTFA auf RTX 4090. Ein 5-Wörter-Satz unter 100ms.
Nachteil: kein echtes Token-Streaming. Pseudo-Streaming geht nur via Sentence-Chunking. Der TTS-Worker generiert Satz für Satz und schickt jeden fertigen Satz sofort an LiveKit. Erster Satz spielt ab, während der zweite generiert wird. Praktisch funktioniert das gut, weil die Sentence-Pause unter 100ms bleibt.
LiveKit ist die Voice-Pipeline. Twilio liefert via SIP-Trunk den Anruf an LiveKit, LiveKit verbindet zum WebRTC-Worker, der Worker holt Audio-Frames von faster-whisper, schickt Text an den LLM, schickt LLM-Output Satz für Satz an OmniVoice, OmniVoice produziert PCM 24kHz mono, das LiveKit zurück an den SIP-Trunk leitet.
Vast.ai macht den Stack günstig. RTX 4090 für 0,30 €/h on-demand, mit vastai_manager.py als Auto-Bootstrap-Skript inklusive Cloudflared-Tunnel. Cold Start dauert 2 bis 3 Minuten, deshalb halte ich während Sales-Hours eine Instance warm und shutte sie nach Feierabend.
CUDA Graphs: 2 bis 4x Speedup ohne Quality-Loss#
Das ist der Hebel, der den Unterschied zwischen 800ms und 350ms TTFA gemacht hat. CUDA Graph Capture ersetzt das normale PyTorch-Forward (Python-Dispatch pro Layer) durch einen einzigen kompilierten Kernel-Launch-Graph.
Konkret: bei OmniVoices _generate_iterative() wird der Forward-Pass für 12 Diffusion-Steps repliziert. Jeder Step macht denselben Forward auf andere Tensoren. Ohne CUDA Graphs geht jeder Step durch Python, dispatcht 50 bis 100 CUDA-Kernels einzeln, akkumuliert Python-Overhead. Mit CUDA Graphs wird der Forward einmal als Graph aufgenommen, und jeder Step ist ein einziger graph.replay().
Skizze der Implementation:
class CUDAGraphForwardRunner:
BUCKET_SIZES = [128, 192, 256, 384, 512, 768]
def _ensure_captured(self, batch_2b, codebooks, bucket):
key = (batch_2b, codebooks, bucket)
if key in self._graphs:
return self._graphs[key]
# Static input buffers (values don't matter for capture)
s_input_ids = torch.zeros(batch_2b, codebooks, bucket,
dtype=torch.long, device=device)
s_audio_mask = torch.zeros(batch_2b, bucket,
dtype=torch.bool, device=device)
# Warmup runs (required before capture)
s = self._capture_stream
s.wait_stream(torch.cuda.current_stream())
# ... 3 warmup forwards ...
# Capture
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, stream=s):
output = self.model(s_input_ids, s_audio_mask, ...)
self._graphs[key] = {
"graph": graph,
"input_buffers": (s_input_ids, s_audio_mask, ...),
"output_buffer": output,
}
return self._graphs[key]Statische Buffer pro Bucket-Size. Wenn ein Satz 197 Tokens hat, fällt er in den 256-Bucket, kopiert die Inputs in den statischen Buffer, replay'd den Graph. Bei 95% der Calls treffe ich einen bereits gecachten Graph. Capture passiert nur einmal pro Bucket beim ersten Hit.
Speedup gemessen auf RTX 4090: zwischen 2x und 4x bei voll aktiviertem Classifier-Free Guidance. Bei OMNIVOICE_GUIDANCE_SCALE=0 (No-CFG-Mode, Batch B statt 2B) zusätzlich 40 bis 50% on top, also insgesamt 3 bis 6x.
Trade-off: Quality bei guidance_scale=0 ist marginal schlechter. In Blind-Tests mit 30 Sätzen war der Unterschied bei 4 von 30 hörbar, bei den restlichen nicht. Für B2B-Outbound, wo Verständlichkeit wichtiger als Studio-Quality ist, ist No-CFG der richtige Default.
Voice Cloning: SHA256-LRU-Cache als Hot Path#
Voice Cloning ist die Funktion, die Cartesia und ElevenLabs nicht so liefern. Du gibst ein 3-Sekunden-Sample der Zielstimme als WAV oder MP3, OmniVoice lernt die Stimme on-the-fly und generiert in dieser Stimme.
Das Problem: create_voice_clone_prompt() ist teuer. Audio-Encoding plus optionaler Whisper-Transkription dauert 800 bis 1500ms. Wenn du das pro Anruf machst, ist Latenz tot.
Lösung: SHA256-Hash des ref_audio als Cache-Key, LRU-Cache mit 8 Einträgen. Bei einem Workspace mit 4 verschiedenen Personas trifft der Cache nach den ersten 4 Calls 100%. Pro Call kostet das Voice-Cloning dann 0ms, weil der Clone-Prompt schon im Memory liegt.
Plus drei Tricks, die mir Wochen Debugging gespart haben:
- 500ms Silence ans
ref_audioanhängen, damit OmniVoice nicht beim ersten Wort einen Pitch-Glitch produziert. preprocess_prompt=Falsesetzen. DefaultTrueentfernt automatisch Stille, was den Silence-Append wieder kaputt macht.ref_text=""lassen. OmniVoice ruft dann intern Whisper auf, was genauer ist als ein hardgecodeter Template-Text.
Diese drei Sachen waren die Differenz zwischen „Voice klingt komisch, manchmal pitchy" und „Voice klingt wie der Originalsprecher".
Latenz-Tabelle: was wirklich rauskommt#
Auf RTX 4090 mit num_step=8 (Design-Mode) oder num_step=12 (Clone-Mode) plus CUDA Graphs:
| Satzlänge | num_step=8 | num_step=12 (Clone) |
|---|---|---|
| < 50 Zeichen | ~200ms | ~400ms |
| 50-100 Zeichen | ~350ms | ~800ms |
| > 100 Zeichen | ~500ms | 1500-2500ms |
Die obere Reihe ist im Latenz-Budget. Die untere ist nicht. Konsequenz: für Clone-Mode auf RTX 4090 muss ich Sätze auf maximal 50 Zeichen begrenzen. Das funktioniert, weil Pseudo-Streaming via Sentence-Chunking ohnehin satzweise generiert. Längere Sätze splitte ich am ersten Komma oder Doppelpunkt.
Die andere Option ist RTX 5090. Dort ist die geschätzte Latenz 2 bis 3x niedriger, aber pro Stunde 0,60 € statt 0,30 €. Bei meinem Setup lohnt sich das aktuell noch nicht.
LLM-Routing: Dual-Probe mit Groq#
Anders als beim TTS ist beim LLM API die richtige Wahl. Live-Inference ist günstig genug. Was zählt, ist Time-to-First-Token.
Mein Pattern: dual-probe. Bei jedem LLM-Call schicke ich parallel an zwei Modelle bei Groq, das schnellste gewinnt. Die andere Antwort wird verworfen.
| Modell | Test 1 | Test 2 |
|---|---|---|
qwen/qwen3-32b | 650ms | 513ms |
openai/gpt-oss-20b | 311ms | 346ms |
gpt-oss-20b ist konsistent schneller, vermutlich weil Groqs KV-Cache für dieses Modell besser warm ist. Ich könnte direkt gpt-oss-20b nehmen, aber die Probe gibt mir Failover, wenn Groq für ein Modell mal degraded ist.
Cost: 2 LLM-Calls pro Sales-Call statt 1. Bei Groq-Pricing macht das pro Call 0,2 Cent statt 0,1 Cent. Vernachlässigbar gegen die TTFA-Stabilität.
Was nicht funktioniert hat#
qwen-tts-turbo auf RTX 3090. Habe ich Ende März getestet als Alternative zu OmniVoice. Bessere Latenz auf dem Papier (~345ms TTFA), aber der Server lieferte leere Audio-Samples. Root-Cause vermutlich KV-Cache-Prefill auf sm_86 ohne Megakernel-Support, oder ein Float32-vs-Int16-Conversion-Bug. Drei Tage Debugging, dann zurück zu OmniVoice.
Manuelle Vast.ai-Instances ohne Bootstrap-Skript. Die Vast.ai-API erlaubt direkten Instance-Spin-up, aber ohne cloudflared im Bootstrap kommen keine Connections durch. Ich verbringe ich nicht mehr Zeit damit, sondern verwende immer vastai_manager.py mit dem custom Bootstrap.
LangChain als LLM-Orchestrierung. Bei Web-Apps fügt LangChain 50 bis 100ms Overhead pro Call hinzu, im Voice-Kontext frisst das mein Budget auf. Direkt zum Groq-SDK statt LangChain-Wrapping.
Was noch offen ist#
Pitch-Stabilität. Aktueller Score 2 von 5 in eigenen Blind-Tests, Ziel ist 4 von 5 plus. Liegt vermutlich an Onset-Artefakten beim ersten Wort eines neuen Satzes nach einer Pause. Mehrere Hypothesen, noch keine validiert.
Micro-Dropouts. 2 bis 3 pro Call. Vermutlich WebSocket-Throttling oder GPU-Scheduling-Konflikt durch den asyncio.Lock, der OmniVoice auf einen Request gleichzeitig serialisiert. Wenn ich auf RTX 5090 wechsle und den Lock entferne, könnte das Problem verschwinden.
Farewell-Detection. „Bis dann" mid-sentence triggert manchmal den Hangup-Flow. Brauche Kontext-Check über die letzten 2 Turns, statt nur auf Phrase-Match.
Cold-Start-Optimierung. 2 bis 3 Minuten ist zu lang für „Sales-Person sagt: ich brauch jetzt einen Outbound-Test". Ziel: 30 Sekunden mit pre-warmed VRAM-Snapshot. Der vastai_manager.py hat dafür einen Hook, ist aber noch nicht produktionsreif.
Was ich anders machen würde, wenn ich nochmal anfange#
Erstens: nicht mit ElevenLabs starten und später migrieren. Direkt OmniVoice ist sauberer, weil das Plugin-Schema und die Latenz-Annahmen anders sind.
Zweitens: vom ersten Tag CUDA Graphs einbauen. Ich hatte erst eine Vanilla-Version 4 Wochen in Production, die war merklich langsamer.
Drittens: Vast.ai-Bootstrap automatisieren. vastai_manager.py ist 800 Zeilen Python und hat sich gelohnt. Manuelle Vast.ai-Instances ohne cloudflared-Bootstrap funktionieren nicht zuverlässig.
Wenn du an einem ähnlichen Setup baust, schreib mir. Ich teile gern den Stand der cuda_graph_omnivoice.py und das Bucketing-Schema, das aktuell 95%+ Hit-Rate hat. Bei mir gibt es einen 15-min Call, wenn du tiefer einsteigen willst.