🥖 给 Agent 装上耳朵和嘴巴:ASR + TTS 语音交互实战

从"打字提问"到"张嘴就聊",聊一聊怎么让 Agent 听懂人话、再用嗓子回你

现在的 Agent,光会打字已经不够了

你去看但凡像样点的 Agent 产品,基本都有一个共同技能:能听、会说。

你说一句话,它转成文字(这就是 ASR ,自动语音识别);它想出来的答案,再用语音念给你听(这就是 TTS,语音合成),而且音色还能随便换。笔记里那句话总结得很到位:

语音输入会转成文本(ASR),大模型的回答通过语音朗读(TTS),可以切换音色。Agent 开发必备技术。

所以今天这篇,我们就来把 Agent 的"耳朵"和"嘴巴"装上去。先理清楚整条链路长什么样:

用户点击录音 → 输入一段语音 → 服务端接口把语音转成文字(ASR)→ 大模型生成回答 → 流式 SSE 返回文字 ,同时 WebSocket 返回流式语音。

一句话说人话:耳朵归 ASR,嘴巴归 TTS,文字走 SSE,语音走 WebSocket。

先回答一个灵魂拷问:为啥不干脆用 SSE 传音频?

既然文字都能用 SSE 流式返回,那音频为什么不行?

笔记里给了答案,而且一针见血:

因为 SSE 是基于 HTTP 的文本协议,需要转 base64 才行,传这种二进制数据还是 WebSocket 更合适。SSE 是基于 GET 请求。

翻译一下:

  • SSE 本质是"文本协议" 。它只能老老实实传纯文本,你想塞一段二进制音频,得先 base64 编码成字符串------体积直接膨胀约三分之一,纯属花钱买罪受。
  • SSE 还是"单工"的 (基于 GET 请求,服务器往客户端单向推)。语音这种事,天生更适合 WebSocket 这种双向、能传二进制的通道。

所以整个项目里就有了一条清清楚楚的分工线:

数据 走哪条路 原因
流式文字 Server-Sent Events (SSE) 本来就是文本,天生合适
流式语音 WebSocket 二进制数据,双向通道更合适

前端这边:录音其实没你想的那么难

语音功能的第一步,是在浏览器里把声音录下来。笔记里把前端要做的事列得明明白白:

  • 静态服务器用 @nestjs/serve-static;
  • 用户交互页面是 asr.html;
  • 调 navigator.mediaDevices.getUserMedia 申请麦克风权限;
  • 用 MediaRecorder 这个录音 API,把音频流(stream)切成一块块(chunk)。

核心就是一句话:getUserMedia 拿到麦克风音频流,MediaRecorder 把它录下来切成 chunk,再把这些二进制音频扔给 NestJS 服务端。

服务端拿到这段音频后,转手去调腾讯云的 ASR,换回文字,再拿去喂给大模型。剩下的,就是"流式返回"的戏份了------那部分是下一篇的主角,这里先按下不表。

ASR 实战:把 mp3 变成一行文字

先看最朴素的版本:给一个音频文件,拿回识别结果。完整代码长这样:

javascript 复制代码
// 自动语音识别 Automatic Speech Recognition
// 知识库,语音先转文字,文本向量 匹配 知识库中的文本向量,找到最相似的文本,作为识别结果。
import "dotenv/config"
import tencentcloud from "tencentcloud-sdk-nodejs";// 腾讯云 sdk
import fs from "fs";// 读写文件fs";

const SECRET_ID = process.env.SECRET_ID;
const SECRET_KEY = process.env.SECRET_KEY;

const AsrClient = tencentcloud.asr.v20190614.Client;
const AUDIO_FILE = './output.mp3';

先把几个要素交代清楚:

  • tencentcloud-sdk-nodejs:腾讯云全家桶 SDK;
  • v20190614:ASR 产品的版本号,写错了直接报错;
  • 密钥走 .env (SECRET_ID / SECRET_KEY),千万别硬编码进代码------这是底线。

顺便说一句:代码顶上那行注释写着"语音先转文字,再拿文本向量去匹配知识库"------这只是笔记里对"知识库问答"场景的联想。就这段代码本身而言,腾讯云的 SentenceRecognition 是直接把语音识别成文字返回给你的,不需要你自己再去做向量匹配。

接着初始化客户端:

javascript 复制代码
const client = new AsrClient({
    credential: {
        secretId: SECRET_ID,
        secretKey: SECRET_KEY,
    },
    region: "ap-shanghai",
    profile: {
        httpProfile: {
            reqMethod: "POST",
            reqTimeout: 30,
        }
    }
});

注意 region: "ap-shanghai"(上海地域)和 reqTimeout: 30(30 秒超时)------音频识别有时比较慢,超时给足一点。

然后是真正干活的部分:

