---
title: "Studio concepts: projects, sections, documents, and workflows"
description: "How LyricWinter Studio models stories as projects, sections, versioned documents, speakers, and asynchronous workflows, and how to edit them safely over the API."
canonical_url: https://lyricwinter.com/docs/studio-concepts
markdown_url: https://lyricwinter.com/docs/studio-concepts.md
last_updated: 2026-10-16
status: beta
---
# Studio concepts: projects, sections, documents, and workflows

> How LyricWinter Studio models stories as projects, sections, versioned documents, speakers, and asynchronous workflows, and how to edit them safely over the API.

LyricWinter Studio models a story as a **project** made of ordered **sections**. Each section has a versioned **document** of narration, dialogue, and sound-effect blocks, each spoken line belongs to a project-wide **speaker**, and long-running work such as generating audio happens in asynchronous **workflows**. This page explains each concept and how to change them safely through the LyricWinter API.

## Projects and unassigned stories

A **project** is a named container for related sections, such as the chapters of a book. All sections in a project share one cast, so a character keeps the same voice from chapter to chapter.

A **standalone story** is a single section that is not yet part of a named project. `POST /studio/sections` creates one. `GET /studio/projects` returns named projects in `projects` and standalone stories in `unassigned_sections`. A standalone story still has a `project_id`, and every project-scoped endpoint works with it. To turn a standalone story into a named project, call `PATCH /studio/projects/{projectId}` with a name.

## Sections

A **section** is one chapter, scene, or episode of story text. Sections in a project are ordered. Add one with `POST /studio/projects/{projectId}/sections`, move one with `PATCH /studio/projects/{projectId}/sections/{sectionId}`, and delete one with `DELETE`. Deletions cannot be undone through the API, so call the matching `deletion-info` endpoint first to see what will be removed.

## Documents and blocks

Every section has a **document**: the canonical, structured script of the section. Read it with `GET /studio/sections/{sectionId}/document`. A document contains:

- `document_version`, which increases on every change.
- `speakers`, the project's current speaker registry, and `speaker_registry_version`.
- `blocks`, the script in order. Each block has a `kind`:

| Block kind | What it is |
| --- | --- |
| `narration` | Narrated prose, voiced by the narrator. |
| `speech` | A line of dialogue attributed to a speaker. |
| `sfx` | A sound effect placed in the timeline. |
| `note` | A note kept in the script but not voiced. |
| `excluded` | Source text kept in the script but intentionally left out of the audio. |
| `unparsed` | Text that has not been structured into narration or dialogue yet. It is not voiced until it is. |

A new section starts as unstructured text. A `prepare_sections` or `create_audio` workflow turns it into narration and speech blocks.

For a finished speaker-labeled script, `POST /studio/sections/{sectionId}/script` creates speech blocks directly, without a prepare workflow or a hand-built AST. Use one `SPEAKER: spoken text` line per turn. A `[direction]` tag at the start of a line steers the whole turn, and a tag mid-line, as in `TORVALD: [softly] Go, Ingvild. [whispers] Please.`, anchors a direction where it appears; both become tracked directions and are removed from the spoken text. A separate `(silence 4s)` or `(silence 4000ms)` line inserts exact silence after the preceding turn. Supply a `speakers` map whose keys exactly match every label and whose values contain `voice_id` and a complete `generation_profile`. For example, `MOM: [quiet, unsteady] I saw his face.\n(silence 4s)\nMOM: I know.` compiles into two speech blocks with a timed-silence point on the first. Direction uses the selected model's supported control, such as an Inworld turn instruction or an ElevenLabs audio tag; models without a suitable whole-turn or mid-line control reject the direction. The route replaces the section's script, preserves its title and section settings, and returns the canonical document. A matching speaker name reuses and recasts that speaker across the project. It makes no generation or provider calls. Unmatched lines, missing speaker mappings, and unsupported directions fail with `400` rather than being dropped.

## Speakers and casting

A **speaker** is a character or narrator in a project. The speaker registry is shared by every section in the project and has its own `speaker_registry_version`. Each speaker has a `voice_id` and a generation profile that controls how its lines are performed. `GET /studio/projects/{projectId}/speakers` lists speakers with how often each one appears. Create Audio casts voices automatically; you can recast in the [Studio app](/studio).

