1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Micsoft Speechで音声をストリーミングで読み上げる

Posted at

 大規模言語モデル(LLM)の開発では、ストリーミング出力の場面によく直面します。つまり、LLMは一度にすべての内容を生成するのではなく、タイプライターのように段階的に単語を出力します。この手法は、チャットボットや音声インタラクション、スマートアシスタントなどで特によく使われており、応答速度が速く、ユーザー体験もより自然です。

 しかしここで問題となるのが、モデルが生成したテキストを音声でも同時に聞きたい場合です。たとえばマイクロソフトのAzure Speech Service(または他のTTSサービス)などでテキストを音声に変換する際、多くのTTS APIは一括テキスト処理を前提としています。すなわち、まず一文や段落全体をTTSに渡した後に音声合成・読み上げが開始されます。これによって遅延が発生し、ユーザーが音声を聞くまでに待ち時間が生じてしまい、体験が損なわれます。


一、LLMのストリーミング出力とは

ストリーミング出力(streaming generation)とは、モデルが生成した内容をリアルタイムでフロントエンドに逐次送信する仕組みです。例えば次のような質問をした場合:

「太陽系の惑星について紹介してください」

LLMはまず「太陽系には8つの惑星があります」と出力し、その後「水星、金星、地球……」などと一文ずつ送信します。一気に500字を生成して返すのではなく、生成しながら随時送り出すイメージです。

技術的には、Server-Sent Events(SSE)やWebSocketにより、バックエンドのモデルがトークン(単語や記号など)を生成するごとに、フロントエンドへ即座に送信し、連続的なテキストストリームを形成します。


二、Speechの「一括式」読み上げ

一方、音声合成(TTS)のロジックは異なります。たとえばMicrosoftのAzure Speechでは、通常、文全体や段落のようにまとめられたテキストを入力します。例:

「太陽系には8つの惑星があります。水星、金星、地球、火星、木星、土星、天王星、海王星が含まれます。」

このようなテキストを受け取ってから音声合成処理がスタートし、読み上げが始まります。バッチ処理方式であるため、テキスト生成や音声生成が完了するまで再生が開始されません。


三、両者をどう組み合わせるか?

目標は、「LLMが生成しながらSpeechが同時に読み上げる」――まるで「考えながら話す」ような体験を実現することです。

解決方法

このためには、以下のような「ミドルウェア」的な仕組みが必要となります:

  • LLMのストリーミング出力を監視し、新しいトークンや文を受け取るごとに処理
  • 閾値や文の終端記号(例:句点、読点、段落記号など)に達したら、その部分を音声合成に送信
  • TTS側にストリーミングAPIがあれば活用し、なければ小分けして再生する仕組みを疑似的に構築
  • 音声ストリームを再生しつつ、次の音声データも同時に受信・再生して切れ目を減らす

Azure Speechを使う場合は「Push audio stream」というAPIインターフェイスがサポートされています。つまり、テキストを順次送信することで、逐次的な読み上げが可能です。テキストの長さや構成に制約はありますが、実験開発用途なら十分に活用できます。


四、実装時の注意点

  • 分割(バッチング)戦略:1トークンごとにTTSに送ると分割過多で中断が増えます。文終端・句読点・数単語ごとなど、まとまりを意識して送るのが最適です。
  • TTS合成時間:ストリーミング再生でもTTS自体のオーディオ生成に時間がかかるため、バッファリングやキュー設計が重要です。
  • 音声バッファ・継ぎ目:再生を途切れさせないために、先読みバッファ(再生キュー)を備え、次の音声もあらかじめ生成・用意しておきます。
  • 並列/非同期処理:テキスト生成・音声合成・音声再生は独立した非同期タスクやスレッドで処理し、ブロックを避けましょう。

以下は実装例のJSコード(バックエンド認証トークンが必要)

const SpeechSDK = window.SpeechSDK;
let synthesizer = null;
let ttsQueue = [];
let isSpeaking = false;
let sentenceBuffer = ""; // 集約バッファ

let messageDiv = document.getElementById("message");
let chatMessageTxt = document.getElementById("chatMessage");
let startBtn = document.getElementById("btnStart");
// 句読点で文の終わりを判定(用途に応じて拡張可)
const SENTENCE_END_RE = /[\u3002\uFF1F\uFF01\.?!;;\n\r]/;

startBtn.onclick = async function () {
    synthesizer = null;
    ttsQueue = [];
    isSpeaking = false;

    sentenceBuffer = "";
    messageDiv.innerText = "";
    this.disabled = true;

    console.log("ストリーミング読み上げを開始...");
    // トークンとregion取得
    const resp = await fetch("/token");
    const { token, region } = await resp.json();
    // Speech SDK初期化(トークン方式)
    const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(token, region);
    speechConfig.speechSynthesisVoiceName = "zh-CN-XiaoxiaoNeural";
    const audioConfig = SpeechSDK.AudioConfig.fromDefaultSpeakerOutput();
    synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, audioConfig);

    console.log("SSEテキストストリーム待機中...");
    // SSEでテキストストリーム監視
    const evtSource = new EventSource("/chat?historyID=1234567890&message=" + chatMessageTxt.value);
    evtSource.onmessage = (event) => {
        const text = event.data;
        messageDiv.innerText += text;
        console.log(`フラグメント受信: ${text}`);
        onFragmentReceived(text);
    };
    evtSource.onerror = (e) => {
        console.error("SSE接続が切断されました", e);
        startBtn.disabled = false;
        evtSource.close();
    };
};

// フラグメントを1文ごとにまとめてキューへ
function onFragmentReceived(text) {
    sentenceBuffer += text;
    // 1度に複数文くる場合もあるのでループで処理
    while (true) {
        const match = SENTENCE_END_RE.exec(sentenceBuffer);
        if (match) {
            // 1文(終端記号含む)を抽出
            const idx = match.index + match[0].length;
            const sentence = sentenceBuffer.slice(0, idx);
            enqueueText(sentence.trim());
            // バッファの残りを次に引き継ぐ
            sentenceBuffer = sentenceBuffer.slice(idx);
        } else {
            break;
        }
    }
}

// キュー制御
function enqueueText(text) {
    if (!text) return;
    ttsQueue.push(text);
    playNext();
}

function playNext() {
    if (isSpeaking) return;
    if (ttsQueue.length === 0) return;

    const text = ttsQueue.shift();
    isSpeaking = true;
    console.log(`読み上げ開始: ${text}`);
    synthesizer.speakTextAsync(
        text,
        (result) => {
            isSpeaking = false;
            if (result.reason === SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
                console.log(`読み上げ完了: ${text}`);
            } else {
                console.error(`読み上げ失敗 Reason: ${result.reason}`);
            }
            playNext();
        },
        (error) => {
            isSpeaking = false;
            console.error("合成エラー: ", error);
            playNext();
        }
    );
}

window.addEventListener("beforeunload", () => {
    if (synthesizer) synthesizer.close();
});

(Translated and optimized by GPT)

元のリンク:https://mp.weixin.qq.com/s/XpJ9JbppE_buGptS41mcbw?token=1840160119&lang=zh_CN&wt.mc_id=MVP_325642

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?