javascript 复制代码
async function run() {
    // mp3 二进制 -> base64
    const audioBase64 = fs.readFileSync(AUDIO_FILE).toString("base64");

    const params = {
        EngSerViceType: "16k_zh", // 16k 16bit 1ch 中文识别
        SourceType:1, // 1: base64 编码的音频数据 2: url
        Data: audioBase64,
        DataLen: Buffer.byteLength(audioBase64),
        VoiceFormat: "mp3",
    }
    try {
        const data = await client.SentenceRecognition(params);
        console.log("识别结果", data.Result);
    } catch (error) {
        console.error("识别失败", error);
    }
}

run()
    .catch(console.error);

这几个参数,个个都是"不写就报错"的主:

  • EngSerViceType: "16k_zh":16k 采样率、中文识别。名字里的 "S" 大写小写这事儿别纠结,照抄就对了(历史遗留的拼写)。
  • SourceType: 1 :意思是"我传的是 base64 编码的音频数据";如果改成 2,那就是传一个音频 URL 让它自己去下载。
  • Data + DataLen :音频的 base64 内容,和它的字节长度(Buffer.byteLength)。
  • VoiceFormat: "mp3":音频格式,跟你的文件对上。

关键点 :音频是二进制,SDK 要的是 base64 字符串,所以第一步 fs.readFileSync(...).toString("base64") 把 mp3 整个读进来编码。识别成功,结果就在 data.Result 里躺着了。

TTS 基础版:让文字出声

会听之后,还得会说。先看非流式版本------给它一段文字,它给你一个完整音频文件:

javascript 复制代码
import "dotenv/config";
import tencentcloud from "tencentcloud-sdk-nodejs-tts";
import fs from "node:fs";

const secretId = process.env.SECRET_ID;
const secretKey = process.env.SECRET_KEY;

const TtsClient = tencentcloud.tts.v20190823.Client;
const client = new TtsClient({
    credential: {
        secretId,
        secretKey,
    },
    region: "ap-beijing",
    profile: {
        httpProfile: {
            endpoint: "tts.tencentcloudapi.com",
        },
    },
   });

注意这里用的是另一个 SDK tencentcloud-sdk-nodejs-tts,版本 v20190823,地域换成了 ap-beijing。

参数和调用:

javascript 复制代码
const params = {
    // Text: `下班路上,我还在为晚霞开心。突然电话响起:系统崩了。我的心一下揪紧,
    // 冲进办公室时几乎是绝望。可当大家一起排查、重启、屏幕终于恢复正常的,我长长松了口气,
    // 笑着说:还好,我们没放弃。`,
    Text: `Hello`,
    SessionId: 'session-001',
    VoiceType: 502006, // 女声
    Codec: "mp3"
}

这里有个有趣的细节:源码里被注释掉的那段 Text,是一小段"晚霞 → 电话铃响 → 系统崩了 → 冲回办公室 → 终于恢复 "的戏剧小作文。写 demo 的人是真的懂生活------而正式跑的是简简单单一句 Hello,先跑通再说。

VoiceType: 502006 就是音色编号(标注是女声)。想换声音?改这个号就行,这就是"可切换音色"的实现方式。

最后是保存音频:

javascript 复制代码
client
    .TextToVoice(params)
    .then((data) => {
        // console.log(data); // base64字符串
        // 二进制缓冲区
        const audioBuffer = Buffer.from(data.Audio, 'base64');// 二进制缓冲区
        const outputPath = './output.mp3';
        fs.writeFileSync(outputPath, audioBuffer, (err) => {
            if (err) {
                console.error('保存文件失败:', err);
            } else {
                console.log(`文件已成功保存: ${outputPath}`);
            }
        });
    })

流程是:调 TextToVoice → 返回的 data.Audio 是 base64 字符串 → 用 Buffer.from(data.Audio, 'base64') 还原成二进制 → 写进 ./output.mp3。

一来一回正好对称:ASR 是"文件二进制 → base64 → 喂给接口",TTS 是"接口返回 base64 → 还原二进制 → 存成文件"。二进制和 base64 之间来回倒腾,是跟云服务打交道的基本功。

TTS 进阶版:流式语音,边生成边播放

基础版有个体验问题:它得等整段音频全部合成完,才一次性丢给你。文字长一点,你就得干等着。

而真正救体验的,是流式合成------文字一句一句送进去,音频一块一块吐出来,理论上可以做到"边说边播"。这段代码信息量最大,我们慢慢拆。

第一步:自己算一个签名(腾讯云 WebSocket 的规矩)

流式 TTS 走的是 WebSocket,地址长这样:wss://tts.cloud.tencent.com/stream_wsv2。而这个 URL 不是随便拼的,得签名:

javascript 复制代码
const SECRET_ID = process.env.SECRET_ID;
const SECRET_KEY = process.env.SECRET_KEY;
const APP_ID = process.env.APP_ID;