### Profiles and provider options

Use `PATCH /studio/projects/{projectId}/speakers/{speakerId}/casting` to assign
a speaker's voice and complete generation profile together. The request schema
types `options` by the provider/model pair. For example:

```json
{
  "provider": "elevenlabs",
  "model": "eleven_v4",
  "fallback": "strict",
  "controls": "best_effort",
  "options": {}
}
```

These are the implemented option keys for connected Studio models. All keys
are optional; omit a key to use the provider default. Unsupported keys and
invalid values fail before a new profile is saved or paid work starts.

| Model | Provider | Accepted `options` |
| --- | --- | --- |
| `eleven_v4` | `elevenlabs` | `stability` 0-1; integer `seed` 1-4294967295. Empty `{}` preserves defaults. |
| `inworld-2` | `inworld` | `apply_text_normalization`: `APPLY_TEXT_NORMALIZATION_UNSPECIFIED`, `ON`, or `OFF`. |
| `inworld-1.5-max`, `inworld-1.5-mini` | `inworld` | The normalization option above; `speaking_rate` 0.5-1.5; `temperature` greater than 0 and at most 2. |
| `cartesia-sonic-3.6`, `cartesia-sonic-3.5` | `cartesia` | `speed` 0.6-1.5; `volume` 0.5-2. |
| `fishaudio`, `fishaudio-s2.1-pro`, `fishaudio-drama-3` | `fish_audio` | `temperature` and `top_p` 0-1; `latency`: `low`, `normal`, or `balanced`; integer `chunk_length` 100-300; boolean `normalize` and `condition_on_previous_chunks`. |
| `lightning_v3.1` | `smallest_ai` | `{}` only. |
| `gpt-4o-mini-tts`, `tts-1-hd`, `tts-1` | `openai` | `{}` only. |
| `f5tts` | `f5tts` | `{}` only. |
| `stable-audio-3-medium` (generated SFX only) | `stable_audio` | Integer `steps` 1-50. |

ElevenLabs seed is best-effort, not a guarantee of deterministic audio or a
remedy for repeated words. The connected dialogue transport does not expose
`similarity_boost`; v4 does not support `speed`. Unsupported keys are rejected
instead of silently ignored. For example, `options: {"stability": 0.5, "seed": 42}`
sets only those two supported controls.

Use the listed keys for provider options. Delivery directions and expressive
controls belong in delivery control sets. A partial block override inherits its missing
fields from the speaker's effective profile; its options are checked in that
resolved context. Changing an inherited model can make an existing override
invalid. Historical document profiles remain readable.

Catalog-only profiles such as `inworld-2-experimental`, `chatterbox_fal`,
`zyphra`, and `misotts` can still be saved for compatibility, but their Studio
generation is not connected. A saved profile does not promise usable audio:
check generation availability for the assigned voice and then `/media` for
actual audio readiness.

Fish Audio Drama 3 is an explicit engine choice: use `provider: "fish_audio"`
and `model: "fishaudio-drama-3"`. It reuses the same Fish voice reference as
the other Fish engines and uses **2× words** for generated speech and engine
samples.
A completed sample for the voice's current revision
can prepare that reference automatically during Create Audio. Manual Fish
voice preparation continues to use `model: "fishaudio-s2.1-pro"`; it does not
change the speaker's selected synthesis model.

Drama 3 accepts open-vocabulary `[directions]` before controlled speech,
inline sounds and pauses, and paired `<whisper>`, `<emphasis>`, `<soft>`,
`<fast>`, `<slow>`, `<stress>`, and `<forceful>` tags around specific words.
Existing Fish phoneme pronunciation controls also apply. Adjacent directions
are retained; Studio omits malformed cues and terminal directions with no
following speech. Saved source text remains unchanged.

### Preparing a speaker for ElevenLabs v4

ElevenLabs v4 requires a paid plan. Free accounts can use other engines, and existing audio remains playable. Requests to select v4 or create new v4 audio without an active paid plan return `403 VOICE_MODEL_SUBSCRIPTION_REQUIRED`.

