> ## Documentation Index
> Fetch the complete documentation index at: https://docs.venice.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Narrating Articles with Text-to-Speech

> Turn any web article into a narrated audio file with Venice text-to-speech.

Making one `/audio/speech` call is easy. Narrating a real article is where the interesting problems show up: the endpoint accepts at most 4096 characters per request, every voice belongs to a specific model, audio formats differ from model to model, and text written to be read looks nothing like text written to be heard.

In this tutorial we work through all four. The result is a script that turns a URL into a single audio file:

```bash theme={"system"}
python article_to_audio.py https://docs.venice.ai/overview/privacy
```

<Card title="Run this tutorial in Google Colab" icon="notebook" href="https://colab.research.google.com/github/veniceai/api-docs/blob/main/notebooks/article-narration.ipynb">
  Every step below as an executable notebook, with the narration playing inline. Nothing to install.
</Card>

We will:

1. Pick a model and a voice that suit long form narration
2. Make a single speech request and save the audio
3. Split a long article into chunks that fit the character limit
4. Join the synthesized chunks into one file with no audible seams
5. Rewrite the article into something worth listening to
6. Combine the pieces, then look at streaming for interactive use

## Setup

You need Python 3.9 or newer, the `requests` package, and a Venice API key.

```bash theme={"system"}
pip install requests
export VENICE_API_KEY="your-api-key-here"
```

## 1. Choose a model and a voice

Voices belong to models. Sending a voice from one family to a model from another is the most common first mistake, so start by listing what each model actually accepts:

```bash theme={"system"}
curl "https://api.venice.ai/api/v1/models?type=tts" \
  -H "Authorization: Bearer $VENICE_API_KEY" |
  jq -r '.data[] | "\(.id)  formats=\(.model_spec.supported_formats)  voices=\(.model_spec.voices | length)"'
```

```
tts-kokoro  formats=["mp3","opus","aac","flac","wav","pcm"]  voices=54
tts-qwen3-0-6b  formats=["mp3"]  voices=9
tts-qwen3-1-7b  formats=["mp3"]  voices=9
tts-xai-v1  formats=["mp3","wav","pcm"]  voices=26
tts-inworld-1-5-max  formats=["wav"]  voices=14
tts-chatterbox-hd  formats=["wav"]  voices=9
tts-orpheus  formats=["wav"]  voices=8
tts-elevenlabs-turbo-v2-5  formats=["mp3"]  voices=21
tts-minimax-speech-02-hd  formats=["mp3","pcm","flac"]  voices=15
tts-gemini-3-1-flash  formats=["mp3","opus","wav"]  voices=30
tts-gradium-v1  formats=["wav","pcm","opus"]  voices=12
```

`model_spec.voices` is the authoritative voice list for a model, and `supported_formats` tells you which `response_format` values it accepts. Drop the `| length` from the query to print the voice names themselves.

We will use `tts-xai-v1` with the voice `eve`. It supports `pcm`, which is what makes joining chunks straightforward in section 4.

<Note>
  Synthesis speed varies far more between TTS models than output quality does, and the gap is large enough to change your architecture. Time a realistic request against two or three candidates before committing. A chunk that one model returns in a few seconds can take another several minutes.
</Note>

## 2. Make a single request

The response body is raw audio rather than JSON, so write the bytes straight to a file.

<CodeGroup>
  ```python Python theme={"system"}
  import os
  from pathlib import Path

  import requests

  response = requests.post(
      "https://api.venice.ai/api/v1/audio/speech",
      headers={
          "Authorization": f"Bearer {os.environ['VENICE_API_KEY']}",
          "Content-Type": "application/json",
      },
      json={
          "model": "tts-xai-v1",
          "voice": "eve",
          "input": "Hello from Venice.",
          "response_format": "mp3",
      },
      timeout=300,
  )

  response.raise_for_status()
  Path("hello.mp3").write_bytes(response.content)
  ```

  ```javascript Node.js theme={"system"}
  import { writeFile } from "node:fs/promises";

  const response = await fetch("https://api.venice.ai/api/v1/audio/speech", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VENICE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "tts-xai-v1",
      voice: "eve",
      input: "Hello from Venice.",
      response_format: "mp3",
    }),
  });

  if (!response.ok) {
    throw new Error(`${response.status}: ${await response.text()}`);
  }

  await writeFile("hello.mp3", Buffer.from(await response.arrayBuffer()));
  ```

  ```bash cURL theme={"system"}
  curl https://api.venice.ai/api/v1/audio/speech \
    -H "Authorization: Bearer $VENICE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "tts-xai-v1",
      "voice": "eve",
      "input": "Hello from Venice.",
      "response_format": "mp3"
    }' \
    --output hello.mp3
  ```
