Contents

A directed cloud voice

Status: built, 2026-10-10: the core, the command line, Studio's Settings › Voices and the character studio's Voice, and each person's key on the web service. The third product audit's finding 8 (0.12 plan item 4, voice-provider): Kokoro is the only voice, and an emotion is a pitch shift, a range, a speed and a tremble done to its even take afterwards, so an excited line is a faster, higher read of the same flat voice. This is a voice that reads the direction itself, behind the person's own key, with Kokoro kept as the free, offline default.

Which voice, and why

ElevenLabs' Eleven v4, each fact from ElevenLabs' and the others' own pages, read 2026-10-10:

  • Direction in the words, at the word. "Wrap a short instruction in square brackets and place it in the text where the delivery should change." "Eleven v4 and Eleven v3 support audio tags." "We recommend Eleven v4." (audio tags). "Capitalization increases emphasis"; "Ellipses (...) add pauses and weight" (prompting).
  • One plain call. POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}, the key in xi-api-key, output_format in the query (create speech); its /with-timestamps form answers audio_base64 and each character's timing (with timestamps).
  • A price per character, in dollars. "API usage is billed in US dollars, not credits"; Eleven v4 $0.08 per 1K characters (shown at $0.022 until a promotion ends on October 12; Reelwright prices at $0.08) (API pricing). A line of 60 characters is about half a cent.
  • What the audio may be used for. "All paid plans include a commercial license, provided you're not using Beta Services." "The free plan does not include a commercial license and cannot be used for any commercial purpose", and a free plan's work must say "elevenlabs.io" in its title (publishing). Saving a key says which plan it is on (GET /v1/user/subscription, free), and a free plan is told this.

Not chosen:

  • OpenAI: "The following text-to-speech models are deprecated and will be removed from the API on January 6, 2027", gpt-4o-mini-tts and tts-1 among them, and the replacement it names runs over the Realtime API only (deprecations); its instructions cover a whole request, not a word.
  • Anthropic has no voice: "All current models support text and image input, text output…" (models).
  • Google's Gemini TTS (a style per turn, cheaper) is the runner-up; Cartesia offers six emotions in beta, English only.

The weak spot: "All our Default voices will expire on December 31, 2026" (default voices). So Reelwright names no voice of its own: a character's voice is an id from the person's own account (reelwright voices list, GET /v2/voices).

A line's direction, as sent

