让Micsoft Speech语音边加载边朗读

在做大语言模型(LLM)开发的时候,我们经常会遇到流式输出的场景。也就是说,LLM不是一次性把所有内容都生成完,而是像打字机一样一段一段往外吐词。这种方式在聊天机器人、语音交互、智能助理等场景下特别常见,响应更快,用户体验也更自然。

但问题来了——当你想让模型生成的文字被朗读出来,比如用微软的 Azure Speech Service(或者别的 TTS 服务)把文字变成声音时,很多 TTS API 是设计来处理整段文本的:你得先把完整的一段话传给它,然后它才开始合成语音并朗读。这就带来了延迟,用户要等一段时间才听到声音,体验打了折扣。

一、LLM 的流式输出是怎么回事

流式输出,英文叫 streaming generation,是指模型边生成内容边把它传给前端。比如你问了一个问题:

“介绍一下太阳系的行星”

LLM 会先输出“太阳系有八大行星”,然后接着输出“包括水星、金星、地球……”等等,一边生成一边发给你,而不是等它一口气写完 500 字才返回。

在技术实现上,这通常是基于 Server-Sent Events (SSE) 或 WebSocket,模型在后台生成一个 token(一个单词或符号),就立刻推给前端,形成连续不断的文字流。

二、Speech 的“整段式”朗读

而语音合成(TTS)这边的逻辑就不太一样。以微软的 Azure Speech 为例,它通常接受完整的一段文字,比如:

“太阳系有八大行星,包括水星、金星、地球、火星、木星、土星、天王星和海王星。”

然后它才开始合成音频,并朗读出来。整个过程是 批处理式的,所以你必须等文字生成完、音频生成完,才能播放。

三、那我们怎么把两者结合起来呢?

目标是这样的:LLM边生成、Speech边朗读,让人感觉像是在“思考着说话”。

解决思路

我们需要搭建一个“中间件”机制:

监听 LLM 的流式输出,比如每接收到一个新 token 或一段文字,就进行处理;

设定一个阈值或句子终止符(比如遇到句号、逗号、段落符),就把这部分送去语音合成;
TTS 使用流式接口(如果有),或者模拟成分段播放;
播放音频流,一边接收一边播,避免卡顿。
如果你用的是 Azure Speech,它其实支持一个叫 “Push audio stream” 的 API 接口。也就是说,我们可以边把文本送进去,它边朗读。虽然这个功能对文本长度和格式有一定要求,但在实验性开发中是可行的。

四、实际开发中要注意的点

分段策略:不要一个 token 就扔给 TTS,会导致频繁中断。可以按“句子结尾”“标点符号”或者“每 n 个词”分批。
TTS 合成时间:即使是流式朗读,TTS 本身也要一点时间生成音频,要做好缓存和排队机制。
音频拼接或缓冲:为了让朗读不中断,最好有一个 buffer(播放队列),提前塞好下一段音频。
多线程处理:文字生成、语音合成和音频播放可以用异步任务或多线程分开跑,防止阻塞。

下面是实现的JS代码,后端需要授权Token
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 starBut=document.getElementById("btnStart");
            // 标点正则,遇到这些符号就认为一句结束(可以根据场景扩展)
            const SENTENCE_END_RE = /[\u3002\uFF1F\uFF01\.?!;;\n\r]/;

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

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

              console.log("开始流式朗读...");
              // 获取 token 和 region
              const resp = await fetch("/token");
              const { token, region } = await resp.json();                
              // 初始化 Speech SDK(用 token方式)
              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);
                starBut.disabled=false;
                evtSource.close();
              };
            };

            // 片段聚合为一句完整话后入队
            function onFragmentReceived(text) {
              sentenceBuffer += text;
              // 可以一次收到多句,所以用while
              while (true) {
                const match = SENTENCE_END_RE.exec(sentenceBuffer);
                if (match) {
                  // 取出一句完整内容(包括结尾标点)
                  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();
            });

声明:来自硅基-桂迹,仅代表创作者观点。链接:https://eyangzhen.com/2060.html

硅基-桂迹的头像硅基-桂迹

相关推荐

关注我们
关注我们
购买服务
购买服务
返回顶部