Skip to main content

TTS Services

The text-to-speech layer turns assistant text into streamed audio. It ships as two independent component services that speak the same WebSocket envelope protocol, plus a fallback owner in the conversation engine that chains them together.

Two backends

Both services expose the same synthesis contract and stream base64-encoded PCM16LE, mono, 24000 Hz audio. They differ only in how audio is produced and how a per-avatar voice is selected.

BackendRolePortModuleVoice mechanism
tts_service (ElevenLabs)Primary8014 (TTS_ELEVENLABS_PORT)component_services.tts_service.main:appCloud ElevenLabs; per-avatar elevenlabs_voice_id
tts_omnivoice (OmniVoice)Fallback8015 (TTS_OMNIVOICE_PORT)component_services.tts_omnivoice:appLocal GPU model k2-fsa/OmniVoice; reference-audio voice cloning

Both bind 127.0.0.1 and are launched via run/run.sh / run/services/start.sh with uvicorn.

Neither service knows about the other. The primary/fallback relationship is owned entirely by the conversation engine's FallbackTTSClient (conversation_engine/services/tts.py).

note

tts_service is a layered (hexagonal) ElevenLabs adapter: an inbound WS API (api/), an application core (application/) with a TTSProviderPort, and an outbound ElevenLabsClient (services/elevenlabs.py). tts_omnivoice is a single file (component_services/tts_omnivoice.py) containing the app, WS handler, model manager, voice-profile store, and metrics.

Fallback behavior

FallbackTTSClient.synthesize runs the ElevenLabs primary (ENGINE_CONFIG.tts_ws_url, 8014) and awaits the first AudioChunk from its generator. The fallback to OmniVoice (ENGINE_CONFIG.tts_omnivoice_ws_url, 8015) fires when that first chunk never arrives:

  • StopAsyncIteration — the primary produced zero audio chunks (an upstream error event or a clean close broke the engine TTSClient loop before any audio), or
  • any other Exception — WebSocket connect failure or open_timeout.

The same request kwargs (text, session_id, avatar_id, language_hint, voice_clone_path, voice_clone_text, elevenlabs_voice_id) are then replayed against the fallback backend.

warning

Fallback is first-chunk-only. Once the first primary chunk is yielded, the engine drains the rest of the primary stream and returns — a mid-stream primary failure is not retried against OmniVoice.

The engine's single-backend TTSClient swallows failures by ending its generator: a websockets.WebSocketException is logged and the stream ends; an upstream error event is logged and breaks the loop. Both surface to FallbackTTSClient as "no chunks" and thus trigger the fallback.

Inside the pipeline, TTS is a best-effort final step: it runs only when the session has voice_output enabled and there is assistant text, and the whole synthesis loop is wrapped in a try/except that swallows exceptions. If both backends fail, the turn still completes without audio.

The gateway relay path is different — see Voiceover flow below. It has no fallback.

Endpoints

The synthesis WebSocket lives at TTS_CONFIG.ws_path (default /ws/tts) on each service. These routes are not mounted behind the gateway /api_lis prefix; they are consumed directly by the conversation engine and by the gateway relay.

ElevenLabs (tts_service, :8014)

MethodPathDescription
WS/ws/ttsSynthesis stream. Inbound synthesis.start / session.ping / session.cancel; outbound synthesis.startedaudio.chunk*synthesis.completed.
GET/healthstatus ok/degraded based on api_key + voice_id + model_id; backend: "elevenlabs".
GET/statusConfig snapshot: sample_rate, audio_format, channels, max_text_chars, base_url, voice_id, model_id, output_format, api_key_set.

OmniVoice (tts_omnivoice, :8015)

MethodPathDescription
WS/ws/ttsSame protocol; synthesis.completed also carries latency and a profiling block. Always-clone voice.
GET/healthready (model loaded and profile store ok), model_error, gpu_visible, voice_profile_store, active_requests, cache_size; backend: "omnivoice".
GET/statusIn-memory metrics plus config (model_id, device, dtype, num_step_default, pcm_stream_ms, language_default, max_concurrent_synthesis, …).

Gateway voiceover relay

The gateway exposes one public TTS path. It proxies a synthesis.start session directly to the ElevenLabs service and injects the avatar's voice_clone_path / voice_clone_text into the first frame (only if not already present).

