给 AI Agent 加上语音交互:ASR + 流式 TTS 实战

0 阅读16分钟

我们常用的 Agent 都有语音的功能,比如你用豆包的时候:

pic1.png

正在进行语音输入:

pic2.png

还能切换音色:

pic3.png

这种语音输入会转成文字,大模型的回答会通过语音朗读,可以切换音色,基本是常用 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…

pic5.png

在终端执行 node tts-normal.mjs 命令,即可生成音频文件:

pic4.png

实时语音合成

上面非实时语音合成是直接传人全部文本生成语音的方式,不符合我们的场景。

比如豆包流式返回回答,语音也是流式播放的。

这种就需要实时语音合成接口了,它是 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.updateinput_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.createdresponse.audio.deltaresponse.donesession.finished

session.created 事件作用是客户端连接到服务端后,服务端发送的第一个事件,该事件返回时会携带服务端对此次连接的默认配置信息。

response.audio.delta 事件会在模型增量生成新的 audio 数据时发送给客户端。可在此事件中获得音频数据。

response.done 事件会在单次响应生成完成时,服务端会发送该事件给客户端。

session.finished 事件会在所有响应生成完成时,服务端会发送该事件给客户端。代表整个会话结束,可以关闭连接。

最终运行效果:

g1.gif

可以看到音频不是一次性完整生成,而是一段一段生成。这就是实时语音合成的效果。

语音转文字(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 文件,作为这个脚本需要识别的音频:

pic6.png

可以看到音频被正确地识别。

综合实现

接下来我们开始实现豆包同款的语音交互,具体的交互流程为:

  1. 使用浏览器原生 MediaRecorder API 录制音频

  2. 调用语音转文字的接口,将录制的音频转为文本

  3. 将生成的文字传给大模型的回答接口

  4. 将大模型回答接口生成的文本传给文字转语音的接口,实现语音播报

服务端的实现

服务端主要实现三个接口,分别为

  • 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 ,数据边走边传,不需要等。

pic7.png

这里用到了 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 处理用户按住后拖出按钮的情况——拖出也停止录音,防止"死锁"。

touchstarte.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.database64 编码的 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 字段格式。

效果演示

g2.gif

代码上传了 github: github.com/Panda-plus5…

读者也可以自己尝试实现一下。

总结

本文实现了豆包同款的语音交互。

技术上以 Node.js 服务端作为透明代理,串联前端 MediaRecorder 录音 → 阿里云 ASR 识别 → LangChain SSE 流式对话 → WebSocket 实时 TTS 合成 → AudioContext 无缝播放的完整数据链路,并通过标点分句 + 15 字兜底策略实现了"AI 还在想、语音已开始读"的并行体验。

整个语音交互实现的核心挑战在于如何设计流式数据的转发、缓冲与调度机制,让 ASR、LLM、TTS 三个异步环节像齿轮一样精密咬合。

参考

非实时语音合成

实时语音合成

客户端事件

服务端事件

非实时语音识别