</CodeGroup>

Mismatched combinations are rejected before any audio is generated, and the error tells you what would have worked instead:

```json theme={"system"}
{"error":"Voice \"eve\" is not supported by model \"tts-kokoro\". Try using a supported voice: af_alloy, af_aoede, af_bella, af_heart, af_jadzia, ...."}
```

```json theme={"system"}
{"error":"response_format \"wav\" is not supported by model \"tts-elevenlabs-turbo-v2-5\". Supported formats: mp3."}
```

## 3. Split text at the 4096 character limit

The `input` field accepts at most 4096 characters. Longer text is rejected outright rather than truncated silently:

```json theme={"system"}
{"error":"Invalid request parameters","issues":[{"code":"too_big","maximum":4096,"path":["input"]}]}
```

So we split the article first. Splitting on sentence boundaries matters, because a chunk that ends mid sentence produces an audible stumble at the join. Create `narrate.py`:

```python theme={"system"}
from __future__ import annotations

import os
import re
import sys
import wave
from concurrent.futures import ThreadPoolExecutor

import requests

BASE_URL = "https://api.venice.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['VENICE_API_KEY']}",
    "Content-Type": "application/json",
}

MODEL = "tts-xai-v1"
VOICE = "eve"
SAMPLE_RATE = 24000  # tts-xai-v1 returns 24 kHz mono signed 16-bit PCM.

SENTENCE_END = re.compile(r"(?<=[.!?])\s+")


def split_into_chunks(text: str, max_chars: int = 1500) -> list[str]:
    """Split text on sentence boundaries into chunks under the 4096-character cap."""
    chunks: list[str] = []
    current = ""

    for sentence in SENTENCE_END.split(text.strip()):
        if not sentence:
            continue
        if len(sentence) > max_chars:
            raise ValueError(f"Sentence longer than {max_chars} characters: {sentence[:80]}...")
        if len(current) + len(sentence) + 1 > max_chars:
            chunks.append(current)
            current = sentence
        else:
            current = f"{current} {sentence}" if current else sentence

    if current:
        chunks.append(current)
    return chunks
```

On a 5362 character script this produces four chunks, each ending on a sentence:

```python theme={"system"}
chunks = split_into_chunks(script)
print(f"len(chunks) = {len(chunks)}")
for index, chunk in enumerate(chunks):
    print(f"chunk {index}: {len(chunk)} chars")
```

```
len(chunks) = 4
chunk 0: 1428 chars
chunk 1: 1410 chars
chunk 2: 1489 chars
chunk 3: 1015 chars
```

The default `max_chars` is 1500 rather than something near the 4096 ceiling, and that is deliberate. Synthesis time grows with input length, so smaller chunks come back sooner and, because they run in parallel, finish the whole job faster. They also make retries cheap when one request fails.

## 4. Join the chunks into one file

Concatenating encoded audio such as MP3 is unreliable, because every chunk carries its own frame headers. Requesting `pcm` avoids the problem entirely. PCM is raw samples with no container, so joining is just appending bytes, and Python's standard library `wave` module writes the header for us.

```python theme={"system"}
def synthesize(text: str, speed: float = 1.0) -> bytes:
    """Return raw PCM audio for one chunk."""
    response = requests.post(
        f"{BASE_URL}/audio/speech",
        headers=HEADERS,
        json={
            "model": MODEL,
            "voice": VOICE,
            "input": text,
            "response_format": "pcm",
            "speed": speed,
        },
        timeout=300,
    )
    if response.status_code != 200:
        raise RuntimeError(f"TTS failed ({response.status_code}): {response.text}")
    return response.content


def narrate(text: str, out_path: str) -> str:
    chunks = split_into_chunks(text)
    print(f"Synthesizing {len(chunks)} chunks", file=sys.stderr)

    with ThreadPoolExecutor(max_workers=4) as pool:
        audio = list(pool.map(synthesize, chunks))

    with wave.open(out_path, "wb") as output:
        output.setnchannels(1)
        output.setsampwidth(2)
        output.setframerate(SAMPLE_RATE)
        for part in audio:
            output.writeframes(part)

    seconds = sum(len(part) for part in audio) / 2 / SAMPLE_RATE
    print(f"Wrote {out_path} ({seconds:.1f}s of audio)", file=sys.stderr)
    return out_path
```