Casting and voice preparation are separate. A voice's `default_model` or completed ElevenLabs engine sample does not change a Studio speaker's saved `generation_profile`. There is currently no project-level default generation profile; when no speaker or block override selects a model, Studio uses `inworld-2`. Select `provider: "elevenlabs"` and `model: "eleven_v4"` in the speaker's casting update (or a block override) before checking its readiness.

1. Read `GET /studio/sections/{sectionId}/generation-availability` and find the speech or narration block. Its `generation.requested_profile` identifies the effective model. `generation.status` is `available`, `materialization_required`, or `unavailable`; `generation.options` reports alternatives.
2. If ElevenLabs v4 is `materialization_required`, prepare the assigned voice through `POST /studio/projects/{projectId}/speakers/{speakerId}/voice-materialization-jobs` with `schema_version: 1`, a new `client_mutation_id`, `materialization_kind: "provider_asset"`, `expected_source_voice_id` set to the speaker's saved `voice_id`, `provider: "elevenlabs"`, and `model: "eleven_v4"`. Poll `GET /studio/voice-materialization-jobs/{jobId}` until it completes, then read generation availability again.
3. If you also want to audition the voice, `POST /voices/{voiceId}/engine-samples` with `model: "eleven_v4"` and the voice's current `expected_acoustic_revision` synthesizes the fixed comparison phrase. A successfully completed sample can publish a reusable ElevenLabs provider asset, so it may make Studio ready too. The sample's cached audio is not itself Studio's readiness authority: Studio requires an active provider asset for the voice's current acoustic revision. Always re-read generation availability before generating.

For any new `provider_asset` preparation, `expected_source_voice_id` pins the intended assigned voice. First admission rejects a different current cast rather than preparing the replacement voice. Once accepted, an identical request returns the original job even if casting later changes. Save the exact request and `client_mutation_id`; if acceptance is uncertain, retry both unchanged instead of adding the assertion to an existing request or generating a new mutation ID. Legacy requests without the assertion remain supported. This field does not apply to AI voice design.

For a materializable voice, `generation.reason_code` and the matching option's `reason_code` distinguish `provider_asset_missing` from `acoustic_revision_changed` when a prior-revision provider asset exists. Editing or redesigning a voice can advance its acoustic revision; the old asset then cannot generate new Studio audio. An asset can also be retired or evicted. In either case, prepare the current revision and recheck. Other unavailable conditions use the human-readable `reason` and `diagnostics`; absence of `reason_code` does not mean the block is ready.

`POST /ai-voices` currently creates Inworld-designed voices. It has no ElevenLabs Voice Design selector. Preparing such a voice for ElevenLabs v4 changes its synthesis asset, not the design provider or the speaker's selected profile.

## Versions and optimistic concurrency

Studio never silently overwrites newer work. Every mutable resource carries a version:

| Version | Protects | Where to read it |
| --- | --- | --- |
| `document_version` | One section's document | `GET /studio/sections/{sectionId}/document` |
| `speaker_registry_version` | The project's speakers and casting | The same document response |
| `section_order_version` | The order of sections in a project | `GET /studio/projects/{projectId}/sections` |
| `mastering_version` | Loudness and mastering settings | `GET /studio/projects/{projectId}/mastering-profile` |

When you change something, send the version you last read. If someone else changed it first, the request fails with `409 CONFLICT` instead of overwriting their work. Read the resource again, reapply your change, and retry. Playback compilation and new exports also validate these versions. Versions do not identify audio takes: a retake can replace audio without editing the document or cast. An existing export is an immutable historical file, not a request to compile the newest takes.

### Section pacing

Use `set_section_pacing` in `PATCH /studio/sections/{sectionId}/document`
to change the silence between consecutive audible passages without replacing
the script. Both values are integer milliseconds from `0` through `1000`:

```json
{
  "kind": "set_section_pacing",
  "same_speaker_gap_ms": 100,
  "different_speaker_gap_ms": 250
}
```

Wrap the command in the normal document-save request with current
`base_document_version`, `base_speaker_registry_version`, session and mutation
IDs, and target-version maps. Section-only changes use empty
`target_block_versions` and `target_speaker_versions` maps. Read the returned
document version before your next edit or export. Existing audio takes remain
intact; playback and new exports use the new spacing.