MethodPathDescription
WS/api_lis/voiceoverRelays to GATEWAY_CONFIG.tts_ws_url (ElevenLabs 8014 only). Query params user_id, api_key, session_id. No OmniVoice fallback. On upstream WS/OS error the client socket is closed with code 1014.
note

The ElevenLabs → OmniVoice fallback is exercised only on the conversation path (/api_lis/sessions/{session_id}/message with voice_output=true), where the engine pipeline drives FallbackTTSClient and delivers audio inline. See the WebSocket Protocol page for the voiceover and message flows.

Synthesis protocol

Accepted inbound events on /ws/tts (both backends): synthesis.start, session.ping, session.cancel.

tip

Note the asymmetry: the inbound trigger is synthesis.start, while the first outbound event is synthesis.started.

The outbound sequence:

synthesis.started
→ voice.resolved (only when payload.debug = true)
→ audio.chunk (repeated)
→ synthesis.completed

audio.chunk payload fields:

FieldValue
chunk_index1-based chunk counter
audiobase64-encoded PCM
audio_formatpcm16le
sample_rate24000
channels1
is_finalfalse
duration_mschunk duration

Backend differences:

  • ElevenLabssynthesis.started reports request_state: "streaming_elevenlabs", backend: "elevenlabs".
  • OmniVoicesynthesis.started reports request_state: "resolving_voice"; synthesis.completed adds latency_to_first_chunk_ms, voice_mode_used, reference_used, and a detailed profiling block.

Session control and concurrency

  • The WebSocket binds to the first session_id it sees; a different session_id returns SESSION_CANCELLED.
  • A second synthesis.start while a synthesis task is running returns REQUEST_ALREADY_IN_PROGRESS.
  • session.cancel sets the stop event, cancels the running task, and replies session.cancelled. session.ping replies session.pong.
  • ElevenLabs has no global concurrency limit (bounded only by one task per connection). OmniVoice throttles all connections through a BoundedSemaphore(TTS_MAX_CONCURRENT_SYNTHESIS) (default 1) plus a model lock — effectively one GPU synthesis at a time by default.

Request validation

RuleError code
stream: falseINVALID_PAYLOAD
audio_formatpcm16leINVALID_PAYLOAD
sample_rate24000INVALID_PAYLOAD
missing avatar_id or textMISSING_REQUIRED_FIELD
empty text after markdown/URL sanitizationINVALID_PAYLOAD

Text is sanitized (markdown, URLs, citations stripped) and truncated to max_text_chars. MODEL_NOT_READY (OmniVoice model not loaded) and unhandled INTERNAL_ERROR are reported as non-recoverable (recoverable: false); most other errors are recoverable.

Voice cloning

Per-avatar voice selection is backend-specific. Both backends receive the same synthesis.start payload, but each ignores the other's fields.

ElevenLabs uses only elevenlabs_voice_id, validated against ^[A-Za-z0-9_-]{1,64}$ (otherwise INVALID_PAYLOAD); it falls back to ELEVENLABS_CONFIG.voice_id when absent. ElevenLabsClient.stream_pcm accepts reference_audio_path / reference_text to satisfy the port contract but ignores them — reference-audio cloning is not implemented for ElevenLabs.

OmniVoice is always-clone: resolve_voice always sets reference_used = true and voice_mode_used = "reference". Reference resolution precedence:

  1. request reference_audio_path / reference_text,
  2. the avatar profile in data/voice_profiles.json (VoiceProfileStore, keyed by avatar_id),
  3. the configured fallback reference (TTS_OMNIVOICE_FALLBACK_REFERENCE_AUDIO_PATH + TTS_OMNIVOICE_FALLBACK_REFERENCE_TEXT).

Only if even the fallback reference is invalid does it raise the non-recoverable MISSING_REQUIRED_FIELD. Prepared clone prompts are cached in-process, keyed by sha1(reference_audio_path + reference_text).

note

The per-avatar values originate in the gateway avatars table (voice_clone_path, voice_clone_text, elevenlabs_voice_id). The gateway forwards them into synthesis.start: the voiceover relay injects voice_clone_path / voice_clone_text, while the conversation path forwards elevenlabs_voice_id and the clone fields through the session config into the engine's TTSClient.

