我们常用的 Agent 都有语音的功能,比如你用豆包的时候:
正在进行语音输入:
还能切换音色:
这种语音输入会转成文字,大模型的回答会通过语音朗读,可以切换音色,基本是常用 Agent 的必备功能。
要实现这类语音交互,有两个必不可少的环节,一个是语音转文字(ASR),另一个是文字转语音(TTS)。
ASR (Automatic Speech Recognition)语音识别,有些地方也叫 STT (Speech To Text),两个是指同一样东西,只是叫法不一样。TTS(Text To Speech)——文字转语音。
本篇文章我们一起来实现豆包同款的语音交互功能。
我们这里用阿里云的语音服务,其实各家用法都差不多。
文字转语音(TTS)实现
非实时语音合成
非实时语音合成不符合这里的要求,因为我们用豆包的时候可以发现,语音朗读的时候,语音在文字还没有等文字完成输出完整,就开始朗读了,是边输出文字,边朗读的。
而非实时语音合成需要等待大模型的回答输出完整,即文字完全输出完,语音才开始播放。但非实时语音合成作为后续学习实时语音合成的热身项目,也是不错的。
创建项目
mkdir tts-normal
cd tts-normal
npm init -y
我们用阿里云的千问系列的语音合成大模型 qwen3-tts-flash ,其实各家云服务厂商的大模型用法都类似。
我们在阿里云百炼的 API Key 管理页面获取 API Key 后就可以调用千问系列的大模型了。
安装用到的包:
pnpm install dotenv
创建 .env 配置文件:
DASHSCOPE_API_KEY=你的 API Key
因为非流式模式下,千问系列大模型响应中包含 url 字段,指向合成的音频文件。可以使用 fetch 请求该 url 获取合成的音频。
创建 tts-normal.mjs
import fs from "fs";
import dotenv from "dotenv";
dotenv.config(); // 从 .env 加载环境变量
const API_KEY = process.env.DASHSCOPE_API_KEY;
const OUTPUT_FILE = "output.mp3"; // 本地保存路径
// 配置区
const config = {
model: "qwen3-tts-flash",
// 音色
voice: "Serena",
text: "那我来给大家推荐一款T恤, 这款呢真的是超级好看, 这个颜色呢很显气质, 而且呢也是搭配的绝佳单品, 大家可以闭眼入, 真的是非常好看, 对身材的包容性也很好, 不管啥身材的宝宝呢, 穿上去都是很好看的。推荐宝宝们下单哦。",
};
// 核心逻辑
async function generateTTS() {
try {
// 1. 调用多模态生成 API(非实时)
const response = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
input: {
text: config.text,
voice: config.voice,
},
}),
}
);
const data = await response.json();
// 2. 提取音频文件 URL(多模态生成 API 返回 output.audio.url)
const audioUrl = data?.output?.audio?.url;
if (!audioUrl) {
console.error("API 响应:", JSON.stringify(data, null, 2));
throw new Error("API 返回无 audio.url");
}
console.log(`获取音频 URL: ${audioUrl}`);
// 3. 下载音频并保存为本地 MP3
const audioResponse = await fetch(audioUrl);
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
fs.writeFileSync(OUTPUT_FILE, audioBuffer);
console.log(`已保存 MP3 至: ${OUTPUT_FILE}`);
console.log(`音频链接有效期:24 小时(过期需重新调用 API)`);
} catch (error) {
console.error("合成失败:", error.message);
throw error;
}
}
// 执行
generateTTS();
使用 fetch 下载文件时,将服务器返回的是原始二进制数据(MP3 音频的字节流)转为 buffer 写入文件。
音色名称可以从这里找:help.aliyun.com/zh/model-st…
在终端执行 node tts-normal.mjs 命令,即可生成音频文件:
实时语音合成
上面非实时语音合成是直接传人全部文本生成语音的方式,不符合我们的场景。
比如豆包流式返回回答,语音也是流式播放的。
这种就需要实时语音合成接口了,它是 WebSocket 的,通过 WebSocket 协议将文本实时转换为自然语音,支持流式输入与输出。
创建项目
mkdir realtime-tts
cd realtime-tts
npm init -y
安装用到的包:
pnpm install dotenv ws
与非实时语音合成一样,创建 .env 配置文件:
DASHSCOPE_API_KEY=你的 API Key
从环境变量中加载 API Key。
创建 realtime-tts.mjs
import WebSocket from "ws";
import fs from "fs";
import dotenv from "dotenv";
dotenv.config();
// 配置区
const config = {
apiKey: process.env.DASHSCOPE_API_KEY,
// 实时语音合成模型
model: "qwen3-tts-flash-realtime",
voice: "Cherry",
// 待合成文本(模拟流式输入)
textChunks: [
"我不去想是否能够成功",
"既然选择了远方,便只顾风雨兼程",
"我不去想能否赢得爱情",
"既然钟情于玫瑰,就勇敢地吐露真诚",
"我不去想身后会不会袭来寒风冷雨",
"既然目标是地平线,留给世界的只能是背影",
"我不去想未来是平坦还是泥泞",
"只要热爱生命,一切,都在意料之中",
],
// 新加坡地域需替换 WorkspaceId,北京地域使用 wss://dashscope.aliyuncs.com/api-ws/v1/realtime
wsUrl: "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
};
// 1. 建立 WebSocket 连接
const ws = new WebSocket(`${config.wsUrl}?model=${config.model}`, {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
// 2. 处理连接打开事件
ws.on("open", () => {
console.log("WebSocket 连接已建立");
// 发送 session.update 配置会话参数
const sessionUpdate = {
type: "session.update",
session: {
mode: "server_commit", // 服务端智能判断分段与合成时机
voice: config.voice,
response_format: "mp3", // 支持 mp3, pcm, wav 等
},
};
ws.send(JSON.stringify(sessionUpdate));
// 模拟流式发送文本
sendTextStream();
});
// 3. 模拟流式发送文本
function sendTextStream() {
let index = 0;
const interval = setInterval(() => {
if (index < config.textChunks.length) {
const text = config.textChunks[index];
console.log(`发送文本: ${text}`);
// 追加文本到缓冲区
ws.send(
JSON.stringify({
type: "input_text_buffer.append",
text: text,
})
);
index++;
} else {
clearInterval(interval);
// 文本发送完毕,通知服务端结束
ws.send(JSON.stringify({ type: "session.finish" }));
console.log("文本发送完毕,等待最后音频生成...");
}
}, 200); // 每 200ms 发送一段,模拟打字机效果
}
// 4. 处理服务端返回事件
ws.on("message", (data) => {
const response = JSON.parse(data.toString());
switch (response.type) {
case "session.created":
console.log(`会话已创建,Session ID: ${response.session.id}`);
break;
case "response.audio.delta":
// 接收音频流数据(Base64 编码)
const audioBuffer = Buffer.from(response.delta, "base64");
// 实际项目中,这里可以推送到前端播放器或写入文件
fs.appendFileSync("realtime_output.mp3", audioBuffer);
process.stdout.write("."); // 简单的进度提示
break;
case "response.done":
console.log("\n单段音频响应完成");
break;
case "session.finished":
console.log("实时语音合成全部完成!");
ws.close();
break;
default:
// 处理其他事件或错误
if (response.type?.includes("error")) {
console.error("服务端错误:", response);
}
break;
}
});
// 5. 错误与关闭处理
ws.on("error", (err) => console.error("WebSocket 错误:", err.message));
ws.on("close", (code, reason) =>
console.log(`连接已关闭 (Code: ${code}, Reason: ${reason || "Normal"})`)
);
阿里云千问实时语音合成 API 提供两种交互模式(断句策略),分为 server_commit 模式和 commit 模式。
server_commit 模式由服务端智能处理文本分段与合成时机,客户端只需持续追加文本,无需关注分段和提交。
commit 模式由客户端主动提交文本缓冲区以触发合成。
在这里我们用 server_commit 模式。因为在上面代码中文本是分块流式发送的——8 句话每隔 200ms 发一段,客户端不关心也不应该去判断每句话的语义边界在哪。server_commit 让云端模型自己根据标点符号和语义自动判断"这句说完了,可以开始合成了",你只管往里塞文字——更简单,也更自然。
客户端(在这里是 Node.js 代码)与阿里云实时语音合成 Websocket 服务端通过事件进行通信。
事件分为客户端事件和服务端事件。
客户端事件由客户端发起,向服务端发送数据。
服务端事件由服务端发起,向客户端发送数据。
这里用到的客户端事件为 session.update 和 input_text_buffer.append
session.update 事件作用是在语音合成开始前,告诉服务器你希望怎么合成——用什么音色、什么格式、什么断句策略。如果未发送,系统将使用默认配置。
ws.send(
JSON.stringify({
type: "session.update", // 事件类型:更新会话配置
session: {
mode: "server_commit", // 断句策略:让服务端自动判断
voice: "Cherry", // 音色
response_format: "mp3", // 输出格式
},
})
);
input_text_buffer.append 事件作用是将待合成文本追加到文本缓冲区。在 server_commit 模式中,文本将追加到服务端的文本缓冲区;在 commit 模式中,文本将追加到客户端的文本缓冲区。
这里用到的服务端事件为 session.created 、response.audio.delta、response.done 和 session.finished 。
session.created 事件作用是客户端连接到服务端后,服务端发送的第一个事件,该事件返回时会携带服务端对此次连接的默认配置信息。
response.audio.delta 事件会在模型增量生成新的 audio 数据时发送给客户端。可在此事件中获得音频数据。
response.done 事件会在单次响应生成完成时,服务端会发送该事件给客户端。
session.finished 事件会在所有响应生成完成时,服务端会发送该事件给客户端。代表整个会话结束,可以关闭连接。
最终运行效果:
可以看到音频不是一次性完整生成,而是一段一段生成。这就是实时语音合成的效果。
语音转文字(ASR)实现
接下来试一下语音识别 ASR(Automatic Speech Recognition),有的地方也叫 STT(Speech To Text)
这个就不用流式了,我们平时用豆包的时候,都是说完一段话才转成的文本。
创建项目
mkdir asr-offline
cd asr-offline
npm init -y
安装用到的包:
pnpm install dotenv
与上面的语音合成一样,创建 .env 文件,填入大模型的 API key :
DASHSCOPE_API_KEY=你的 API Key
创建 asr-offline.mjs
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
dotenv.config();
const API_KEY = process.env.DASHSCOPE_API_KEY;
const AUDIO_FILE = path.resolve("./realtime_output.mp3");
async function transcribeLocalAudio() {
// 检查本地文件是否存在
if (!fs.existsSync(AUDIO_FILE)) {
console.error(`找不到本地音频文件: ${AUDIO_FILE}`);
return;
}
try {
console.log("正在识别本地音频文件...");
// 1. 读取音频文件并转为 base64
const audioBuffer = fs.readFileSync(AUDIO_FILE);
const audioBase64 = audioBuffer.toString("base64");
// 构造 data URL(MP3 格式)
const audioDataUrl = `data:audio/mp3;base64,${audioBase64}`;
// 2. 调用多模态生成 API,以 messages 格式传入音频 data URL
const response = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "qwen3-asr-flash",
input: {
messages: [
{
role: "user",
content: [
{
audio: audioDataUrl,
},
],
},
],
},
parameters: {
enable_itn: true, // 开启 ITN,将中文数字(如"一百")转为阿拉伯数字(100)
},
}),
}
);
if (!response.ok) {
console.error(`HTTP 错误: ${response.status} ${response.statusText}`);
const errorBody = await response.text();
console.error("响应内容:", errorBody);
return;
}
const data = await response.json();
// 3. 提取识别结果
// 响应结构: output.choices[0].message.content[0].text
const text = data?.output?.choices?.[0]?.message?.content?.[0]?.text;
if (text) {
console.log("\n========== 识别结果 ==========");
console.log(text);
console.log("==============================\n");
} else {
console.error(
"未获取到识别结果,完整响应:",
JSON.stringify(data, null, 2)
);
}
} catch (error) {
console.error("语音识别失败:", error.message);
}
}
transcribeLocalAudio();
我们用上一节实时语音合成中生成的音频文件 realtime_output.mp3 文件,作为这个脚本需要识别的音频:
可以看到音频被正确地识别。
综合实现
接下来我们开始实现豆包同款的语音交互,具体的交互流程为:
-
使用浏览器原生
MediaRecorder API录制音频 -
调用语音转文字的接口,将录制的音频转为文本
-
将生成的文字传给大模型的回答接口
-
将大模型回答接口生成的文本传给文字转语音的接口,实现语音播报
服务端的实现
服务端主要实现三个接口,分别为
-
SSE 流式聊天接口
-
语音转文字接口 (ASR)
-
流式语音合成 (TTS) WebSocket 接口
SSE 流式聊天接口实现
注册 SSE 流式聊天接口路由,同时支持 POST 请求和 GET 请求
app.post("/api/chat", handleChatStream); // 支持 POST 请求
app.get("/api/chat", handleChatStream); // 支持 GET 请求
因为浏览器原生的 EventSource 仅支持 GET 请求,所以这里额外支持了 GET 请求,但是由于 GET 请求的限制:
-
只能把参数放到 URL 中
-
URL 长度有限
-
参数会暴露在 URL 中
-
不适合发送复杂 JSON
因此在实际 AI 应用中,我们一般用 POST 请求的聊天接口,使用 fetch + ReadableStream 获取 SSE 流式数据,而不是使用 EventSource 。
handleChatStream 流式聊天接口的具体实现:
import { ChatOpenAI } from "@langchain/openai";
async function handleChatStream(req, res) {
// 第1步:获取用户消息
const message =
req.query.message || // GET: /api/chat?message=你好
(req.body && req.body.message) || // POST: body里取
"你好"; // 兜底默认值
// 第2步:设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream"); // 告诉浏览器这是SSE流
res.setHeader("Cache-Control", "no-cache"); // 禁用缓存
res.setHeader("Connection", "keep-alive"); // 保持长连接
res.setHeader("X-Accel-Buffering", "no"); // 禁用nginx缓冲
res.flushHeaders(); // 立即发送响应头,不等body
// 第3步:创建大模型实例
const model = new ChatOpenAI({
model: process.env.MODEL_NAME || "qwen-plus",
apiKey: process.env.DASHSCOPE_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
streaming: true, // 关键:启用流式模式
temperature: 0.7, // 控制创造性(0=严谨 1=天马行空)
});
// 第4步:流式生成并逐块推送
try {
const stream = await model.stream(message);
for await (const chunk of stream) {
// chunk 可能是一个字、几个字、或一个词
const content = chunk.content;
if (content) {
// 拼成 SSE 格式: data: {"content":"你"}\n\n
const sseData = `data: ${JSON.stringify({ content })}\n\n`;
// 用 Buffer.from 确保 UTF-8 编码,避免中文乱码
res.write(Buffer.from(sseData, "utf-8"));
}
}
// 第5步:发送结束标记
res.write(Buffer.from("data: [DONE]\n\n", "utf-8"));
res.end(); // 关闭连接
} catch (error) {
// 错误处理
// 情况1:响应头还没发 → 返回普通 JSON 错误
if (!res.headersSent) {
return res.status(500).json({ error: error.message });
}
// 情况2:已经开始流式输出 → 用 SSE 格式发送错误
res.write(
Buffer.from(
`data: ${JSON.stringify({ error: error.message })}\n\n`,
"utf-8"
)
);
res.write(Buffer.from("data: [DONE]\n\n", "utf-8"));
res.end();
}
}
语音转文字接口(ASR)实现
语音转文字的接口实现较为简单,具体流程为接收前端发来的 base64 音频,转发给阿里云语音识别服务,识别出文字,最后将识别出的文字返回给前端。
Express 默认的 JSON body 大小限制只有 100KB,而一段 5 秒录音的 base64 编码就能达到 200KB+ ,因此需要将 Express 默认的 JSON body 大小限制调整为 50MB :
app.use(express.json({ limit: "50mb" }));
50MB 足够录制约 2 小时的音频,对于对话场景绰绰有余。
语音转文字接口具体实现:
app.post("/api/asr", async (req, res) => {
try {
// 1. 参数校验
const { audio } = req.body;
if (!audio) {
return res.status(400).json({ error: "缺少 audio 字段" });
}
console.log("正在调用语音识别...");
// 2. 调用阿里云 ASR API
const response = await fetch(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DASHSCOPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "qwen3-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ audio }],
},
],
},
parameters: {
enable_itn: true,
},
}),
}
);
const data = await response.json();
// 3. 错误处理
if (!response.ok) {
console.error("ASR API 错误:", data);
return res
.status(response.status)
.json({ error: data.message || "语音识别失败" });
}
// 4. 提取结果
const text = data?.output?.choices?.[0]?.message?.content?.[0]?.text;
if (text) {
console.log("识别结果:", text);
res.json({ text });
} else {
console.error("未获取到识别结果:", JSON.stringify(data));
res.status(500).json({ error: "未获取到识别结果" });
}
} catch (error) {
// 5. 兜底异常
console.error("ASR 接口错误:", error.message);
res.status(500).json({ error: error.message });
}
});
enable_itn 参数:
enable_itn: true
ITN = Inverse Text Normalization(逆文本正则化),将识别结果中的中文数字转为阿拉伯数字:
原始: "价格是一百二十三块五毛"
ITN: "价格是123.5元"
这是阿里云 ASR 特有的参数,不是标准语音识别都有的。
流式语音合成 (TTS) WebSocket 接口实现
这个是服务端中最有技术含量的接口。
Node.js 服务端作为中转,将浏览器发送来的文本转发给阿里云实时语音合成服务,再将合成后的音频转发给浏览器播放。全程用 WebSocket ,数据边走边传,不需要等。
这里用到了 ws npm 包,它是 Node.js 社区最流行的 WebSocket 实现,提供服务端和客户端两套 API。
import { WebSocketServer, WebSocket } from "ws";
-
WebSocketServer作为服务端接受浏览器连接 -
WebSocket作为客户端连接 DashScope(阿里云语音合成服务)
// 创建 HTTP 服务器(Express + WebSocket 共用)
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/ws/tts" });
new WebSocketServer 创建一个 WebSocket 服务端实例,其核心作用是让 Node.js 服务器能够接受和处理浏览器的 WebSocket 连接。将一台普通 HTTP 服务器升级为 HTTP + WebSocket 双协议服务器。
server 选项用于将 WebSocketServer 挂载到已有的 HTTP 服务器上,让它与 HTTP 服务共享同一个端口,无需单独开启端口。
path 指定 websocket 接口路径为 /ws/tts
当浏览器发起 WebSocket 连接时,会触发 connection 事件 。在 connection 事件中,我们可以获取到浏览器的 WebSocket 连接实例,并开始处理数据。
一方面接收浏览器发来的消息,另一方面创建 WebSocket 客户端与阿里云实时语音合成服务建立连接,将接收浏览器发来的文字消息发给阿里云实时语音合成服务器,得到合成后的音频数据后,使用浏览器的 WebSocket 连接实例将音频数据发给浏览器。
流式语音合成 (TTS) WebSocket 接口具体实现:
/**
* 流式语音合成 (TTS) WebSocket 接口
* 路径: ws://localhost:3000/ws/tts
*
* 浏览器 → 服务器:
* { type: "text", data: "要合成的内容" } 追加文本
* { type: "finish" } 文本结束,等待最后音频
*
* 服务器 → 浏览器:
* { type: "audio", data: "base64 mp3..." } 音频块
* { type: "done" } 合成完成
* { type: "error", message: "..." } 错误
*/
wss.on("connection", (browserWs) => {
console.log("TTS WebSocket 客户端已连接");
// 到阿里云的 WebSocket 连接引用
let dashScopeWs = null;
// 标记浏览器是否已发出过 finish
let isFinished = false;
// 标记 DashScope session 是否创建完成
let sessionReady = false;
// 缓存 session 就绪前的消息
let pendingMessages = [];
const ttsUrl = `wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-tts-flash-realtime`;
// 实时语音合成参考:https://help.aliyun.com/zh/model-studio/interactive-process-of-qwen-tts-realtime-synthesis?spm=a2c4g.11186623.help-menu-2400256.d_2_5_1_1_0.158c29ee8q62gF&scm=20140722.H_2963385._.OR_help-T_cn~zh-V_1
// 连接到阿里云百炼实时 TTS
dashScopeWs = new WebSocket(ttsUrl, {
headers: {
Authorization: `Bearer ${process.env.DASHSCOPE_API_KEY}`,
},
});
dashScopeWs.on("open", () => {
console.log("已连接 DashScope TTS");
// 配置 session:server_commit 模式让服务端自动判断断句与合成时机
// 通过发送 session.update 事件设置音色、格式、模式等参数。
dashScopeWs.send(
JSON.stringify({
type: "session.update",
session: {
mode: "server_commit",
voice: "Cherry",
response_format: "mp3",
},
})
);
});
// 接收阿里云实时语音合成服务发来的消息
dashScopeWs.on("message", (data) => {
const msg = JSON.parse(data.toString());
console.log(`DashScope: ${msg.type}`);
switch (msg.type) {
case "session.created":
console.log(`TTS 会话已创建: ${msg.session?.id}`);
// Session 就绪,发送缓存的消息
sessionReady = true;
if (pendingMessages.length > 0) {
console.log(`发送 ${pendingMessages.length} 条缓存消息`);
for (const pending of pendingMessages) {
dashScopeWs.send(JSON.stringify(pending));
}
pendingMessages = [];
}
break;
case "response.audio.delta":
// 将音频块转发给浏览器
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(JSON.stringify({ type: "audio", data: msg.delta }));
}
break;
case "response.done":
// 单段音频完成
break;
case "session.finished":
console.log("TTS 合成全部完成");
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(JSON.stringify({ type: "done" }));
browserWs.close();
}
break;
default:
if (msg.type?.includes("error")) {
console.error("TTS 错误:", msg);
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(
JSON.stringify({
type: "error",
message: msg.message || "TTS 合成错误",
})
);
}
}
break;
}
});
dashScopeWs.on("error", (err) => {
console.error("DashScope WebSocket 错误:", err.message);
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(JSON.stringify({ type: "error", message: err.message }));
}
});
dashScopeWs.on("close", () => {
console.log("DashScope TTS 连接已关闭");
});
// 接收浏览器发来的消息
browserWs.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
console.log(`收到浏览器消息: type=${msg.type}`);
if (msg.type === "text" && msg.data) {
// 缓存消息,等 DashScope session 就绪后再发送
const ttsMsg = {
type: "input_text_buffer.append",
text: msg.data,
};
if (sessionReady && dashScopeWs?.readyState === WebSocket.OPEN) {
console.log(
`DashScope: input_text_buffer.append (${msg.data.length} chars)`
);
// 将浏览器发来的文本消息转发给阿里云实时语音合成服务,将文本转语音
dashScopeWs.send(JSON.stringify(ttsMsg));
} else {
// 缓存消息,待阿里云实时语音合成服务 session 就绪后再发送
pendingMessages.push(ttsMsg);
console.log(
`缓存消息 (sessionReady=${sessionReady}, wsState=${dashScopeWs?.readyState})`
);
}
} else if (msg.type === "finish") {
isFinished = true;
const finishMsg = { type: "session.finish" };
if (sessionReady && dashScopeWs?.readyState === WebSocket.OPEN) {
console.log(`DashScope: session.finish`);
// 发送 session.finish 事件通知阿里云实时语音合成服务不再有文本输入,
// 阿里云实时语音合成服务将剩余音频返回,随后关闭连接。
dashScopeWs.send(JSON.stringify(finishMsg));
} else {
pendingMessages.push(finishMsg);
console.log(
`缓存 finish (sessionReady=${sessionReady}, wsState=${dashScopeWs?.readyState})`
);
}
}
} catch (err) {
console.error("解析浏览器消息失败:", err.message);
}
});
browserWs.on("close", () => {
console.log("TTS 客户端断开");
// 清理 DashScope 连接
if (dashScopeWs?.readyState === WebSocket.OPEN) {
if (!isFinished) {
dashScopeWs.send(JSON.stringify({ type: "session.finish" }));
}
dashScopeWs.close();
}
});
browserWs.on("error", (err) => {
console.error("浏览器 WebSocket 错误:", err.message);
});
});
前端的实现
使用 MediaRecorder 录制音频。
调用语音转文字接口,将录音转文字
再将文字传入大模型流式回答接口
将大模型的回答传入文字转语音接口,实现语音播报
麦克风录音模块
这是整个应用用户交互最密集的部分。涉及三个事件:按下开始、松开停止、鼠标离开取消。
事件绑定
micBtn.addEventListener("mousedown", startRecording);
micBtn.addEventListener("touchstart", (e) => {
e.preventDefault();
startRecording();
});
micBtn.addEventListener("mouseup", stopRecording);
micBtn.addEventListener("touchend", (e) => {
e.preventDefault();
stopRecording();
});
micBtn.addEventListener("mouseleave", () => {
if (isRecording) stopRecording();
});
同时支持 PC 鼠标和移动端触屏。mouseleave 处理用户按住后拖出按钮的情况——拖出也停止录音,防止"死锁"。
touchstart 中 e.preventDefault() 阻止移动端的默认行为(如页面缩放、长按菜单)。
开始录音
async function startRecording() {
if (isProcessing || isRecording) return; // 防重入
// 弹出浏览器授权弹窗,请求麦克风权限
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus" // Chrome/Firefox 最优
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm" // 降级
: "audio/mp4"; // Safari 兜底
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
// 当 MediaRecorder 在录音/录像过程中产生一段可用的媒体数据时自动触发
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
// 开始录制
mediaRecorder.start();
isRecording = true;
micBtn.classList.add("recording");
}
停止录音
async function stopRecording() {
if (!mediaRecorder || mediaRecorder.state !== "recording") return;
isRecording = false;
mediaRecorder.stop(); // 触发 ondataavailable 和 onstop
if (stream) {
stream.getTracks().forEach((t) => t.stop()); // 释放麦克风
stream = null;
}
micBtn.classList.remove("recording");
mediaRecorder.onstop = async () => {
if (audioChunks.length === 0) {
setStatus("未录到音频", "");
return;
}
await transcribeAndChat(); // 进入识别和聊天流程
};
}
stream.getTracks().forEach(t => t.stop()) 释放麦克风硬件资源,浏览器标签页上的红色录制指示器会消失。
语音识别 → 自动聊天流程
async function transcribeAndChat() {
isProcessing = true;
micBtn.disabled = true;
sendBtn.disabled = true;
setStatus("🔉 正在识别语音...", "transcribing");
// 1. 合并音频块为 Blob
const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
// 2. Blob → base64 Data URL
const base64 = await blobToBase64(blob);
// 3. 发送到后端 ASR 接口
const asrRes = await fetch("/api/asr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audio: base64 }),
});
const asrData = await asrRes.json();
// 4. 显示识别结果 + 自动发起聊天
const recognizedText = asrData.text;
addMessage("user", recognizedText);
await streamChat(recognizedText); // 进入流式聊天
}
整个流程是线性的异步链条:
录音停止 → Blob → base64 → POST /api/asr → 拿到文字
↓
显示用户气泡 + streamChat()
文本发送
async function sendTextMessage() {
const text = textInput.value.trim();
if (!text || isProcessing) return;
textInput.value = ""; // 清空输入框
textInput.style.height = "auto"; // 重置高度
isProcessing = true;
addMessage("user", text);
await streamChat(text); // ← 同样的聊天入口
isProcessing = false;
textInput.focus();
}
语音识别和文本发送最终都走到同一个 streamChat() 函数。这保证了两种输入方式的行为完全一致。
输入框的自动撑高:
textInput.addEventListener("input", () => {
textInput.style.height = "auto";
textInput.style.height = Math.min(textInput.scrollHeight, 100) + "px";
});
先重置为 auto 获取真实内容高度,再设置但不超过 100px,可以实现 textarea 自适应高度。
流式聊天核心:streamChat()
这是整个文件最核心、代码量最大的函数。它同时做了三件事:流式接收 AI 回复 + 渲染到气泡 + 转发文本给 TTS。
取消旧请求
if (activeAbortController) {
activeAbortController.abort(); // 取消上一个 fetch
activeAbortController = null;
}
const abortController = new AbortController();
activeAbortController = abortController;
用户快速连发多条消息时,前一条还没返回完就被新请求取消。不取消的话,旧请求的数据会继续写到气泡里,和新消息混在一起。
发起聊天请求
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal: abortController.signal, // 绑定取消信号
});
逐块读取 SSE 流
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // 保留不完整的最后一行
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6); // 去掉 "data: " 前缀
// ...
}
}
这个循环的每一步都有讲究:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 读取 | await reader.read() | 等待服务端推来下一块数据,异步挂起 |
| 解码 | decoder.decode(value, { stream: true }) | stream: true 告诉解码器这是流式数据,避免多字节 UTF-8 字符被从中间切断 |
| 分行 | buffer.split("\n") | 按单换行分割,保证每行完整到达就能立即处理 |
| 缓存 | buffer = lines.pop() | 最后一行可能不完整,留到下次拼接 |
三种数据类型的处理
// 1. 结束标记
if (data === "[DONE]") {
cleanup();
// 发送 TTS 尾部缓冲 + 结束信号
return;
}
// 2. 错误信息
const parsed = JSON.parse(data);
if (parsed.error) {
currentAssistantMsg.textContent = `${parsed.error}`;
cleanup();
return;
}
// 3. 正常内容
if (parsed.content) {
fullContent += parsed.content;
currentAssistantMsg.textContent = fullContent; // 完整文本替换
messagesEl.scrollTop = messagesEl.scrollHeight; // 自动滚到底部
// TTS 相关……
}
为什么用 textContent 而不是 innerHTML? 因为要防止 XSS 攻击——如果 AI 回复中包含 HTML 标签,textContent 会原样显示,不会执行。同时保留了 white-space: pre-wrap 让换行正常显示。
为什么每次都用完整文本 fullContent 替换? 而不是 += 追加?因为 AI 回复可能包含 Markdown 或特殊格式,累积到完整字符串再统一渲染更安全。当前实现是纯文本,所以 fullContent += 然后完整赋值和直接 += 效果相同,但前者为未来扩展留了空间。
边生成边朗读——流式聊天与 TTS 的实时联动
if (ttsWs && ttsWs.readyState === WebSocket.OPEN) {
ttsBuffer += parsed.content;
// 遇到句号、问号、感叹号、换行,或缓冲区够长了,就发送
if (
/[。!?!?\n]/.test(parsed.content) ||
ttsBuffer.length >= TTS_CHUNK_SIZE
) {
ttsWs.send(JSON.stringify({ type: "text", data: ttsBuffer }));
ttsBuffer = "";
}
}
断句策略:不等到整段文字结束才发 TTS,而是遇到标点或累积 15 个字就发一次。这样 TTS 能在 AI 还在生成后续文字时就开始朗读前面的句子,做到"边说边想"的效果。
错误处理
} catch (err) {
if (err.name === "AbortError") {
console.log("请求已取消"); // 静默处理
return;
}
// 真正的网络错误
cleanup();
if (!fullContent) currentAssistantMsg.textContent = "连接失败";
}
区分 AbortError 和真实网络错误是必须的。AbortError 是我们主动调用 abort() 触发的,不代表出了问题——用户发新消息取消旧请求是正常行为,不能显示"连接失败"。
TTS WebSocket 客户端
实例化 WebSocket 客户端,接收音频数据
function connectTTS(audioPlayer, assistantMsg) {
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws/tts`;
const ws = new WebSocket(wsUrl);
协议自适应:根据当前页面是 HTTP 还是 HTTPS 自动选择 ws:// 或 wss://。
接收消息的三种类型
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "audio" && msg.data) {
audioPlayer.enqueue(msg.data); // 音频块入队播放
} else if (msg.type === "done") {
audioPlayer.onAllDone = () => {
setStatus("", "");
assistantMsg.classList.remove("speaking");
};
audioPlayer.waitForFinish(); // 等队列播完
ws.close();
} else if (msg.type === "error") {
setStatus("", "");
ws.close();
}
};
msg.data 是 base64 编码的 MP3 数据。直接传给 AudioPlayer,解码和播放由 AudioPlayer 负责。
AudioPlayer:基于 AudioContext 的流式音频播放器
class AudioPlayer {
constructor() {
this.ctx = null; // AudioContext 实例
this.queue = []; // 待播放的音频队列
this.nextStartTime = 0; // 下一段的开始时间
this.scheduledCount = 0; // 已调度的段数
this.finishedCount = 0; // 已播完的段数
}
为什么用 AudioContext 而不是 <audio> 标签?
<audio> 标签 | AudioContext | |
|---|---|---|
| 多段连续播放 | 需要监听 ended 事件手动切换,有间隙 | source.start(time) 精确调度,无缝衔接 |
| 解码 | 浏览器自动 | 手动 decodeAudioData |
| 灵活性 | 低 | 高(可加音效、调节音量等) |
核心方法:enqueue + _playNext
enqueue(base64Data) {
this._ensureContext(); // 首次使用时创建 AudioContext
this.queue.push(base64Data);
if (!this.isPlaying_) {
this.isPlaying_ = true;
this._playNext(); // 如果没在播,立即开始
}
}
async _playNext() {
const base64Data = this.queue.shift();
// base64 → Uint8Array → AudioBuffer
const binaryStr = atob(base64Data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
const audioBuffer = await this.ctx.decodeAudioData(bytes.buffer);
// 创建音频源并调度播放
const source = this.ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.ctx.destination);
source.start(this.nextStartTime); // 关键:指定开始时间
this.nextStartTime += audioBuffer.duration;
}
source.start(this.nextStartTime) 是无缝衔接的关键。AudioContext 有自己的内部时钟,start(time) 告诉它在未来的某个精确时刻开始播放。即使下一段音频在上一段结束前还没解码完,只要 nextStartTime 计算正确,就不会有间隙。
完成检测
source.onended = () => {
this.finishedCount++;
this._checkAllDone(); // 所有段都播完了?
};
_checkAllDone() {
if (this.queue.length === 0
&& this.finishedCount >= this.scheduledCount
&& this.onAllDone) {
this.onAllDone(); // 回调:重置状态栏,移除 speaking 样式
}
}
因为音频是异步解码、异步播放的,判断"全部播完"需要同时满足三个条件:队列为空、已调度数等于已播完数、回调已注册。
重要辅助函数
blobToBase64 函数:
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
readAsDataURL 一步完成二进制读取和 base64 编码,结果格式为 "data:audio/webm;base64,AAAA..."。这正是后端 /api/asr 接口要求的 audio 字段格式。
效果演示
代码上传了 github: github.com/Panda-plus5…
读者也可以自己尝试实现一下。
总结
本文实现了豆包同款的语音交互。
技术上以 Node.js 服务端作为透明代理,串联前端 MediaRecorder 录音 → 阿里云 ASR 识别 → LangChain SSE 流式对话 → WebSocket 实时 TTS 合成 → AudioContext 无缝播放的完整数据链路,并通过标点分句 + 15 字兜底策略实现了"AI 还在想、语音已开始读"的并行体验。
整个语音交互实现的核心挑战在于如何设计流式数据的转发、缓冲与调度机制,让 ASR、LLM、TTS 三个异步环节像齿轮一样精密咬合。