## Mutation IDs and actor sessions

Studio write requests include two UUIDs in the JSON body:

- **`actor_session_id`** identifies the client session making changes. Generate one when your process starts and reuse it for all requests from that process.
- **`client_mutation_id`** identifies one intended change. Generate a new one for each new change, and reuse it only when retrying the exact same request after a timeout or network error.

Replaying a `client_mutation_id` with the same body returns the original result. Some creation endpoints return `200` with `idempotent_replay: true` instead of `201`. Reusing it with a different body returns `409`. See [Errors and retries](/docs/errors-and-retries).

## Workflows

A **workflow** is a durable, asynchronous job over one or more sections. Start one with `POST /studio/projects/{projectId}/workflows` and poll `GET /studio/workflows/{workflowRunId}`. Creation, polling, cancellation, and resume return `data: { run, steps }`; read the ID and status from `data.run`, not directly from `data`. The latest-project-workflow endpoint is separately nullable when no workflow exists. There are two kinds:

| `workflow_kind` | What it does | Cost | Sections per run |
| --- | --- | --- | --- |
| `prepare_sections` | Structures text into narration and dialogue blocks. Use it to review the script before generating audio. | No audio is generated. | Up to 100 |
| `create_audio` | Runs the full pipeline: prepare, assign speakers, cast voices, direct emotion and delivery, place sounds, and generate audio. | Consumes words. | Up to 12 with both sound options on, 14 with one, 16 with none |

A Create Audio run moves through these `current_stage` values: `preparing_sections`, `assigning_speakers`, `casting_voices`, `planning_emotion`, `directing_delivery`, `placing_project_sounds`, `directing_sfx`, `preparing_voices`, and `generating_audio`. Stages that are turned off are skipped.

A run's `status` is one of `queued`, `running`, `canceling`, `completed`, `partially_completed`, `failed`, or `cancelled`. The last four are terminal. Cancel a run with `POST /studio/workflows/{workflowRunId}/cancel`. Cancellation is cooperative, so work that already started may finish first. When `resumable` is `true` on a failed or partially completed run, `POST /studio/workflows/{workflowRunId}/resume` starts a new linked run that picks up from each section's first failed stage instead of redoing finished work. If the balance runs out, the audio step fails with `error_code` `STUDIO_INSUFFICIENT_BALANCE`; add words, then resume.

Workflow progress counts summarize the run. To inspect a particular block's
current audio, read `GET /studio/sections/{sectionId}/media` and join by
`block_id`. That endpoint exposes freshness, generation activity, errors, and
playback URLs. `GET /studio/sections/{sectionId}/generation-availability`
answers generation eligibility instead; `available` is not a current-audio
status.

Create Audio has three independent options in `schema_version` 2:

- `include_generated_sfx` adds AI-generated sound effects where the story calls for them.
- `place_project_sounds` places sounds from your [sound library](/docs/api/sounds) that are linked to the project.
- `direct_emotion` runs the automatic emotion step, which adds delivery directions to spoken blocks that have none for their engine. Set it to `false` to skip the step.

All three default to `true`. Direction text sent to the provider is billed, which for ElevenLabs v4 means the audio tags are counted. A run with `direct_emotion` on can therefore cost slightly more than the text alone, and the charge is the text actually sent.

### Checking the balance

`GET /me/balance` with `account:read` returns the current settled account
balance in `data.balance.snowcoins` and the amount available after holds in
`data.balance.available_snowcoins`. This is account funding only. For Studio
promotional credits too, call `GET /studio/account-capabilities` with
`studio:read` and use `data.studio_funding.total_available_snowcoins` or
`total_available_words`. Its `as_of` identifies the snapshot. This endpoint
can claim an eligible account reward before reading its balance; repeated reads
do not award the same reward again. Subscription state describes the plan,
not the remaining generation balance. Capability reads are advisory, not live
provider-health checks; the server reserves credits atomically when paid work
starts. Billing activity is a historical ledger, not a source of per-event
resulting balances.

## Media, playback, and exports

