/v1/story keeps the script so it survives the tab it was
written in. The console surface is Studio → Story.
A block
The 4,000-character block cap sits under the 4,096-character limit on
POST /v1/audio/speech, so a block always fits in one synthesis request. That
is the point of the smaller number.
The project
/v1/story. Call it over plain HTTP:
PATCH replaces blocks wholesale when you send it, and leaves them untouched
when you omit it. There is no per-block update, so read, modify the list you got
back, and send the whole thing. A concurrent editor in another tab loses the
race.project_uuid, title, block_count, preview — the
first line with words in it, up to 280 characters — and updated_at. The full
script comes only from the detail read, because a long script is tens of
kilobytes and a library page does not need it.
Rendering it
The loop is yours to write, and it is short:- Walk the blocks in order.
- Skip every block whose
typeis nottext. A heading organizes the script for whoever is writing it; speaking “Chapter two” aloud in the middle of a story is what a heading is not for. - Skip blocks with no text.
POST /v1/audio/speechwith the block’svoice_idasvoiceand itstextasinput. Ask forwavif you are going to concatenate — stitching compressed frames is more work than decoding once.- Append the block’s
delayas silence, plus whatever beat you want between turns. The console adds 0.3 seconds of its own.
source: "story" on each synthesis request so these takes are
distinguishable from everything else in
your generation history.
Because each block is a separate request against the same stored reference clip,
a voice sounds the same in block one and block ninety. That is the
reference-clip invariant doing the work.
Sharp edges
A block can name a voice that no longer exists
voice_id is stored as text. Nothing links a block to the voice row, and
nothing stops you deleting a voice a script depends on — deliberately, because
the alternative is a voice you cannot erase on request. A script written six
months ago may name a voice that has since been deleted or has aged out of its
three-year retention window.
Synthesis is where you find out, with a 400 naming the id. Check the cast
against GET /v1/voices before rendering a long script, rather than discovering
it at block forty.
Bracketed direction is read aloud
The synthesis model has no markup vocabulary. Send"The kettle boiled. [short pause] Nobody noticed." and it says “short pause”
out loud, in the middle of your sentence.
The console resolves a small set of tags in the editor before it calls the API:
timing tags ([pause], [short pause], [long pause], [breath]) split the
line and become silence between the rendered pieces, and delivery tags
([soft], [whispering], [emphasis], [excited], [sad], [warm],
[urgent]) become an instructions string on the piece that follows. Anything
bracketed that it does not recognize is left in the text on purpose — “[sic]”
and “[laughs]” are things people write and mean to be read.
That resolution is a client convention. The API stores your text verbatim, so if
you are writing your own renderer, do the same thing: strip direction out of the
text before it becomes input.
Delivery instructions do not apply to a named voice
instructions on POST /v1/audio/speech is a voice-design description, and it
is read only when voice does not resolve to a stored clip. A block that
names an ev_… voice is conditioned on that clip, and an instructions string
sent alongside it has no effect — not an error, no effect. Direction changes the
words and the timing you send; it does not restyle a cloned voice.
Limits, cost and failures
/v1/story is not one of the OpenAI-shaped routes, so its errors use the plain
envelope rather than the OpenAI one:
Next
Text to speech
The endpoint every block goes through.
Voices
Casting: where a
voice_id comes from.