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 inxi-api-key,output_formatin the query (create speech); its/with-timestampsform answersaudio_base64and 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
instructionscover 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).