> ## 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.

# 텍스트-음성 변환으로 기사 낭독하기

> Venice 텍스트-음성 변환으로 웹 기사를 낭독된 오디오 파일로 만들고, 음성 선택, 4096자 제한, 이음새 없는 오디오 결합, 스트리밍까지 다룹니다.

`/audio/speech`를 한 번 호출하는 것은 쉽습니다. 실제 기사를 낭독하는 순간부터 흥미로운 문제들이 드러납니다. 엔드포인트는 요청당 최대 4096자를 받고, 모든 음성은 특정 모델에 속하며, 오디오 형식은 모델마다 다르고, 읽기 위해 쓴 텍스트는 듣기 위해 쓴 텍스트와 전혀 다르게 생겼습니다.

이 튜토리얼에서 그 네 가지를 모두 다룹니다. 결과물은 URL을 하나의 오디오 파일로 바꾸는 스크립트입니다:

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

우리는 다음을 수행합니다:

1. 긴 형식의 낭독에 적합한 모델과 음성을 고릅니다
2. 하나의 음성 요청을 보내고 오디오를 저장합니다
3. 긴 기사를 문자 제한에 맞도록 청크(chunk)로 분할합니다
4. 합성된 청크를 이음새가 들리지 않도록 하나의 파일로 결합합니다
5. 기사를 들을 만한 형태로 다시 씁니다
6. 조각들을 결합하고, 이어서 대화형 사용을 위한 스트리밍을 살펴봅니다

## 준비 사항

Python 3.9 이상, `requests` 패키지, 그리고 Venice API 키가 필요합니다.

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

## 1. 모델과 음성 선택하기

음성은 모델에 속합니다. 한 모델 계열의 음성을 다른 계열의 모델에 보내는 것이 가장 흔한 첫 실수이므로, 각 모델이 실제로 무엇을 받아들이는지 나열하는 것으로 시작하세요:

```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-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`는 해당 모델의 공식적인 음성 목록이며, `supported_formats`는 어떤 `response_format` 값들을 받아들이는지 알려줍니다. 쿼리에서 `| length`를 빼면 음성 이름 자체를 출력할 수 있습니다.

우리는 음성 `eve`와 함께 `tts-xai-v1`을 사용합니다. 이 모델은 `pcm`을 지원하는데, 이는 4단계에서 청크를 결합하는 작업을 단순하게 만들어 줍니다.

<Note>
  합성 속도는 TTS 모델 사이에서 출력 품질보다 훨씬 더 크게 차이 나며, 그 격차는 아키텍처를 바꿀 만큼 큽니다. 확정하기 전에 두세 개의 후보를 대상으로 현실적인 요청의 시간을 측정해 보세요. 한 모델에서 몇 초 만에 돌아오는 청크가 다른 모델에서는 몇 분이 걸릴 수 있습니다.
</Note>

## 2. 하나의 요청 보내기

응답 본문은 JSON이 아니라 원시(raw) 오디오이므로, 바이트를 그대로 파일에 기록하세요.

<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>

일치하지 않는 조합은 오디오가 생성되기 전에 거부되며, 에러 메시지는 대신 어떤 값을 사용하면 되는지 알려줍니다:

```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. 4096자 제한에 맞춰 텍스트 분할하기

`input` 필드는 최대 4096자를 받습니다. 더 긴 텍스트는 조용히 잘리지 않고 아예 거부됩니다:

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

그래서 기사를 먼저 분할합니다. 문장 경계에서 분할하는 것이 중요합니다. 문장 중간에서 끝나는 청크는 결합부에서 눈에 띄는 어긋남을 만들기 때문입니다. `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
```

5362자짜리 스크립트에서 이 함수는 각각 문장으로 끝나는 네 개의 청크를 만듭니다:

```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
```

기본 `max_chars`가 4096 상한 근처가 아니라 1500인 것은 의도적입니다. 합성 시간은 입력 길이에 따라 늘어나므로, 작은 청크가 더 빨리 돌아오고, 병렬로 실행되기 때문에 전체 작업을 더 빨리 끝냅니다. 또한 한 요청이 실패했을 때 재시도 비용도 저렴해집니다.

## 4. 청크를 하나의 파일로 결합하기

MP3와 같은 인코딩된 오디오를 이어붙이는 것은 신뢰할 수 없습니다. 각 청크가 자체 프레임 헤더를 지니고 있기 때문입니다. `pcm`을 요청하면 이 문제를 완전히 피할 수 있습니다. PCM은 컨테이너가 없는 원시 샘플이므로 결합은 단순히 바이트를 이어붙이는 것이고, Python 표준 라이브러리의 `wave` 모듈이 헤더를 대신 써 줍니다.

```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
```

단일 청크는 자신이 포함한 오디오 양에 비해 빠르게 돌아옵니다:

```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
```

이 요청은 89초 분량의 음성을 생성하는 데 약 13초가 걸렸습니다. 네 청크를 동시에 실행하는 것이 전체 시간을 합리적으로 유지하는 비결입니다. 아래의 전체 낭독은 실제로 15초의 벽시계 시간이 걸렸습니다.

`ThreadPoolExecutor.map`은 입력이 제출된 순서로 결과를 반환하므로, 같은 시간에 합성되었더라도 청크는 읽기 순서대로 정렬됩니다.

<Warning>
  원시 PCM은 샘플 레이트를 포함하지 않으므로 WAV 헤더를 쓸 때 올바른 값을 직접 제공해야 하며, 이 값은 모델별로 다릅니다. `tts-xai-v1`은 24 kHz를, `tts-gradium-v1`은 48 kHz를 반환합니다. 잘못 추측하면 낭독이 잘못된 속도와 음정으로 재생됩니다.
</Warning>

임의의 모델에 대한 샘플 레이트를 찾으려면, 짧은 클립을 `wav`로 요청한 뒤 돌아온 헤더를 읽으세요:

```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. 자연스럽게 들리는 텍스트 준비하기

스크랩된 Markdown을 그대로 낭독하면 거의 들을 수 없는 수준이 됩니다. URL이 가장 명확한 예입니다. 음성 모델은 URL을 한 글자씩 읽으므로 `https://docs.venice.ai/llms.txt`가 이렇게 나옵니다:

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

제목, 불릿, 표, 코드 블록도 같은 문제를 더 작은 규모로 일으킵니다. 정규표현식으로 Markdown과 싸우는 대신, 채팅 모델에게 기사를 말로 하기에 적합한 형태로 다시 쓰도록 요청할 수 있습니다. `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()
```

`URL_PATTERN` 치환은 모델이 남긴 링크에 대한 안전망으로 남아 있습니다.

## 6. 하나로 합치기

진입점은 스크래핑하고, 스크립트를 작성하고, 저장한 뒤 낭독합니다:

```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")
```

`script.txt`를 오디오 옆에 저장하는 두 줄은 충분히 가치가 있습니다. 낭독이 이상하게 들릴 때 스크립트가 거의 항상 이유를 보여주며, 다시 비용을 들여 합성하지 않고도 수정할 수 있기 때문입니다.

```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)
```

6분에 조금 못 미치는 낭독이 약 15초 만에 생성되었습니다. 스크립트는 이제 내비게이션 요소가 아니라 산문으로 시작합니다:

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

<Tip>
  낭독 전체를 앉아서 듣지 않고 확인하려면, 오디오를 [`/audio/transcriptions`](/guides/media/speech-to-text)로 다시 보내 그 결과를 `script.txt`와 비교하세요. 마지막 20초를 전사(transcribe)하는 것만으로도 청크가 올바른 순서로 결합되었는지 빠르게 확인할 수 있으며, 누락된 청크나 한 글자씩 읽힌 URL을 몇 초 만에 잡아냅니다.
</Tip>

## 대화형 사용을 위한 스트리밍

배치 낭독은 전체 시간을 최적화합니다. 음성 인터페이스는 그와 정반대의 우선순위, 즉 첫 오디오를 최대한 빨리 내보내는 것을 우선합니다. `streaming: true`를 설정하면 본문이 생성되는 대로 문장 단위로 반환되어, 클립 전체를 기다리는 대신 약 1초 만에 재생을 시작할 수 있습니다.