A single chunk returns quickly relative to how much audio it contains:

```python theme={"system"}
pcm = synthesize(chunks[0])
print(f"bytes   = {len(pcm)}")
print(f"audio   = {len(pcm) / 2 / SAMPLE_RATE:.1f}s")
```

```
bytes   = 4269376
audio   = 88.9s
```

That request took about 13 seconds to produce 89 seconds of speech. Running the four chunks concurrently is what keeps the total reasonable: the full narration below took 15 seconds of wall clock time.

`ThreadPoolExecutor.map` returns results in the order the inputs were submitted, so the chunks land in reading order even though they were synthesized at the same time.

<Warning>
  Raw PCM carries no sample rate, so you have to supply the correct one when writing the WAV header, and it is model specific. `tts-xai-v1` returns 24 kHz while `tts-gradium-v1` returns 48 kHz. Guess wrong and the narration plays at the wrong speed and pitch.
</Warning>

To find the rate for any model, ask for one short clip as `wav` and read the header it comes back with:

```python theme={"system"}
import wave

response = requests.post(
    f"{BASE_URL}/audio/speech",
    headers=HEADERS,
    json={"model": MODEL, "voice": VOICE, "input": "Probe.", "response_format": "wav"},
    timeout=300,
)
response.raise_for_status()
with open("probe.wav", "wb") as handle:
    handle.write(response.content)

with wave.open("probe.wav") as probe:
    print(probe.getframerate(), probe.getnchannels(), probe.getsampwidth())
```

```
24000 1 2
```

## 5. Prepare text that sounds right

Scraped Markdown read aloud verbatim is close to unlistenable. URLs are the clearest example. A speech model spells them out one character at a time, so `https://docs.venice.ai/llms.txt` comes out as:

> h t t p s colon slash slash docs dot venice dot a i l l m s dot t x t

Headings, bullet markers, tables, and code blocks cause smaller versions of the same problem. Rather than fighting Markdown with regular expressions, we can ask a chat model to rewrite the article as something meant to be spoken. Create `article_to_audio.py`:

```python theme={"system"}
from __future__ import annotations

import os
import re
import sys

import requests

from narrate import narrate

BASE_URL = "https://api.venice.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['VENICE_API_KEY']}",
    "Content-Type": "application/json",
}

URL_PATTERN = re.compile(r"https?://\S+|www\.\S+")


def scrape(url: str) -> str:
    response = requests.post(
        f"{BASE_URL}/augment/scrape", headers=HEADERS, json={"url": url}, timeout=120
    )
    response.raise_for_status()
    return response.json()["content"]


def write_script(markdown: str, minutes: int = 6) -> str:
    response = requests.post(
        f"{BASE_URL}/chat/completions",
        headers=HEADERS,
        json={
            "model": "zai-org-glm-5-1",
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "You rewrite articles as scripts to be read aloud. Output plain prose only: "
                        "no Markdown, no headings, no bullet points, no URLs, no code, no emoji. "
                        "Spell out abbreviations and numbers the way a narrator would say them. "
                        "Use short sentences with clear punctuation so speech synthesis paces well."
                    ),
                },
                {
                    "role": "user",
                    "content": f"Rewrite this article as a {minutes}-minute spoken summary.\n\n{markdown[:20000]}",
                },
            ],
            "temperature": 0.4,
        },
        timeout=300,
    )
    response.raise_for_status()
    script = response.json()["choices"][0]["message"]["content"]
    return URL_PATTERN.sub("", script).strip()
```

The `URL_PATTERN` substitution stays in as a safety net for the occasional link the model leaves behind.

## 6. Put it together

The entry point scrapes, writes the script, saves it, and narrates:

```python theme={"system"}
if __name__ == "__main__":
    url = sys.argv[1]

    print("Scraping", url, file=sys.stderr)
    markdown = scrape(url)

    print(f"Writing script from {len(markdown)} characters of Markdown", file=sys.stderr)
    script = write_script(markdown)

    with open("script.txt", "w") as handle:
        handle.write(script)

    narrate(script, "article.wav")
```

Saving `script.txt` next to the audio is worth the two lines. When a narration sounds wrong the script almost always shows why, and you can fix it without paying to synthesize again.

```bash theme={"system"}
python article_to_audio.py https://docs.venice.ai/overview/privacy
```

```
Scraping https://docs.venice.ai/overview/privacy
Writing script from 7582 characters of Markdown
Synthesizing 4 chunks
Wrote article.wav (355.0s of audio)
```

