> ## 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へ送信し、オーディオを順次再生します。

以下に、そのままプロジェクトに貼り付けて実行できるエンドツーエンドのレシピを示します。2つのAPIキーを設定し、`voice_id`を差し替えるだけで動作します。

## ストリーミングが不要な場合もあります

`stream_speech`は`sona_speech_1`でのみサポートされています。優先事項が\*\*最初のオーディオが返るまでの総時間（time-to-first-audio）\*\*であれば、各リクエストを高速に完了する非ストリーミングモデルを選んだほうが結果的に速くなることが多くあります。

* **`supertonic_api_3`** — 最も推論が速く、レイテンシが低いモデルです。発話の安定性も大幅に向上しており、time-to-first-audioを最重視するボイスエージェントに最適です。
* **`sona_speech_2_flash`** — バランス型。`sona_speech_2`と同等の品質を保ちつつ、レイテンシが低くなっています。
* **`sona_speech_1`** + `stream_speech` — テキスト1チャンクが十分に長く、チャンク単位のストリーミングによって再生開始が意味のある形で早まる場合にのみ有効です。

下記の「文単位でLLMを処理する」パターンでは、各TTS呼び出しは短い1文を対象とします。**この場合、高速モデルで非ストリーミング呼び出しを行うほうが、`sona_speech_1`のストリーミングが最初のチャンクを出力するよりも早く完了することがほとんどです。** サンプルでは既定として`supertonic_api_3`を使用しています。`model`の文字列を入れ替えて他のモデルも試してみてください。

## レシピ

以下からLLMと言語スタックを選択してください。4つのレシピはいずれも同じ「文単位のバッチ処理」パターンに従っており、異なるのは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>

## 設計上のポイント

* **文単位のバッチ処理が重要です。** トークンを1つずつ送信すると、途切れがちで不自然な音声になります。上記の文分割処理は`.`, `!`, `?`, `。`, `！`, `？`で区切ります。さらにレイテンシを下げたい場合は、バッファが約60文字を超えた時点でカンマでもフラッシュする方法もあります。
* **送信前にマークダウンを除去してください。** インストラクションチューニング済みのモデル（特にClaude）は、回答をマークダウンで包んで返すことが多くあります（`# Title`のような見出し、`**text**`の太字、コードスパンなど）。Supertone TTSは`#`を含むテキストを拒否します（予約文字のため）。そのため上記のサンプルではすべての文を小さなヘルパー`for_tts` / `forTts`に通し、`#`、`*`、`_`、バッククォートを取り除いています。これを行わないと、Claudeレスポンスの最初の文がしばしば400で失敗します。
* **レイテンシ重視のモデル選定。** `sona_speech_2`は、ユーザーが待てるオフライン用途や高品質用途に取っておきましょう。`sona_speech_2_flash`は品質と速度のバランスが良いモデルです。`supertonic_api_3`は最も短いtime-to-first-audioと高い発話安定性を実現します。`sona_speech_1`は`stream_speech`によるチャンク単位のストリーミングをサポートする唯一のモデルです — 1文が長く、文の生成が終わる前に再生を開始したい場合に有効です。
* **保存と再生。** サンプルではすべてのオーディオチャンクを`response.wav`に追記しています。実際のエージェントでは、ディスクへの書き込みに加えて（あるいは代わりに）、各クリップをWeb AudioやPortAudioなどのオーディオ出力にパイプすることになります。
* **コネクションの再利用。** Supertoneクライアントは複数のリクエストで使い回してください。文ごとに作り直さないようにしましょう。
* **長い文。** 1文が300文字を超える場合は、SDKが内部で自動的にチャンク分割するため、こちらで分割する必要はありません。

## 関連情報

<CardGroup cols={2}>
  <Card title="モデル" icon="layer-group" href="/ja/docs/core-concepts/models">
    レイテンシ予算に合わせて適切なモデルを選択しましょう。
  </Card>

  <Card title="レイテンシ最適化" icon="gauge-high" href="/ja/docs/production/latency-optimization">
    time-to-audioを短縮するためのヒントをさらに紹介します。
  </Card>
</CardGroup>
