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

# LLM 응답을 TTS로 스트리밍하기

> LLM이 생성한 텍스트를 Supertone을 거쳐 흘려보내, 답변이 만들어지는 동시에 사용자가 음성을 들을 수 있게 합니다 — OpenAI와 Anthropic용 실행 가능한 예제.

<Note>
  이 문서는 영어 원문을 기반으로 자동 번역되었습니다. 표현이 어색하거나 모호한 부분이 있을 수 있으니, 정확한 내용은 [영어 원문](/en/docs/examples/llm-streaming-tts)을 함께 확인해 주세요.
</Note>

보이스 에이전트와 챗봇에서는 LLM이 응답을 모두 생성한 뒤가 아니라 **응답이 생성되는 동안** 사용자가 답변을 들을 수 있어야 합니다. 패턴은 다음과 같습니다.

1. LLM에서 토큰을 스트리밍합니다.
2. 문장 단위 크기의 청크로 묶습니다.
3. 각 청크를 Supertone TTS로 보내 오디오를 전달합니다.

아래는 새 프로젝트에 그대로 붙여 넣고 실행할 수 있는 엔드투엔드 레시피입니다. 두 개의 API Key를 설정하고 `voice_id`만 교체하면 끝입니다.

## 스트리밍이 꼭 필요하지 않을 수도 있습니다

`stream_speech`는 `sona_speech_1`에서만 지원됩니다. 만약 우선순위가 **전체 첫 오디오 출력까지의 시간(time-to-first-audio)** 이라면, 각 요청을 빠르게 완료하는 비스트리밍 모델을 선택하는 편이 더 빠른 경우가 많습니다.

* **`supertonic_api_3`** — 가장 빠른 추론 속도와 가장 낮은 지연시간을 제공하며, 음성 안정성이 크게 향상되었습니다. 첫 오디오까지의 시간이 가장 중요한 보이스 에이전트에 가장 적합합니다.
* **`sona_speech_2_flash`** — 균형형으로, `sona_speech_2`와 비슷한 품질을 유지하면서 지연시간이 더 낮습니다.
* `stream_speech`를 사용하는 **`sona_speech_1`** — 청크 스트리밍으로 재생 시작을 의미 있게 앞당길 수 있을 만큼 단일 텍스트 청크가 충분히 긴 경우에만 유용합니다.

아래의 LLM 문장 단위 패턴에서는 각 TTS 호출이 짧은 한 문장만 다루므로, **빠른 모델에서의 비스트리밍 호출이 `sona_speech_1`의 스트리밍이 청크를 내보내기 시작하기도 전에 응답을 끝내는 경우가 대부분입니다.** 아래 예제는 기본값으로 `supertonic_api_3`를 사용하며, `model` 문자열을 바꾸어 다른 모델을 시도해 볼 수 있습니다.

## 레시피

아래에서 사용할 LLM과 언어 스택을 선택하세요. 네 가지 레시피 모두 동일한 문장 배칭 패턴을 따르며, LLM 스트리밍 부분만 다릅니다.