- **Generation eligibility.** `GET /studio/sections/{sectionId}/generation-availability` checks plan access, the effective model, voice preparation, and compiler constraints. ElevenLabs v4 is unavailable on free plans, including for already prepared voices. Paid plans include a monthly allowance for custom voice clones with ElevenLabs: MyWinter 20, UltraWinter 60. New or renewed clones count; reusing a current clone and other engines do not. The allowance resets on the first of each month at 00:00 UTC, including annual plans. If cloning is unavailable, paid accounts can still use prepared current-revision voices. `generation.status: available` does not mean audio exists or guarantee synthesis quota or funding. `GET /voices/preparation-status` provides a read-only preparation check before selecting or creating a voice; signed-in requests check that account's allowance, and `unknown` means the current allowance could not be verified.
- **Media.** `GET /studio/sections/{sectionId}/media` returns one entry per block with its `freshness` (`absent`, `current`, `stale`, or `unsupported`), independent `activity` (`idle`, `generating`, or `failed`), latest generation/import `error`, and a short-lived signed `playback_url` for active audio. Use `freshness: current` plus a non-null playback URL for ready current audio. A failed retake can leave valid current audio available; do not turn `activity: failed` into an assertion that nothing can play.

Pinned `project_sound` blocks use an uploaded library revision, not Stable Audio
synthesis. They return `generation: null`, as do other blocks to which provider
generation does not apply. This is not an assertion that the sound exists or
is playable. Read `/media` to check materialization and freshness; do not require
every block to have `generation.status: available` before playback or export.

### Boundary pause handling

Request `GET /studio/sections/{sectionId}/media?schema_version=2` for each active
take's `boundary_silence_handling`. The default response is unchanged.

- `timeline`: leading/trailing timed silences are assembled outside the clip.
  Editing those boundary pauses does not require regenerating its speech.
- `fixed_in_asset`: the older generated take includes its authored boundary
  timing in the audio. Changing those points makes the take stale with
  `stale_reasons: ["timing"]` and requires regeneration for current export.
- `unsupported`: imported or non-speech media has no supported boundary-pause
  classification. Do not infer editability from its creation timestamp.
- `unavailable`: there is no active take to classify.

Internal timed silences remain part of the generated asset even in `timeline`
mode. Editing an internal pause can still require regeneration. `/media`
reports the resulting freshness; a stale historical take can remain playable.

### Playback and export identity

- **Playback manifest.** `GET /studio/sections/{sectionId}/playback-manifest` compiles the section's audio, gaps, and sound effects into one timeline for gapless playback. Pass the `document_version` and `speaker_registry_version` you read from the document.
- **Exports.** `POST /studio/exports` renders a section into `mp3`, `wav`, `m4b`, synchronized `epub`, or `srt`. Poll `GET /studio/exports/{exportId}` and download `artifact.download_url` when `status` is `completed`.

For synchronized playback of an exported file, use
`GET /studio/exports/{exportId}/manifest`. Its immutable `manifest.hash` and
block timings describe that file, even after a retake or script edit. Do not
pair an older file with the current section playback manifest. Block durations
include authored silence within the block but not the gaps between blocks. The
export manifest's `timing_basis` is
`media_and_intra_block_silence_excluding_inter_block_spacing`.

The playback manifest's `sequential_units` distinguish `unit_type: "asset"`
(the audio asset's own duration), `unit_type: "silence"` (an authored pause
assembled on the timeline), and `unit_type: "spacing"` (an inter-block gap).
Sound effects appear in `overlay_units`. For speech and narration, an export
block's `duration_ms` is the sum of its contiguous asset and silence unit
durations; it excludes spacing.
Thus an asset unit and its export block can share `start_ms` but have different
durations when that block has a trailing pause. Do not replace either duration
with the other or normalize the block to its audio asset. Join block-level
records by stable `block_id`, and per-unit records by `unit_id`, not by start
time or array position. A partial manifest does not contain audio for every
document block.

`authority.is_current` on the export status is a legacy version comparison:
it checks the document, cast, and mastering versions, not audio-take identity.
After a retake, create a new export with a new `Idempotency-Key`. Retrying the
old key intentionally returns its original export. Newly requested exports
compile the current media before content-addressed artifact reuse is checked.

