免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

JS语音合成与WebSocket流式推送:实时语音系统落地实践

JS语音合成与WebSocket流式推送:实时语音系统落地实践 做实时语音系统的时候最绕不开的一环就是把一段文本变成声音然后以流的形式送到接收端。最近我正好整理了一个方案——用 JavaScript 做语音合成把合成出来的音频转成语音流再通过 WebSocket 推送给前端播放。这套链路听起来简单真正落地的时候会发现语音合成接口、音频编码格式、流式传输协议、前端播放缓冲每一个环节都能给你出点幺蛾子。这篇文章我就把完整实现梳理一遍包括方案选型、关键原理、可跑的示例代码以及我踩过的几个坑希望能帮你少走弯路。1. 需求场景与整体方案选型1.1 标题里的三个关键词到底在做什么先把标题拆开看。JS语音合成准确说是用 JavaScript 去调用文本转语音TTS能力。这里的 JS 可以是浏览器里的前端代码也可以是 Node.js 服务端代码。语音流转化指的是合成结果不是一整个文件一次性返回而是变成一个可读、可切分、可持续输出的音频流。通过 WebSocket 推送数据就是要把这个音频流切成小块通过全双工的 WebSocket 通道实时送到客户端播放。这个组合常见的落地场景有在线文字转语音朗读、智能客服语音回复、语音助手实时播报、无障碍辅助阅读甚至物联网设备语音下放。比如用户在网页上点一下“播放”服务器合成语音后通过 WebSocket 把音频分片推送过来浏览器边收边播又或者两个设备之间实时传语音服务端做中间转接。这种实时性要求高的场景HTTP 长连接或者轮询都不太合适WebSocket 几乎是标配。1.2 纯前端合成不是不行但拿不到音频流一开始我想得比较简单浏览器自带speechSynthesisAPI直接speechSynthesis.speak(new SpeechSynthesisUtterance(你好))就能让网页开口说话那是不是直接把这段声音抓下来推给 WebSocket 就行了实测下来发现这条路基本走不通。speechSynthesis的音频输出走的是系统底层不会经过网页的AudioContext所以没有任何官方 API 能让你拿到合成后的二进制音频数据。虽然有各种奇技淫巧去 hook 音频输出但兼容性极差而且不同操作系统、不同浏览器的发音引擎、语速、音色完全不一致做出来的效果没法保证。更麻烦的是它只负责“在当前设备上出声”无法把声音发到远端。所以我的结论是如果你的目标是“拿到语音数据然后通过网络推送”不要试图用前端speechSynthesis。正确的思路是把语音合成放到拥有完整音频输出权限的服务端 Node.js 环境里或者调用云厂商的 TTS 服务让服务端拿到音频流再通过 WebSocket 转发给任意客户端。这样客户端不管是浏览器、App 还是小程序都能收到同样音色的语音数据。1.3 整体流程文本进分片出确定了服务端合成方案后整个链路变得清晰客户端通过 WebSocket 发送合成请求带上文本内容和音色参数。服务端收到请求调用 TTS 引擎得到音频数据。TTS 输出被包装成音频流按照固定的时间长度比如每 100ms切分成小块。服务端通过 WebSocket 二进制帧把分片逐个推给客户端。客户端收到分片后进入播放队列用 Web Audio API 依次播放实现边收边播。这个流程最大的优势是延迟低、内存占用小。TTS 引擎刚合成出一段服务端就马上推给客户端客户端不用等全部音频生成完才开始播。对于长文本朗读体验提升非常明显。2. 核心细节语音合成与音频流转化原理2.1 TTS 引擎如何把文本变成音频TTS 本质上是一个“朗读机器”。以主流的神经网络 TTS 为例引擎收到文本后先做文本规范化处理数字、单位、缩写、标点再分词、预测发音和韵律然后通过声学模型生成声学特征最后由声码器合成出音频波形。最终输出常见格式有 MP3、WAV、PCM、OGG 等。如果是在 Node.js 环境里接入 TTS通常会拿到一个音频流对象。比如很多云厂商的 SDK 提供Stream返回你可以像读文件一样一块一块去读。也有部分 SDK 是 HTTP 接口返回一个可读流 body。这一步的关键是不要把整个音频一次性缓冲到内存里而是保持它的“流”属性。举个生活化的例子TTS 像厨师做菜做好的菜装盘给你是一种方式但如果客人还没到你希望边做边端上去让客人边吃边等那就是“流式上菜”。语音流就是要这种效果所以我们需要的是一个水龙头而不是一瓶装好的水。2.2 音频流“转化”的本质格式、采样率与分片语音流转化听起来神秘实际上就是三件事确认格式、统一采样率、切分数据。先说格式。如果 TTS 返回的是 MP3它本身是压缩格式分成小块之后每个块不一定能独立解码因为 MP3 帧有固定长度但 TCP/WebSocket 并不会帮你按帧切好。如果返回的是 PCM脉冲编码调制裸数据那处理起来就简单多了。PCM 就是纯声音波形采样点没有任何压缩每个采样点固定字节数切多少就播多少。所以做实时语音流推送我最推荐 TTS 输出 PCM 格式或者把其他格式转成 PCM 再推。再说采样率。常见语音 TTS 输出采样率有 16kHz、24kHz、48kHz。16kHz 是电话音质级别人声清晰数据量小适合实时传输48kHz 是 CD 音质但带宽占用高。流式传输一般推荐 16kHz、16bit、单声道一秒的数据量是16000 × 2 × 1 32000字节也就是约 31.25KB/s带宽压力很低。最后是分片。分片大小的选择会影响播放延迟和网络开销。如果每片太小比如 10ms网络包太多吞吐率下降如果每片太大比如 1s接收端缓冲时间太长延迟感人。我常用的分片时长是 60ms 到 200ms。以 100ms、16kHz、16bit、单声道为例每个分片的字节数计算如下分片字节数 采样率 × 通道数 × 字节数/采样点 × 分片秒数 16000 × 1 × 2 × 0.1 3200 字节这样一个分片约 3.2KB非常合适。2.3 为什么用 WebSocket 而不是 HTTP 拉流或轮询其实 HTTP 也支持流式响应Transfer-Encoding: chunked服务端也能把音频流分批吐给客户端。那为什么很多实时语音方案还是用 WebSocket根本原因在于角色模型。HTTP 是“客户端主动请求一次服务端响应一次”即使流式响应连接也是一次性的。如果后续还需要传新的请求参数、取消合成、切换音色就要重新建立连接或者并发开多个连接非常别扭。WebSocket 是全双工实时通道客户端随时可以往服务端发数据服务端也随时可以往客户端推数据天然适合这种持续的语音交互。另外WebSocket 建立一次连接后消息头的开销非常小省去了 HTTP 每次请求的额外头部。对于实时语音这种高频小包传输累积省下来的开销相当可观。3. 实操实现Node.js 服务端 TTS WebSocket 推送3.1 环境准备与依赖安装这一节提供一个可跑的完整示例。环境要求 Node.js 18 以上使用内置fetch和WebSocket客户端如果版本低可以装ws替代。我们先创建项目并安装依赖mkdir voice-ws-demo cd voice-ws-demo npm init -y npm install ws需要说明的是这里我用ws库来搭建 WebSocket 服务端它是目前 Node.js 生态最稳定的方案。如果你使用 TypeScript可以额外安装types/ws。为了让示例不依赖任何云厂商密钥我先用一个“模拟 TTS 输出”的函数生成 PCM 数据帮你完整跑通链路。最后再告诉你真实接入 TTS 时需要替换哪部分。3.2 生成测试音频流模拟 TTS 的 PCM 输出写一个tts.js文件封装一个函数输入文本和时长输出一段 PCM 字节流。这里用一个 440Hz 正弦波模拟语音信号方便测试播放效果。// tts.js const sampleRate 16000; // 16kHz 采样率 const bytesPerSample 2; // 16bit 单声道 function generatePcm(text, durationSec 2) { const totalBytes sampleRate * bytesPerSample * durationSec; const pcmBuffer Buffer.alloc(totalBytes); const sampleCount totalBytes / bytesPerSample; for (let i 0; i sampleCount; i) { // 模拟一个 440Hz 的正弦波幅值取 0.5 避免爆音 const value Math.sin((2 * Math.PI * 440 * i) / sampleRate); pcmBuffer.writeInt16LE(Math.round(value * 16000), i * bytesPerSample); } return pcmBuffer; } module.exports { generatePcm, sampleRate, bytesPerSample };你会发现传入的text其实没被用到。真实场景中这个函数应该变成调用 TTS 引擎根据text返回真实的 PCM 流。比如你接入 Azure、阿里云、讯飞的 TTS SDK它们返回的Readable流基本都能通过fs.createReadStream的姿势去读后面我们的推送逻辑完全不需要变。3.3 搭建 WebSocket 服务端按分片推送二进制数据创建server.js核心逻辑是监听客户端消息收到合成请求后生成 PCM然后按固定字节数切成多个 chunk再逐个通过 WebSocket 二进制帧发送。// server.js const WebSocket require(ws); const { generatePcm, sampleRate } require(./tts); const wss new WebSocket.Server({ port: 8080 }); const chunkDurationMs 100; const bytesPerSample 2; const chunkBytes Math.floor(sampleRate * bytesPerSample * (chunkDurationMs / 1000)); wss.on(connection, (ws) { console.log(client connected); ws.on(message, (message) { try { const data JSON.parse(message.toString()); if (data.type synthesize) { const text data.text || ; const pcm generatePcm(text); // 先发送元数据消息格式、采样率等 ws.send(JSON.stringify({ type: start, format: pcm, sampleRate, channelCount: 1, bitsPerSample: 16 })); // 按分片大小逐段发送 for (let offset 0; offset pcm.length; offset chunkBytes) { const chunk pcm.subarray(offset, offset chunkBytes); ws.send(chunk); } // 发送结束标记 ws.send(JSON.stringify({ type: end })); } } catch (err) { console.error(err); ws.send(JSON.stringify({ type: error, message: err.message })); } }); ws.on(close, () { console.log(client disconnected); }); }); console.log(WebSocket server listening on ws://localhost:8080);这里有个小细节ws.send传入Buffer时会以二进制帧发送传入普通字符串时会以文本帧发送。前端可以用event.data instanceof Blob或者typeof event.data string区分。如果你用的是浏览器原生WebSocket二进制数据会以Blob形式收到设置ws.binaryType arraybuffer可以直接拿到ArrayBuffer方便后续处理。3.4 前端接收音频分片并实时播放接下来写一个简单的前端页面index.html展示完整的接收和播放逻辑。重点是用 Web Audio API 的AudioContext实现 PCM 的流式播放而不是把所有分片攒完再播。!DOCTYPE html html head meta charsetutf-8 titleWebSocket 语音流播放/title /head body button idplayBtn合成并播放/button script const ws new WebSocket(ws://localhost:8080); ws.binaryType arraybuffer; let audioContext null; let nextTime 0; let sampleRate 16000; function schedulePcmChunk(arrayBuffer) { const int16 new Int16Array(arrayBuffer); const float32 new Float32Array(int16.length); const volume 32768; for (let i 0; i int16.length; i) { float32[i] int16[i] / volume; } const audioBuffer audioContext.createBuffer(1, float32.length, sampleRate); audioBuffer.copyToChannel(float32, 0); const source audioContext.createBufferSource(); source.buffer audioBuffer; source.connect(audioContext.destination); // 保证多个分片按顺序无缝衔接 if (nextTime audioContext.currentTime) { nextTime audioContext.currentTime; } source.start(nextTime); nextTime audioBuffer.duration; } ws.onopen () { console.log(connected); }; ws.onmessage async (event) { if (typeof event.data string) { const msg JSON.parse(event.data); if (msg.type start) { if (!audioContext) { audioContext new (window.AudioContext || window.webkitAudioContext)(); } sampleRate msg.sampleRate; nextTime audioContext.currentTime 0.05; // 预留50ms缓冲 console.log(start, msg); } if (msg.type end) { console.log(全部数据已接收继续播完队列); } } else { const arrayBuffer event.data; schedulePcmChunk(arrayBuffer); } }; document.getElementById(playBtn).addEventListener(click, () { ws.send(JSON.stringify({ type: synthesize, text: 你好欢迎使用实时语音合成 })); }); /script /body /html这段代码的核心是schedulePcmChunk。每次收到一块 PCM 数据就把它转成Float32Array填入AudioBuffer然后通过BufferSource排进播放时间线。nextTime起到了“排队器”的作用保证分片之间的播放不重叠、不产生间隙。这是实现流式播放的关键技巧。3.5 真实 TTS 接入把模拟函数替换成实际引擎上面示例里的generatePcm只是测试占位符。真正接入 TTS 时你可以把这个函数改成任何能返回 PCM 流的方式。这里给出一个通用的伪代码思路// 伪代码示意如何接入真实TTS const { Readable } require(stream); async function createTtsPcmStream(text, options) { // 假设某TTS SDK有一个 createStream 方法返回可读流 const response await fetch(https://tts.example.com/api, { method: POST, body: JSON.stringify({ text, voice: female, format: pcm }) }); // 如果返回的格式是 MP3/OGG需要先转成 PCM // 这一步可以交给 ffmpeg 或对应 SDK 的转码能力 return response.body; // Web ReadableStream }拿到可读流之后推送逻辑就从“一次性生成整个 Buffer”改成“一边读一边发”。比如在server.js里把generatePcm(text)换成const stream await createTtsPcmStream(text)然后用stream.on(data, chunk ...)的方式去发送。如果你用的是 Node.js 原生Readable可以直接for await (const chunk of stream)。比较常见的坑是 TTS 返回的是 MP3你不能直接把 MP3 分片当前 PCM 去播放必须转码。有些云服务可以指定输出格式比如 Azure Speech 支持Riff16Khz16BitMonoPcm如果只能在服务端拿到 MP3就先用ffmpeg转成 PCM 再推。转换命令很简单ffmpeg -i input.mp3 -f s16le -ar 16000 -ac 1 output.pcm或者在 Node.js 里用fluent-ffmpeg边转边推也能实现流式。4. 常见问题与排查技巧实录4.1 WebSocket 连接数多了怎么办做服务端推送时最常遇到的是连接管理问题。如果服务端不处理心跳和超时TCP 连接可能被防火墙或代理悄悄断开服务端却不知道。所以一定要在底层加心跳机制。我一般这样处理每隔 30s 向客户端发送一个ping消息客户端收到后回pong。如果服务端连续 3 次没有收到客户端的pong就主动关闭这个连接。客户端在页面隐藏或主动关闭时调用ws.close()并且服务端监听close事件清理资源。此外不要在一个连接里同时推送多路音频流。如果客户端需要并行合成多段语音建议为每个任务分配独立的“流ID”在消息里带上避免前端数据串台。4.2 播放卡顿、爆音、断续卡顿大概率是接收端缓冲策略不对。如果每个分片到了就立刻播放网络稍微抖动音频就会断一下。解决方案是引入“提前量”在收到start消息后不要立即播放而是预留 200ms~500ms 的缓冲再开始。相当于先攒几个分片再启动播放。这样网络抖动可以被缓冲吸收。爆音的常见原因是 PCM 转Float32Array时计算错误。注意16bit PCM 的取值范围是 -32768 到 32767转成浮点应该除以32768即0x8000不是32767。有的代码里除以0x7FFF虽然差别很小但完美强迫症会明显听到底噪。可以把音量统一除以32768稳妥。断续还有一个隐蔽原因采样率不匹配。服务端声明的sampleRate是 16000但实际发送的数据却是 24000 采样率生成的前端createBuffer时按 16000 处理就会导致速度快慢错乱。排查时先确认服务端实际输出的 PCM 采样率再跟前端设置的sampleRate对齐。4.3 WebSocket 二进制数据和 JSON 消息混在一起怎么处理当数据帧类型不固定时要严格区分。我的约定是所有控制消息start/end/error都用 JSON 字符串所有音频数据都用二进制帧。前端判断ws.onmessage (event) { if (typeof event.data string) { // 控制消息 } else { // 音频数据 } };注意浏览器收到二进制时默认是Blob。如果你设置ws.binaryType arraybuffer那么event.data就是ArrayBuffer可以直接传给Int16Array使用。如果你不设置需要先await event.data.arrayBuffer()。推荐一开始就设置好减少类型转换的坑。4.4 到底是推整段 MP3 文件还是推 PCM 更合适这个争议很大。如果你的场景是“服务端合成完一整段音频发过去”那直接推 MP3 文件更简单前端用audio播放即可。但如果你的目标是“实时合成、边合成边播”建议直接上 PCM。MP3 本身按帧编码但流式切分到任意字节前端没法逐片解码。除非你用 MediaSource ExtensionsMSE去喂 MP3 分片这个方案成熟度不如 Web Audio 播放 PCM。PCM 的优势是解码简单、跨浏览器兼容性好只要控制好采样率和数据量实时性远胜 MP3。4.5 问题排查速查表现象可能原因排查/解决办法客户端收不到任何消息服务端没启动或者连接没建立先看ws.onopen是否触发用 Postman 或 wscat 测试 WebSocket 服务收到数据但没声音PCM 转 Float32 时字节序错误确认服务端是小端writeInt16LE前端用Int16Array读不要用DataView乱读声音太快/太慢采样率不匹配核对服务端输出采样率和前端createBuffer的采样率声音断断续续网络抖动或缓冲不足增加预缓冲时间把nextTime的预留从 50ms 调到 300ms内存飙高chunk 没有及时释放确认前端audioBuffer在播放结束后可被 GC服务端不要一次性攒整个大文件多个任务数据串流没有区分流ID加一个streamId字段控制消息和数据帧都带上5. 一些个人经验和扩展建议最后分享几个我在实际项目里摸索出来的小技巧。第一个技巧是如果只是做 demo完全可以用文件流代替真实 TTS。先用任何工具生成一个 PCM 文件服务端用fs.createReadStream按chunkBytes读取并推送前端逻辑一行不用改。等调试完整个链路再去接真正的 TTS SDK。这样能把“传输与播放”的问题和“TTS 服务”的问题隔离开排查起来效率高很多。第二个技巧是分片大小不要拍脑袋定。可以写一个小的压测脚本分别用 20ms、50ms、100ms、200ms 的分片推 10 秒音频统计前端播放的卡顿率和内存占用。我实测下来 100ms 是延迟和稳定性的平衡点。如果对延迟要求极高比如对讲可以降到 60ms但要做好网络抖动补偿。第三个技巧是强烈建议给 WebSocket 消息加上序号。比如每个二进制分片之前先发一条{type:chunk,seq:1,size:3200}的元消息这样前端可以做丢包检测。WebSocket 底层是 TCP消息本身不会丢但你的业务层如果做了多路复用或者中间有其他代理加序号会让你排查问题时有据可查。这套方案我已经在两个项目里落地过一个是网页端的长文朗读另一个是内部工具的实时语音提醒。每次接入不同的 TTS 服务商只要把输出统一转成 16kHz、16bit、单声道 PCM前端播放模块就不用改任何代码。你可以把 P段转成一个通用工具函数后面不管接哪家 TTS 都能复用。希望这篇内容能帮你把“文本到发音”这条路走通。如果你在实际调试中碰到其他问题欢迎顺着这个思路去定位大概率能省下不少时间。
返回列表