```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
```

브라우저의 Web Audio API나 오디오 장치에 직접 전달할 때는 디코딩 단계가 필요 없으므로 `mp3`보다 `pcm`을 선호하세요.

## 알아둘 만한 요청 옵션

| 매개변수          | 설명                                                                                                                                   |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `speed`       | `0.25`에서 `4.0`까지 허용, 기본값 `1.0`. 낭독에는 대략 `0.8`에서 `1.3` 사이를 유지하세요. 그 범위를 벗어나면 발화가 자연스럽게 들리지 않습니다.                                      |
| `language`    | 선택적 힌트로, 허용되는 형식은 모델별로 다릅니다. xAI와 ElevenLabs는 `en` 같은 ISO 639-1 코드를 받고, Qwen 3와 MiniMax는 `English` 같은 전체 이름을 받습니다. 지원되지 않는 값은 무시됩니다. |
| `prompt`      | 스타일 및 감정 신호로, 최대 500자이며 현재는 Qwen 3 모델만 반영합니다. 다른 계열에서는 음성 선택 자체가 톤을 결정합니다.                                                           |
| `temperature` | `0`에서 `2` 범위이며 Qwen 3, Orpheus, Chatterbox HD에서 지원됩니다. 테이크(take) 간 변동을 늘리려면 값을 올리세요.                                                 |

## 에러

| 상태             | 원인                 | 해결                                  |
| -------------- | ------------------ | ----------------------------------- |
| `400`          | `input`이 4096자를 초과 | 3단계처럼 텍스트를 청크로 분할                   |
| `400`          | 모델에서 유효하지 않은 음성    | 해당 모델의 `model_spec.voices`에서 음성을 사용 |
| `400`          | 모델이 지원하지 않는 형식     | `model_spec.supported_formats` 확인   |
| `401`          | 키가 없거나 유효하지 않음     | `Authorization: Bearer` 헤더 확인       |
| `402`          | 잔액 부족              | 계정에 충전                              |
| `429`          | 속도 제한              | `max_workers`를 낮추고 백오프와 함께 재시도      |
| `500` 또는 `503` | 용량 또는 추론 실패        | 영향을 받은 청크를 지터(jitter)와 함께 재시도       |

청크가 서로 독립적이기 때문에, 실패는 언제나 한 청크만 대가로 치르게 되며, 그 청크에 대해 `synthesize`를 재시도하는 것은 언제나 안전합니다.

## 다음 단계

여기서 자연스럽게 이어갈 수 있는 확장 몇 가지:

* 텍스트, 음성, 모델의 해시를 키로 오디오를 캐시해서, 변경되지 않은 단락은 결코 다시 합성하지 않게 하세요.
* [음성 클로닝](/guides/media/voice-cloning)으로 여러분 자신의 음성을 낭독에 사용하도록 클로닝된 음성으로 교체하세요.
* 스크랩하는 대신, [웹 검색으로 인용 답변 만들기](/guides/tools/cited-web-answers)를 사용해 소스 텍스트 자체를 생성하세요.
* [음악과 사운드 이펙트](/guides/media/music-and-sound-effects)로 인트로나 배경 오디오를 추가하세요.

<CardGroup cols={2}>
  <Card title="텍스트-음성 변환" icon="volume-2" href="/guides/media/text-to-speech">
    speech 엔드포인트와 매개변수에 대한 레퍼런스.
  </Card>

  <Card title="음성 클로닝" icon="user" href="/guides/media/voice-cloning">
    프리셋 대신 사용자 지정 음성으로 낭독하세요.
  </Card>

  <Card title="웹 검색으로 인용 답변 만들기" icon="search" href="/guides/tools/cited-web-answers">
    이 도구가 낭독할 텍스트를 생성하세요.
  </Card>

  <Card title="음성-텍스트 변환" icon="microphone" href="/guides/media/speech-to-text">
    낭독을 종단 간(end to end)으로 검증하기 위해 오디오를 전사하세요.
  </Card>
</CardGroup>