Signed URLs expire. Fetch fresh ones from the API instead of storing them.

### Project-wide exports

`POST /studio/projects/{projectId}/exports` creates one parent job with a child
export for each selected section. Send an `Idempotency-Key` header and:

```json
{
  "schema_version": 1,
  "format": "mp3",
  "pin": "current"
}
```

The supported formats are `mp3`, `wav`, `m4b`, synchronized `epub`, and `srt`.
Omit `sections` to export all active sections in project order, up to 100.
For a subset, add `sections` with 1-100 unique section UUIDs belonging to that
project; the children still follow project order, not the order of your array.
Empty selections, duplicates, and selections over 100 are invalid.
For a project with more than 100 active sections, explicitly select a subset
of up to 100 per request; omitting `sections` does not truncate the project.

The parent selection and version plan are captured together and cannot change.
`pin: "current"` captures the saved document, cast, and mastering versions when
the request is enqueued. It does **not** capture all audio takes or media
revisions atomically at that moment: each child selects and pins its exact
media when it compiles against those versions. Save your edits and finish
generating the intended audio before requesting an export. This export renders
existing media; it does not run TTS or automatically retry failed paid
generation.

Creation returns `202` for a new job or `200` for a database replay. A retry
handled by HTTP idempotency can replay the original `202` and body. Reuse the
same key only for the same intended request, then poll for current state;
reusing a key with a different body returns `409`.
Reordering the same explicit section IDs is the same selection and can replay
the original request; changing the selected IDs is a different request.

1. Read the parent ID from `data.id` and poll
   `GET /studio/projects/{projectId}/exports/{exportId}` while its status is
   `queued` or `running`. Dispatch is asynchronous; queued children are
   recovered if needed, but there is no fixed completion deadline.
2. Inspect `data.sections`. Each entry has a `section_id`, captured `title`,
   child `export_id`, captured `authority` (`document_version`,
   `speaker_registry_version`, and `mastering_version`), nullable ordinary
   `export` payload, and nullable `error`. Parent `completed` means every child
   succeeded; terminal `partial_failed` means some succeeded; terminal `failed`
   means none succeeded. `percent` averages child progress and counts terminal
   failures and unavailable children as finished, so 100 percent is not a
   success guarantee. Check the section error before reading a non-null
   child's status, error, and artifact.
3. Download each available completed child's `export.artifact.download_url`
   when its artifact is non-null. For timings matching that file, read
   `GET /studio/exports/{childExportId}/manifest`; add `schema_version=2` for
   provider word timing sidecars. Cancel a child with
   `POST /studio/exports/{childExportId}/cancel`. Use the child ID, not the
   parent ID, for these operations. There is no combined ZIP or project-level
   file: `archive` is always null.

The parent preserves its captured plan without preventing section transfers or
deletion. If a child export moves to another project or is deleted, its section
entry keeps the original identity, title, export ID, and authority versions,
but returns `export: null` and
`error.code: "STUDIO_PROJECT_EXPORT_SECTION_UNAVAILABLE"`. That entry counts
as a failure, not a success, in the parent summary. The original child's status,
manifest, cancellation, and download access are not guaranteed to remain
available after transfer or cleanup.

To retrieve earlier parent jobs, use
`GET /studio/projects/{projectId}/exports`. Optional `format` filters the list;
`cursor` is a nonnegative integer offset, default 0, and `limit` is 1-10,
default 1. Pass the returned string `next_cursor` as the next offset, keeping
the same filter, until it is null.

### Export version comparisons and selected re-export

`GET /studio/projects/{projectId}/export-freshness?format=mp3` returns active
sections in project order, excluding deleted sections. Omit `format` to use
`mp3`; projects with more than 100 active sections return `400` rather than a
truncated freshness list. Each section reports its
current `document_version`, `speaker_registry_version`, `mastering_version`,
and `latest_export` for that format. With no export, `latest_export` and
`matches_current_authority` are both null.

The `basis` is `document_cast_mastering_versions`.
`matches_current_authority: true` means only that these three saved versions
match. It does not prove that the export contains the latest takes, completed
successfully, contains all media, or has a valid unexpired artifact. Inspect
`latest_export.status` and `latest_export.artifact` separately, and use the
child manifest's `completeness` when checking timeline completeness. A retake
can leave the version comparison true while the historical file contains
older audio.