Configuration

Shared audio contract

SettingEnv varDefaultPurpose
sample_rateTTS_SAMPLE_RATE24000Output PCM rate; a different requested value is rejected.
audio_formatTTS_AUDIO_FORMATpcm16leOutput format; a different requested value is rejected.
channelsTTS_CHANNELS1Mono output.
ws_pathTTS_WS_PATH/ws/ttsSynthesis WebSocket route.
warning

max_text_chars (TTS_MAX_TEXT_CHARS) differs between backends: tts_service (ElevenLabs) defaults to 5000, while tts_omnivoice inherits the root default of 2000. A single TTS_MAX_TEXT_CHARS value cannot match both.

ElevenLabs (tts_service)

SettingEnv varDefaultPurpose
api_keyELEVENLABS_API_KEY / ELEVENLABS_XI_API_KEY / XI_API_KEY""Auth; blank makes /health report degraded.
base_urlELEVENLABS_BASE_URLhttps://api.elevenlabs.ioAPI base URL.
voice_idELEVENLABS_VOICE_IDJBFqnCBsd6RMkjVDRZzbDefault voice when the request has no elevenlabs_voice_id.
model_idELEVENLABS_MODEL_IDeleven_multilingual_v2Synthesis model.
output_formatELEVENLABS_OUTPUT_FORMATpcm_24000Stream output format.
timeout_secELEVENLABS_TIMEOUT_SEC30.0HTTP client timeout.
voice settingsELEVENLABS_STABILITY / ELEVENLABS_SIMILARITY_BOOST / ELEVENLABS_STYLE / ELEVENLABS_USE_SPEAKER_BOOST0.5 / 0.8 / 0.0 / trueVoiceSettings sent on every stream call.

Supported language hints: en, pl, uk, tr, ru. Azerbaijani (az) maps to None (unsupported).

OmniVoice (tts_omnivoice)

SettingEnv varDefaultPurpose
model idTTS_OMNIVOICE_MODEL_IDk2-fsa/OmniVoiceHF model loaded via OmniVoice.from_pretrained.
deviceTTS_OMNIVOICE_DEVICEcuda:0device_map.
dtypeTTS_OMNIVOICE_DTYPEfloat16Torch dtype (float16 / bfloat16 / float32).
num_stepTTS_OMNIVOICE_NUM_STEP12Default diffusion steps (per-request num_step clamped 1–50).
pcm_stream_msTTS_OMNIVOICE_PCM_STREAM_MS20PCM chunk size (ms) for streaming (min 10).
language_defaultTTS_OMNIVOICE_LANGUAGE_DEFAULTEnglishFallback language for empty/unknown hints.
max_concurrentTTS_MAX_CONCURRENT_SYNTHESIS1Global semaphore bound on concurrent GPU syntheses.
fallback ref audioTTS_OMNIVOICE_FALLBACK_REFERENCE_AUDIO_PATH<project_root>/reference_text.wavReference audio when neither request nor profile supplies a valid clone reference.
fallback ref textTTS_OMNIVOICE_FALLBACK_REFERENCE_TEXT"Captain Jim had often talked to Anne of Lost Margaret …"Transcript paired with the fallback reference audio.

Voice profiles are loaded from data/voice_profiles.json (PATHS.voice_profiles_file). OmniVoice language mapping: en → English, az → Azerbaijani, tr → Turkish, ru → Russian; unknown hints default to language_default.

Engine wiring (fallback owner)

SettingEnv varDefaultPurpose
tts_ws_urlTTS_WS_URLws://localhost:8014/ws/ttsPrimary (ElevenLabs) backend for FallbackTTSClient.
tts_timeout_secTTS_TIMEOUT_SEC60.0WS open_timeout for the primary.
tts_omnivoice_ws_urlTTS_OMNIVOICE_WS_URLws://localhost:8015/ws/ttsFallback (OmniVoice) backend.
tts_omnivoice_timeout_secTTS_OMNIVOICE_TIMEOUT_SEC120.0WS open_timeout for the fallback.
tts_ws_url (gateway)TTS_WS_URLws://localhost:8014/ws/ttsGateway /api_lis/voiceover relay target — ElevenLabs only.