Just under six minutes of narration, produced in about fifteen seconds. The script now opens with prose instead of navigation furniture:

> Venice is built on a simple but powerful principle. User privacy comes first. The platform's entire architecture flows from this philosophical commitment.

<Tip>
  To check a narration without sitting through it, send the audio back through [`/audio/transcriptions`](/guides/media/speech-to-text) and compare the transcript against `script.txt`. Transcribing the last twenty seconds is a quick way to confirm the chunks were joined in the right order, and it catches dropped chunks and spelled out URLs in seconds.
</Tip>

## Streaming for interactive use

Batch narration optimizes total time. A voice interface has the opposite priority, which is getting the first audio out as fast as possible. Setting `streaming: true` returns the body sentence by sentence as it is generated, so playback can start in about a second instead of waiting for the complete clip.

```python theme={"system"}
import time

import requests

start = time.time()
first_byte = None

with requests.post(
    "https://api.venice.ai/api/v1/audio/speech",
    headers=HEADERS,
    json={
        "model": "tts-xai-v1",
        "voice": "eve",
        "input": "Streaming returns audio while the rest is still being generated.",
        "response_format": "mp3",
        "streaming": True,
    },
    stream=True,
    timeout=300,
) as response:
    response.raise_for_status()
    with open("streamed.mp3", "wb") as audio:
        for chunk in response.iter_content(chunk_size=4096):
            if first_byte is None:
                first_byte = time.time() - start
            audio.write(chunk)

print(f"first byte: {first_byte:.2f}s   complete: {time.time() - start:.2f}s")
```

```
first byte: 0.85s   complete: 1.43s
```

Prefer `pcm` over `mp3` when you are feeding a browser's Web Audio API or an audio device directly, since it needs no decoding step.

## Request options worth knowing

| Parameter     | Notes                                                                                                                                                                                                  |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `speed`       | Accepts `0.25` to `4.0`, default `1.0`. Stay roughly within `0.8` to `1.3` for narration. Beyond that the delivery stops sounding natural.                                                             |
| `language`    | Optional hint whose accepted form is model specific. xAI and ElevenLabs take ISO 639-1 codes such as `en`, while Qwen 3 and MiniMax take full names such as `English`. Unsupported values are ignored. |
| `prompt`      | Style and emotion cue, up to 500 characters, currently honored only by the Qwen 3 models. For other families the voice choice carries the tone.                                                        |
| `temperature` | Range `0` to `2`, supported by Qwen 3, Orpheus, and Chatterbox HD. Raise it for more variation between takes.                                                                                          |

## Errors

| Status         | Cause                             | Fix                                               |
| -------------- | --------------------------------- | ------------------------------------------------- |
| `400`          | `input` over 4096 characters      | Chunk the text as in section 3                    |
| `400`          | Voice not valid for the model     | Use a voice from that model's `model_spec.voices` |
| `400`          | Format not supported by the model | Check `model_spec.supported_formats`              |
| `401`          | Missing or invalid key            | Confirm the `Authorization: Bearer` header        |
| `402`          | Insufficient balance              | Top up the account                                |
| `429`          | Rate limited                      | Lower `max_workers` and retry with backoff        |
| `500` or `503` | Capacity or inference failure     | Retry the affected chunk with jitter              |

Because the chunks are independent, a failure only ever costs you one of them, and retrying `synthesize` for that chunk is always safe.

## Next steps

A few natural extensions from here:

* Cache audio by a hash of the text, voice, and model so unchanged paragraphs are never re-synthesized.
* Swap in a cloned voice with [Voice Cloning](/guides/media/voice-cloning) so the narration uses your own.
* Generate the source text instead of scraping it, using [Cited Answers with Web Search](/guides/tools/cited-web-answers).
* Add intro or background audio with [Music and Sound Effects](/guides/media/music-and-sound-effects).

<CardGroup cols={2}>
  <Card title="Text-to-Speech" icon="volume-2" href="/guides/media/text-to-speech">
    Reference for the speech endpoint and its parameters.
  </Card>

  <Card title="Voice Cloning" icon="user" href="/guides/media/voice-cloning">
    Narrate with a custom voice instead of a preset.
  </Card>

  <Card title="Cited Answers with Web Search" icon="search" href="/guides/tools/cited-web-answers">
    Generate the text this tool narrates.
  </Card>

  <Card title="Speech-to-Text" icon="microphone" href="/guides/media/speech-to-text">
    Transcribe audio to verify a narration end to end.
  </Card>
</CardGroup>