An existing parent never automatically re-exports after edits or retakes. To
export updated sections, explicitly create a **new** parent with a new
`Idempotency-Key` and a `sections` array containing only the sections you want
to render again. For missing or stale audio, repair or regenerate that audio
through its normal workflow first; reading freshness or creating a parent is
not an automatic paid retry. Keep successful child files from the earlier job
and download the new children separately.

### Provider word timings

Add `schema_version=2` to the private playback-manifest or export-manifest GET
request for `unit_word_timings`. Playback still requires both saved version
parameters. The default V1 responses remain unchanged.

Each sidecar identifies its `unit_id`, `block_id`, and immutable `asset_id`.
`word_timing: "provider"` includes `words` with `text`, `start_ms`, and `end_ms`
in section-relative milliseconds, including preceding assembled pauses and gaps.
Join by unit ID, not by word or block index. These sidecars do not change the
content-addressed audio manifest or its hash. Export reads use only the assets
and hashes pinned to that export, never a newer retake's alignment.

New Cartesia speech captures normalized spoken-word timestamps in the same
synthesis request, with no separate transcription charge. Provider normalization
can change word text from the original script; these are not original-text
character offsets. Old assets, unsupported providers (including ElevenLabs),
SFX, missing alignment, or invalid alignment report `word_timing: "unavailable"`
without preventing playback. We do not fabricate timings or transcribe older
assets automatically.

Alignment is bounded to 10,000 words and 512 KiB per asset. The read's provider
timing payload budget is 2 MiB; units beyond it report unavailable. Base manifest
and unavailable-unit identity data are separate from that budget.

### Block identity and indexes

Join documents, media, workflow issues, and playback using stable `block_id`
UUIDs. A `block_index` is zero-based in the full captured document block order,
including sound effects and non-audio blocks. It is not a speech-only count.
The legacy workflow `block_number` is the corresponding one-based display
label. Positions can change after editing; they are not stable identifiers.

For indexed failure details, request
`GET /studio/workflows/{workflowRunId}/progress?schema_version=2`. It returns
the normal ready/total counts plus `block_issues` with `section_id`, `block_id`,
`block_index`, and `block_number`. The index records the order when the issue
was captured. Use the ID to find the current block after an edit. Omitting the
query keeps the original counts-only response for existing clients. Provider
text chunks are generation units; never equate their counters with document
block positions.

### Sound uploads and long ambience

Uploaded project sounds accept files up to **256 MiB** and decoded audio up to
**65 minutes**. The completed sound's `current_revision.duration_ms` and
`file_size_bytes` describe the verified, sanitized audio; the sanitized WAV may
be larger than the original compressed upload, up to **720 MiB**. Upload
metadata is not proof of duration: processing rejects audio outside the limits.

An uploaded ambience track uses an ordinary SFX block with a pinned
`content.source` with `kind: "project_sound"`, `seed: null`, and the source revision's
verified duration. To start it with the section, use a placement anchor
`{ "kind": "section", "position": "start" }` and `offset_ms: 0`. The overlay
plays for the pinned revision's verified duration; it does not loop, trim, or
automatically grow to fit later edits. A long bed is not a separate block type.
`content.duration_ms` describes the immutable source, not a requested playback
length. Changing it on an existing pinned block returns
`400 STUDIO_PROJECT_SOUND_DURATION_MISMATCH`, with `expected_duration_ms` and
`requested_duration_ms` in the error details. Placement gain and fades remain
editable, but a fade does not shorten the section timeline. To use a shorter
bed, upload and place a shorter source revision. There is no placement trim or
`play_duration_ms` field in this contract.

Generated SFX, which omit `content.source`, remain limited to **five minutes**.
Do not use the uploaded-sound limit to request longer provider-generated SFX.

## Sharing

Publish a section with `POST /studio/sections/{sectionId}/share` or a whole project with `POST /studio/projects/{projectId}/share`. Each returns a stable public link that plays the current audio. `DELETE` on the same path revokes it. See the [Sharing API](/docs/api/sharing).