A character gets a binding { "provider": "elevenlabs", "voice": "<id>" } beside its Kokoro one (the character studio's Voice, below). Each of its lines is then sent as elevenRead writes it (core/src/voices/elevenlabs.ts):

The line says Sent as
its emotion (intensity under 0.35, or 0.85 and over) [sad] ([slightly angry], [very excited])
volume whisper, soft, loud, shout [whispers], [softly], [loudly], [shouts]
the voice's speed × the line's pace [slowly], [a little slowly], [a little quickly], [quickly]
its parenthetical, its note to the actor each as a tag, as written: [under her breath]
a stressed word (emphasis, leans on 'away') in capitals: AWAY
a pause (pauses, pause before 'In deep space') an ellipsis: lighthouse...
another take another seed

So the demo's l03 goes as [curious] [Quieter, full of wonder. Small pause before 'In deep space'.] It's a lighthouse... In deep space. The script's own square brackets become round ones, so no word of it is read as a tag. What is sent is in the take's key: change the direction and the line is voiced again; leave it and the store's take is used, with nothing sent. Prosody does nothing to such a take but the binding's own pitch and timbre, and the singing of a sung line.

The take is mastered as Kokoro's are (trimmed to 60 ms of silence at each end, 48 kHz mono, -18 LUFS under a -1 dBFS peak, 24-bit FLAC), and its words are timed by ElevenLabs' timing of each character sent, the tags left out; when that timing does not match the words, the audio times them.

English only for now: a dub's lines go to Kokoro, which reads nine languages.

A take a person acted in the booth can be said in the character's ElevenLabs voice too, its timing kept: voice-from-booth.md.

The key

Like the Anthropic key, never in a show and never printed: ELEVENLABS_API_KEY, else a key file only its owner can read (~/.config/reelwright/elevenlabs-api-key, or the file REELWRIGHT_ELEVENLABS_KEY_FILE names), else the macOS Keychain (-s reelwright -a elevenlabs). Studio's Settings › Voices keeps it through reelwright key set|status|forget elevenlabs [--check], the key on standard input; the check (free) says the account's plan, and a free plan is told that its audio may not be used commercially. The voice reads the key each time it speaks, so nothing restarts.

On the web service, and in the installed Studio for a show on Reelwright (which the service voices), the person pastes it in the same page, and the service keeps it as it keeps their Anthropic key (/api/voices, core/src/host/voices.ts; Studio asks with the account's device token): checked for free, sealed under KEY_ENCRYPTION_KEY in its own table, bound to its kind and its user, and handed to their own daemons on their grant line only; the hosted daemon uses that key and nothing on the machine. A person's runner voicing for them uses the key on that computer.

Choosing a voice

The character studio's Voice lists the account's voices beside Kokoro's (GET /v2/voices, asked again after ten minutes), "George · ElevenLabs, British male". Use this voice gives the character a binding { "provider": "elevenlabs", "voice": "<id>" } beside its Kokoro one, which still reads it on a computer with no key. Nothing else changes: a voice that reads direction itself and that show.json's providers.tts does not list goes just before Kokoro, so a show made with ["recorded", "kokoro"] reads that character with ElevenLabs and everyone else as before; a show that lists ["kokoro", "elevenlabs"] keeps Kokoro first.

With no key on the computer, the next voice in providers.tts (Kokoro, always last) stands in and reads the line, and the line says so itself, a warning at its own place in the episode (voice.stood_in): "kokoro read sc01/l03 instead of ElevenLabs: there is no ElevenLabs API key on this computer…". Studio's Activity says the same for the run. Its take stands in until the character's own voice can read it: voicing again with a key reads it in ElevenLabs, and the show plays that take from then on. Paid voices make a show better, never make it work.

In Studio

The timeline's Voice lines asks what voicing would cost first (episode.voice with estimate, session protocol 1.2) and, when ElevenLabs would read any line, asks before it spends, saying the most it could cost; its status says what was spent. A line Kokoro read in place of its ElevenLabs voice is marked on the timeline as an actor's recording is (a small warning before its words, its tooltip saying why: no key here, or the budget), from episode.timeline's standIns; once ElevenLabs can read it, Voice lines counts it and reads it again. Activity's entry for the run says how many lines Kokoro read and why. A daemon older than 1.2 is never asked for an estimate (it has no voice that charges), and a Studio older than 1.2 never asks and reads past the new fields.

Spending

Every line is priced before it is sent (every character sent, the tags too, at the list price, so an estimate is never under the bill), and held against the show's budget on the ledger the showrunner and the director spend from (.reelwright/spend/): maxUsdPerRequest for the whole run, maxUsdPerEpisode for every voicing of the episode (voice.<episode>, apart from directing it), maxUsdPerMonth for everything, and on the web service the person's own cap and the service's. The run says what it will cost first ("voicing 2 lines with elevenlabs, one at a time, costs at most $0.0185"), records what each line cost (ElevenLabs' character-cost header), and sends nothing past the first line the caps refuse: Kokoro stands in for that line and the rest, each saying so (voice.stood_in, with the cap's words); a line Kokoro had read before keeps that reading. Only with no voice left to stand in is a line refused (voice.budget). reelwright voice --estimate says what the missing lines would cost and sends nothing. Whatever voices the lines (reelwright voice, a render that voices missing lines first, Studio's Voice lines, an audition in the character studio, a runner on the web) goes through the same pricing and caps.

Record and replay

The calls go through a transport: the live API, recordingTransport (each call and its answer written to a file named by the call's hash), or replayTransport (answers from those files; a call never recorded is an error, never a call). The tests use a stand-in ElevenLabs that answers with real audio and timing (core/test/support/fake-eleven.ts), and a run recorded through it replays to the same takes. Nothing in the tests or CI calls ElevenLabs.

The owner's check

One line with a real key, at most $0.05:

reelwright key set elevenlabs --check        # free: says the plan
reelwright voices list                       # free: the account's voices
reelwright voices try "I never said that." --emotion sad --parenthetical "(under her breath)" --record ~/rw-voice-check

It prints what it sends ([sad] [under her breath] I never said that.) and what that costs ($0.0034, 43 characters) before sending, then writes the take. The folder it records holds the call and its answer; in core/test/fixtures/tts/voices-try/ the tests replay it. It is a step of every release (.claude/skills/verify/features/release.md, Publish step 6).