<Tabs>
  <Tab title="Python · Anthropic">
    ```bash theme={"dark"}
    pip install supertone anthropic
    export SUPERTONE_API_KEY="Kp9mZ3xQ7v..."
    export ANTHROPIC_API_KEY="sk-ant-..."
    ```

    ```python theme={"dark"}
    import os
    import re
    from anthropic import Anthropic
    from supertone import Supertone

    VOICE_ID = "20160a4c5ba38967330c84"  # replace with your voice ID
    MODEL = "supertonic_api_3"            # try sona_speech_2_flash for higher quality

    SENTENCE_END = re.compile(r"[.!?。！？]\s+")
    # Supertone TTS rejects text containing '#' (reserved). Instruction-tuned
    # LLMs often emit markdown — strip the common inline markers before sending.
    MARKDOWN_MARKERS = re.compile(r"[#*_`]+")

    def for_tts(text: str) -> str:
        return MARKDOWN_MARKERS.sub("", text).strip()

    def sentences_from_stream(token_stream):
        """Yield sentence-sized strings from an iterable of text tokens."""
        buffer = ""
        for token in token_stream:
            buffer += token
            while True:
                match = SENTENCE_END.search(buffer)
                if not match:
                    break
                sentence = for_tts(buffer[: match.end()])
                if sentence:
                    yield sentence
                buffer = buffer[match.end():]
        tail = for_tts(buffer)
        if tail:
            yield tail

    def stream_claude_tokens(prompt: str):
        anthropic = Anthropic()  # reads ANTHROPIC_API_KEY from env
        with anthropic.messages.stream(
            model="claude-sonnet-4-5",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}],
        ) as stream:
            for text in stream.text_stream:
                yield text

    def play_or_save(audio_bytes: bytes, path: str):
        """Replace with your audio player. Here we just append to a file."""
        with open(path, "ab") as f:
            f.write(audio_bytes)

    def main():
        prompt = "Tell me a short story about a curious robot in three sentences."
        out_path = "response.wav"
        open(out_path, "wb").close()  # truncate

        with Supertone(api_key=os.environ["SUPERTONE_API_KEY"]) as supertone:
            for sentence in sentences_from_stream(stream_claude_tokens(prompt)):
                print(f"→ {sentence}")
                response = supertone.text_to_speech.create_speech(
                    voice_id=VOICE_ID,
                    text=sentence,
                    language="en",
                    model=MODEL,
                )
                play_or_save(response.result.read(), out_path)

        print(f"Saved {out_path}")

    if __name__ == "__main__":
        main()
    ```
  </Tab>

  <Tab title="Python · OpenAI">
    ```bash theme={"dark"}
    pip install supertone openai
    export SUPERTONE_API_KEY="Kp9mZ3xQ7v..."
    export OPENAI_API_KEY="sk-..."
    ```

    ```python theme={"dark"}
    import os
    import re
    from openai import OpenAI
    from supertone import Supertone

    VOICE_ID = "20160a4c5ba38967330c84"  # replace with your voice ID
    MODEL = "supertonic_api_3"            # try sona_speech_2_flash for higher quality

    SENTENCE_END = re.compile(r"[.!?。！？]\s+")
    # Supertone TTS rejects text containing '#' (reserved). Instruction-tuned
    # LLMs often emit markdown — strip the common inline markers before sending.
    MARKDOWN_MARKERS = re.compile(r"[#*_`]+")

    def for_tts(text: str) -> str:
        return MARKDOWN_MARKERS.sub("", text).strip()

    def sentences_from_stream(token_stream):
        buffer = ""
        for token in token_stream:
            buffer += token
            while True:
                match = SENTENCE_END.search(buffer)
                if not match:
                    break
                sentence = for_tts(buffer[: match.end()])
                if sentence:
                    yield sentence
                buffer = buffer[match.end():]
        tail = for_tts(buffer)
        if tail:
            yield tail

    def stream_openai_tokens(prompt: str):
        openai = OpenAI()  # reads OPENAI_API_KEY from env
        stream = openai.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield delta

    def main():
        prompt = "Tell me a short story about a curious robot in three sentences."
        out_path = "response.wav"
        open(out_path, "wb").close()  # truncate

        with Supertone(api_key=os.environ["SUPERTONE_API_KEY"]) as supertone:
            for sentence in sentences_from_stream(stream_openai_tokens(prompt)):
                print(f"→ {sentence}")
                response = supertone.text_to_speech.create_speech(
                    voice_id=VOICE_ID,
                    text=sentence,
                    language="en",
                    model=MODEL,
                )
                with open(out_path, "ab") as f:
                    f.write(response.result.read())

        print(f"Saved {out_path}")

    if __name__ == "__main__":
        main()
    ```
  </Tab>

  <Tab title="TypeScript · Anthropic">
    ```bash theme={"dark"}
    npm add @supertone/supertone @anthropic-ai/sdk
    export SUPERTONE_API_KEY="Kp9mZ3xQ7v..."
    export ANTHROPIC_API_KEY="sk-ant-..."
    ```

    ```typescript theme={"dark"}
    import Anthropic from "@anthropic-ai/sdk";
    import { Supertone } from "@supertone/supertone";
    import * as fs from "node:fs";

    const VOICE_ID = "20160a4c5ba38967330c84"; // replace with your voice ID
    const MODEL = "supertonic_api_3";          // try sona_speech_2_flash for higher quality

    const SENTENCE_END = /[.!?。！？]\s+/;
    // Supertone TTS rejects text containing '#' (reserved). Instruction-tuned
    // LLMs often emit markdown — strip the common inline markers before sending.
    const MARKDOWN_MARKERS = /[#*_`]+/g;
    const forTts = (text: string) => text.replace(MARKDOWN_MARKERS, "").trim();

    async function* sentencesFromStream(tokenStream: AsyncIterable<string>) {
      let buffer = "";
      for await (const token of tokenStream) {
        buffer += token;
        while (true) {
          const match = SENTENCE_END.exec(buffer);
          if (!match) break;
          const sentence = forTts(buffer.slice(0, match.index + match[0].length));
          if (sentence) yield sentence;
          buffer = buffer.slice(match.index + match[0].length);
        }
      }
      const tail = forTts(buffer);
      if (tail) yield tail;
    }

    async function* streamClaudeTokens(prompt: string) {
      const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY from env
      const stream = anthropic.messages.stream({
        model: "claude-sonnet-4-5",
        max_tokens: 1024,
        messages: [{ role: "user", content: prompt }],
      });
      for await (const event of stream) {
        if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
          yield event.delta.text;
        }
      }
    }

    async function main() {
      const prompt = "Tell me a short story about a curious robot in three sentences.";
      const outPath = "response.wav";
      fs.writeFileSync(outPath, Buffer.alloc(0));

      const supertone = new Supertone({ apiKey: process.env.SUPERTONE_API_KEY });

      for await (const sentence of sentencesFromStream(streamClaudeTokens(prompt))) {
        console.log(`→ ${sentence}`);
        const response = await supertone.textToSpeech.createSpeech({
          voiceId: VOICE_ID,
          apiConvertTextToSpeechUsingCharacterRequest: {
            text: sentence,
            language: "en",
            model: MODEL,
          },
        });

        if (response.result instanceof Uint8Array) {
          fs.appendFileSync(outPath, response.result);
        } else if (response.result && "getReader" in response.result) {
          const reader = (response.result as ReadableStream<Uint8Array>).getReader();
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            if (value) fs.appendFileSync(outPath, value);
          }
        }
      }

      console.log(`Saved ${outPath}`);
    }

    main();
    ```
  </Tab>

  <Tab title="TypeScript · OpenAI">
    ```bash theme={"dark"}
    npm add @supertone/supertone openai
    export SUPERTONE_API_KEY="Kp9mZ3xQ7v..."
    export OPENAI_API_KEY="sk-..."
    ```

    ```typescript theme={"dark"}
    import OpenAI from "openai";
    import { Supertone } from "@supertone/supertone";
    import * as fs from "node:fs";

    const VOICE_ID = "20160a4c5ba38967330c84"; // replace with your voice ID
    const MODEL = "supertonic_api_3";          // try sona_speech_2_flash for higher quality

    const SENTENCE_END = /[.!?。！？]\s+/;
    // Supertone TTS rejects text containing '#' (reserved). Instruction-tuned
    // LLMs often emit markdown — strip the common inline markers before sending.
    const MARKDOWN_MARKERS = /[#*_`]+/g;
    const forTts = (text: string) => text.replace(MARKDOWN_MARKERS, "").trim();

    async function* sentencesFromStream(tokenStream: AsyncIterable<string>) {
      let buffer = "";
      for await (const token of tokenStream) {
        buffer += token;
        while (true) {
          const match = SENTENCE_END.exec(buffer);
          if (!match) break;
          const sentence = forTts(buffer.slice(0, match.index + match[0].length));
          if (sentence) yield sentence;
          buffer = buffer.slice(match.index + match[0].length);
        }
      }
      const tail = forTts(buffer);
      if (tail) yield tail;
    }

    async function* streamOpenAITokens(prompt: string) {
      const openai = new OpenAI(); // reads OPENAI_API_KEY from env
      const stream = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: prompt }],
        stream: true,
      });
      for await (const chunk of stream) {
        const delta = chunk.choices[0]?.delta?.content;
        if (delta) yield delta;
      }
    }

    async function main() {
      const prompt = "Tell me a short story about a curious robot in three sentences.";
      const outPath = "response.wav";
      fs.writeFileSync(outPath, Buffer.alloc(0));

      const supertone = new Supertone({ apiKey: process.env.SUPERTONE_API_KEY });

      for await (const sentence of sentencesFromStream(streamOpenAITokens(prompt))) {
        console.log(`→ ${sentence}`);
        const response = await supertone.textToSpeech.createSpeech({
          voiceId: VOICE_ID,
          apiConvertTextToSpeechUsingCharacterRequest: {
            text: sentence,
            language: "en",
            model: MODEL,
          },
        });

        if (response.result instanceof Uint8Array) {
          fs.appendFileSync(outPath, response.result);
        } else if (response.result && "getReader" in response.result) {
          const reader = (response.result as ReadableStream<Uint8Array>).getReader();
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            if (value) fs.appendFileSync(outPath, value);
          }
        }
      }

      console.log(`Saved ${outPath}`);
    }

    main();
    ```
  </Tab>
</Tabs>

## 설계 노트

* **문장 배칭이 중요합니다.** 토큰을 하나씩 전송하면 끊기고 부자연스러운 음성이 만들어집니다. 위의 문장 분리기는 `.`, `!`, `?`, `。`, `！`, `？`에서 플러시합니다. 지연시간을 더 줄이고 싶다면, 버퍼가 약 60자를 넘었을 때 쉼표에서도 플러시하도록 할 수 있습니다.
* **전송 전에 마크다운을 제거하세요.** 인스트럭션 튜닝된 모델(특히 Claude)은 답변을 마크다운으로 감싸 출력하는 경우가 많습니다. `# Title` 같은 헤딩, `**text**` 같은 볼드, 코드 스팬 등이 그 예입니다. Supertone TTS는 `#`(예약 문자)이 포함된 텍스트를 거부하므로, 위 스니펫은 모든 문장을 `for_tts` / `forTts`라는 작은 헬퍼에 통과시켜 `#`, `*`, `_`, 백틱을 제거합니다. 이 과정을 거치지 않으면 Claude 응답의 첫 문장은 보통 400 오류로 실패합니다.
* **지연시간을 고려한 모델 선택.** `sona_speech_2`는 사용자가 기다릴 수 있는 오프라인 / 고품질 용도에만 사용하세요. `sona_speech_2_flash`는 품질과 속도의 균형이 좋습니다. `supertonic_api_3`는 높은 음성 안정성과 함께 가장 빠른 첫 오디오 출력 시간을 제공합니다. `sona_speech_1`은 `stream_speech` 청크 스트리밍을 지원하는 유일한 모델로, 단일 문장이 길어 문장이 끝나기 전에 재생을 시작하고 싶을 때 유용합니다.
* **저장 대 재생.** 위 예제는 모든 오디오 청크를 `response.wav`에 이어 붙입니다. 실제 에이전트에서는 디스크에 쓰는 대신(또는 그와 함께) 각 클립을 오디오 출력(Web Audio, PortAudio 등)으로 흘려보내야 합니다.
* **커넥션 재사용.** Supertone 클라이언트는 요청 간에 재사용하세요 — 문장마다 새로 만들지 마세요.
* **긴 문장 처리.** 단일 문장이 300자를 초과해도 SDK가 내부적으로 자동 분할하므로, 직접 추가로 나눌 필요는 없습니다.

## 관련 문서

<CardGroup cols={2}>
  <Card title="모델" icon="layer-group" href="/ko/docs/core-concepts/models">
    지연시간 예산에 맞는 모델을 선택하세요.
  </Card>

  <Card title="지연시간 최적화" icon="gauge-high" href="/ko/docs/production/latency-optimization">
    오디오 출력까지의 시간을 줄이기 위한 더 많은 팁입니다.
  </Card>
</CardGroup>