const VOICE_TYPE = 101001;
const OUTPUT_FILE = 'output.mp3';
const TEXT_INTERVAL_MS = 3000;

const TEXTS = [
    "傍晚我还在为晚霞开心",
    "突然电话响起:系统崩了",
    "我心里一沉冲回办公室",
    "好在大家一起排查后终于恢复",
    "我长长松了口气"
];
// 等待指定时间 await sleep(1000);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

眼熟吗?又是那段"晚霞与系统崩了"的小剧场,只不过这次被切成了五句,一句一句喂进去。

再看签名函数:

javascript 复制代码
function buildWsUrl() {
    const now = Math.floor(Date.now() / 1000); // 当前时间秒数向下取整
    const sessionId = `session_${now}_${Math.random().toString(36).slice(2)}`;
    const params = {
        Action: "TextToStreamAudioWSv2", // 文本到流式声音 基于WebSocket 协议
        AppId: parseInt(APP_ID),
        Codec: "mp3",
        Expired: now + 3600, // 过期时间 1 小时
        SampleRate: 16000, // 采样率 16000Hz
        SecretId: SECRET_ID,
        SessionId: sessionId,
        Speed: 0,
        Timestamp: now,
        VoiceType: VOICE_TYPE,
        Volume: 5,
    }
    const sortedKeys = Object.keys(params).sort();
    const signStr = sortedKeys.map((k) => `${k}=${params[k]}`).join("&");
    const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
    const signature = crypto
        .createHmac("sha1", SECRET_KEY)
        .update(rawStr)
        .digest("base64");
    // ?a=1&b=2
    const searchParams = new URLSearchParams({
        ...params,
        Signature: signature,
    });
    return {
        sessionId,
        url:`wss://tts.cloud.tencent.com/stream_wsv2?${searchParams.toString()}`,
    }
}

签名规则本身很简单,就三步:

  1. 把参数按键名排序 (Object.keys(params).sort()),拼成 key=value 用 & 连起来;
  2. 前面拼上 GETtts.cloud.tencent.com/stream_wsv2?(注意是 GET 开头,不是 POST);
  3. 用 SECRET_KEY 做 HMAC-SHA1 加密,再 base64 编码,得到 Signature。

几个参数值得记一下:Action 是接口名 TextToStreamAudioWSv2,SampleRate: 16000 采样率,VoiceType: 101001 音色,Expired: now + 3600 表示一小时后过期。

第二步:连上去,然后"接水管"

签名换到 URL,就可以连了:

javascript 复制代码
function streamTTS() {
    if (!SECRET_ID || !SECRET_KEY || !APP_ID) {
        throw new Error("请先在.env文件中配置 SECRET_ID, SECRET_KEY, APP_ID"); 
    }
    const {url,sessionId} = buildWsUrl();
    const ws = new WebSocket(url); // 链接 ws 服务器
    // 水管子 一头扎到目标文件 一头联向 ws 服务器,用于写入音频数据,bundle,写入文件
    const writeStream = fs.createWriteStream(OUTPUT_FILE, {flags: 'w'});
    let totalBytes = 0; // 已写入的字节数
    let closed = false;
    let sent = false;

那句注释我特别喜欢,复述一遍:

水管子:一头扎到目标文件,一头联向 ws 服务器,用于写入音频数据。

fs.createWriteStream 就是这根"水管子"。服务器那边每吐出一块音频,这边就顺着管子"哗"地流进文件里,不用等全部合成完。另外还准备了三个小旗子:totalBytes 记字节数、closed 防重复关闭、sent 防止文本被重复发送。

第三步:处理收到的消息(这段最妙)

javascript 复制代码
    // 有消息了
    ws.on("message", async (data, isBinary) => {
        if (isBinary) {
            writeStream.write(data);
            totalBytes += data.length;
            return;
        }
        // 发送完了,消息,报错
        try {
            const msg = JSON.parse(data.toString());
            console.log("[消息]", JSON.stringify(msg));
            // 服务器就绪,可以发送文本了
            if (msg.ready === 1 && !sent) {
                sent = true;
                await sendTexts(ws, sessionId); // 发送文本
            }
            if (msg.code && msg.code !== 0) {
                console.error(`[错误]  code=${msg.code}, message=${msg.message}`);
                closeAll();
            } else if (msg.final === 1) {
                console.log('[完成] 合成结束');
            }
        } catch (e) {
            console.error("[解析错误]", e.message);
        }
    });

这里有个极其重要的判断 :isBinary。

  • 是二进制 (isBinary === true)→ 那就是音频数据,直接 writeStream.write(data) 灌进文件;
  • 不是二进制 → 那就是控制消息 (JSON 文本),得 JSON.parse 解析。

服务器返回的 JSON 里,有三个关键字段,构成了整个握手流程:

  • ready === 1 :服务器说"我准备好了,你可以发文本了"。所以这里用 sent 标志位守着,只在第一次 ready 时调用 sendTexts,避免重复发送。
  • code !== 0:出错了,打印错误并收工。
  • final === 1:合成结束了。

再回答一次那个"为什么不用 SSE"的问题 :正是因为 WebSocket 能通过 isBinary 直接区分"这块是二进制音频",所以音频可以顺着同一条连接一坨坨地流出来,不用 base64 编码成文本。这就是它比 SSE 适合传音频的根本原因。

第四步:把五句话一句句喂进去

javascript 复制代码
async function sendTexts(ws, sessionId) {
    for (let i = 0; i < TEXTS.length; i++) {
        ws.send(JSON.stringify({session_id:sessionId, message_id: `msg_${i}`,
            action:"ACTION_SYNTHESIS", data: TEXTS[i]}))
        console.log(`[文本]已发送:${TEXTS[i]}`)
        if (i < TEXTS.length - 1) await sleep(TEXT_INTERVAL_MS);
    }
    ws.send(JSON.stringify({ session_id:sessionId, action:"ACTION_COMPLETE" }))
    console.log("[文本]已发送ACTION_COMPLETE")
}

这段就是"把文字一句句递进去"的动作:

  • 每句话用一个 action: "ACTION_SYNTHESIS" 的消息发出去,带上 session_id 和 message_id;
  • 句与句之间 sleep(3000)(TEXT_INTERVAL_MS = 3000)隔 3 秒------模拟"慢慢说"的效果;
  • 全部发完,最后补一条 action: "ACTION_COMPLETE",等于告诉服务器"我说完了,你收尾吧"。

第五步:优雅收尾

javascript 复制代码
    const closeAll = () => {
        if (closed) return;
        closed = true;
        writeStream.end(() => {
            console.log(`[保存]音频已保存到 ${OUTPUT_FILE},共 ${totalBytes} 字节`);
        });
        if (ws.readyState < WebSocket.CLOSING) ws.close();
    }

closeAll 用 closed 标志位做幂等保护 :不管你是正常结束、还是出错、还是连接断了,最后都走这一个出口------关掉"水管子"、打印总字节数、关掉 WebSocket。不写这个,很可能在多个回调里把连接重复关闭,然后收获一堆莫名其妙的报错。

open / error / close 三个事件也都挂了对应的日志处理,整段逻辑才算完整闭环。

小结

到这儿,Agent 的"耳朵"和"嘴巴"就装好了:

  • 耳朵(ASR) :mp3 二进制 → base64 → 腾讯云 SentenceRecognition → 一行文字;
  • 嘴巴(TTS 基础版) :文字 → TextToVoice → base64 → 还原二进制 → 存成 mp3;
  • 嘴巴(TTS 流式版) :自己算 HMAC-SHA1 签名 → 连 WebSocket → ACTION_SYNTHESIS 逐句送文字 → 二进制音频从 isBinary 通道直接流进文件 → ACTION_COMPLETE 收尾。

但有没有发现,这里面其实藏着一个还没解开的关键角色------"流式返回"。文字怎么一段段推给前端?生成器、RxJS、SSE 又是怎么配合的?

这正是下一篇要拆的:NestJS 的 AI 模块 + SSE + RxJS 流式输出 。我们不仅要把 @Sse 这个装饰器聊透,还要看看"生成器(Generator)"是怎么让答案一段一段"吐"出来的。

(小声说:那个"晚霞与系统崩了"的小剧场,大概率还有返场。)

相关推荐
玫瑰互动GEO1 小时前
AI时代下,GEM优化组织落地工程化:角色路由+KPI埋点+预算分配表
人工智能·ai搜索·gem·gem优化·生成式引擎营销
星云低代码开发平台1 小时前
AI 业务工具授权复核:触发条件、权限双层校验与验收清单
人工智能·权限管理·系统集成
雷工笔记1 小时前
AI根本没“觉醒“,别被这波“AI戏剧“骗了
人工智能
·云扬·1 小时前
大模型训练三阶段与 LoRA 微调:从预训练到领域适配
人工智能·深度学习·ai
Psycho_MrZhang1 小时前
Agent 测评、缺陷分级与发布流程
人工智能
朝朝辞暮i1 小时前
VLA 系统学习第 17 课:正式进入 ACT——为什么需要 CVAE、潜变量 z 和 KL Loss?
人工智能·深度学习·神经网络·transformer·vla
小宋10212 小时前
Agent轨迹级评测实战:工具选择、预算超限与回归门禁
android·网络·人工智能·回归
larance2 小时前
[菜鸟教程] 机器学习教程九课-常用数据类型
人工智能·机器学习
宋哥转AI2 小时前
我的创作纪念日:成为创作者的第 128 天
人工智能·ai