Skip to main content

Voice

Voice selection is part of onboarding, not of training. A creator either picks a stock ElevenLabs voice from a fixed roster — auditioned through pre-rendered preview files — or uploads reference audio for a cloned voice. Both land on the avatar row for LIS 3 to use at speech-synthesis time.

The roster

voice/service.py ships 21 hard-coded options: default, female_1female_10, and male_1male_10, each with a display label and an ElevenLabs voice id.

VoiceOption(key="default", label="Default", voice_id="JBFqnCBsd6RMkjVDRZzb")
VoiceOption(key="female_1", label="Female 1", voice_id="EXAVITQu4vr4xnSDxMaL")
VoiceOption(key="male_1", label="Male 1", voice_id="N2lVS1w4EtoT3dr4eOWO")

The roster can be replaced wholesale with ONBOARDING_VOICE_ROSTER_JSON — a JSON array of {key, label, voice_id} objects. The override is defensive: malformed JSON, a non-list value, or an empty result after filtering all fall back to the built-in roster, and individual entries missing a key, a label, or a valid voice id are skipped.

Voice id validation

normalize_elevenlabs_voice_id is the single gate for every voice id in the system:

  • empty or whitespace-only input returns None (no voice selected);
  • anything else must match ^[A-Za-z0-9_-]{1,64}$, or it raises.

POST /v1/runs surfaces a violation as 422. Two later call sites — smart generation and avatar-profile publication — re-normalize the stored value and fall back to None on failure, so a bad id can never propagate into the avatar row.

Previews

Previews are static files generated ahead of time, not synthesized per request. Every voice speaks the same sentence:

Hello, I am your AI assistant. This is a voice preview for selection.

GET /v1/voice-previews

{
"preview_text": "Hello, I am your AI assistant. This is a voice preview for selection.",
"generated_at": "2026-06-29T11:45:23.452978+00:00",
"voices": [
{
"key": "default",
"label": "Default",
"voice_id": "JBFqnCBsd6RMkjVDRZzb",
"preview_text": "Hello, I am your AI assistant. This is a voice preview for selection.",
"available": true,
"audio_url": "/v1/voice-previews/audio/default.mp3"
}
]
}

The response is driven by the roster, not by the manifest: every configured option is returned, and the manifest is consulted only for the filename and the generation timestamp. When a manifest entry is missing, the filename falls back to {key}.wav. available reflects whether the file actually exists on disk, and audio_url is null when it does not — so a partially generated preview set degrades per voice instead of failing the whole endpoint. A missing or unparseable manifest is treated as empty.

GET /v1/voice-previews/audio/{filename}

Serves one file from state/voice/previews/audio/. Path traversal is rejected by requiring Path(filename).name == filename (400); a nonexistent file is 404.

Generating previews

python -m voice.tools # skip voices that already have a file
python -m voice.tools --force # regenerate everything

The tool walks the configured roster, calls POST {ELEVENLABS_BASE_URL}/v1/text-to-speech/{voice_id} with stability=0.5, similarity_boost=0.8, style=0.0, use_speaker_boost=true, writes the audio to state/voice/previews/audio/, and then writes manifest.json with the model id, output format, timestamp, and a per-voice record.

Failures are captured per voice: the error text is stored in that voice's manifest entry with available: false, and the run continues to the next voice.

VariableDefaultPurpose
ELEVENLABS_API_KEY (or ELEVENLABS_XI_API_KEY, XI_API_KEY)Required; sent as xi-api-key.
ELEVENLABS_BASE_URLhttps://api.elevenlabs.io.
ELEVENLABS_MODEL_IDeleven_multilingual_v2 in the shipped profile.
ELEVENLABS_PREVIEW_OUTPUT_FORMATwav_44100Must start with wav_, or the tool raises.
--timeout-sec45.0Per-request timeout.
warning

The shipped state/voice/previews/ contains .mp3 files generated with output_format: mp3_44100_128, which the current tool would refuse — it enforces a WAV format and writes {key}.wav. Regenerating previews today therefore produces .wav files alongside the existing .mp3 ones. The API itself is unaffected, since it reads filenames from the manifest.

Startup validation

On startup the API compares the roster against the manifest and logs — without failing — a warning when manifest.json is absent, and a warning naming every configured voice_id that the manifest does not cover. Any exception while inspecting the manifest is logged with a traceback and swallowed.

Voice cloning

POST /v1/avatars/{avatar_id}/voice-clone accepts reference audio from a creator. See the API reference for the request contract; the storage rules are:

  • accepted formats are .wav, .mp3, .m4a, .ogg, .webm, detected from the filename first and the Content-Type second;
  • an empty body is 422, over 25 MB is 413;
  • both creator_id and avatar_id are sanitized for use as path segments — anything outside [A-Za-z0-9._-] becomes _, leading and trailing ./_ are stripped, and an empty result becomes unknown;
  • the file is written to state/voice/clones/{creator_id}/{avatar_id}/voice_clone_{YYYYMMDDTHHMMSS}_{8 hex chars}{ext}.

Uploads are never overwritten — the timestamp and random suffix make each filename unique — but the avatar row keeps only the most recent path in voice_clone_path. Older files stay on disk unreferenced.

voice_clone_text is set to the same standard preview sentence as the roster previews, giving the cloning backend the transcript of the reference audio.

If no avatar row exists yet, one is created by the upload, so a creator can supply reference audio before starting a run.