用 Deepgram + CloudBase AI 做一个浏览器端语音对话 chatbot
一句话定义:浏览器
getUserMedia+MediaRecorder录一段语音 → 上传云函数走 Deepgramnova-3做 STT → 转写文本喂 CloudBase AIstreamText({ model: 'deepseek-v4-flash' })流式回答 → 前端按句号切片送进SpeechSynthesisUtterance边读边播。一问一答的语音 chatbot,前后端凭证不落浏览器。预计耗时:60 分钟 | 难度:进阶
适用场景
- 做客服 / AI 助手类语音交互产品,用户按住说话、松开等回答,典型的 push-to-talk
- 无障碍场景:视觉受限用户用语音问、用语音听结果,不用看屏幕
- hands-free 场景:车载 / 厨房 / 健身,用户腾不出手敲字
- 已经用 connect-deepgram-speech-to-text-cloud-function 跑通了 STT,用 add-ai-nextjs 跑通了 streamText,本篇把两端组合成产品级闭环
不适用:
- 电话级实时双向语音(打断、抢话、亚 300ms 延迟)——那种走 WebRTC + 实时语音 API(Deepgram Live、ElevenLabs Conversational AI、OpenAI Realtime),本篇是一问一答,STT 走批量接口
- 超长对话(连续 200+ 轮 TTS)——浏览器
speechSynthesis队列存在 200 条左右的隐性上限,长时间不消费会被截断,产品上要么定期cancel()清队列要么换成第三方 TTS API - 中文 TTS 高音质需求——浏览器内置
SpeechSynthesisUtterance中文音质一般(各系统自带的中文 voice 多为离线 TTS,韵律平),要好音质走第三方 TTS(Azure / Volcengine / MiniMax)的 HTTP API 在云函数里代理 - 需要 punctuation 之外的字级时间戳来做卡拉 OK 式高亮——本篇 TTS 是浏览器播报,没有 audio 文件;要做字幕同步走 Deepgram
utterances+ 自己渲染
环境要求
| 依赖 | 版本 |
|---|---|
| 浏览器 | Chrome / Edge / Safari 14+ (MediaRecorder + speechSynthesis 都支持);Safari macOS 14 之前 MediaRecorder 不支持 webm |
@cloudbase/node-sdk | 3.16.0 及以上(AI 模块要求,timeout 选项要拉到 60s) |
@deepgram/sdk | ^4.x(本文按 v4 API,listen.v1.media.transcribeFile) |
| Node.js | 云函数运行时 ≥ 18 |
| CloudBase 环境 | 已开通,且控制台已开通「AI+」能力 |
| Deepgram 账号 | 一个 API key(注册即送 200 美元额度) |
需要准备:
- 一个有麦克风的设备(笔记本自带就行)
- HTTPS 域名或
localhost——getUserMedia在非安全上下文下被浏览器拒绝 - 一个 CloudBase 环境 envId + 一组腾讯云子账号密钥(SecretId / SecretKey),通过 CAM 锁到当前环境最安全
整体链路
[浏览器]
getUserMedia → MediaRecorder(webm/opus)
↓ Blob → base64 / FormData
↓ HTTP POST
[Web 云函数 transcribe-audio]
Buffer → Deepgram nova-3 transcribeFile
↓ transcript 文本
↓ HTTP 返回
[浏览器]
fetch('/api/chat')
↓ POST { messages: [...历史, { role: 'user', content: transcript }] }
[Web 云函数 chat]
CloudBase AI streamText(deepseek-v4-flash)
↓ textStream(AsyncIterable<string>)
↓ ReadableStream(text/plain)
[浏览器]
reader.read() 累积 chunk
↓ 按句号 / 问号 / 感叹号 / 换行切句
↓ 每整一句 enqueue 到 TTS 队列
SpeechSynthesisUtterance + speechSynthesis.speak()
关键设计:STT 走批量(短录音整体一次),AI 走流式,TTS 也走流式。STT 不流式是因为客户端短录音(常见 < 30 秒)上传后整体转一次比起 WebSocket 实时流麻烦更少;AI 必须流式,否则用户要等几秒才听到第一个字;TTS 必须按句切片,等模型整段输出完再 TTS 等于把流式收益吃掉。
第一步:CloudBase 上开通 AI + 拿 Deepgram key
跟 add-ai-nextjs 第一步、connect-deepgram-speech-to-text-cloud-function 第一步完全一样:
- CloudBase 控制台 → 环境 → AI+ → 快速接入,首次会有「立即开通」按钮,开通免费、调用按 token 计费
- 在「模型管理」里确认
deepseek-v4-flash已上架(这是 CloudBase 当前主推的对话模型,完整列表见 接入大模型) - 登录 Deepgram Console → API Keys → Create a New API Key,Scope 选
Member,复制 key 备用(只显示一次)
下面用两个云函数:
voice-stt— HTTP 触发的 Web 云函数,接前端上传的音频 buffer,调 Deepgram 返回 transcriptvoice-chat— HTTP 触发的 Web 云函数,接前端 messages,调 CloudBase AI 流式返回
也可以塞进同一个函数走 path 分发,本篇分开写更清楚。
第二步:写 voice-stt(Web 云函数,Deepgram STT)
新建函数目录:
mkdir voice-stt && cd voice-stt
npm init -y
npm install --save @deepgram/sdk
index.js:
const { createClient } = require("@deepgram/sdk");
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
// Web 云函数入口:event 形如 { httpMethod, body, headers, isBase64Encoded, ... }
exports.main = async (event) => {
// 浏览器 fetch 传 base64 文本上来,云函数侧把它还原成 Buffer
// 不直接传 binary 是为了避开 Web 云函数对原始二进制 body 的尺寸 + Content-Type 限制
if (event.httpMethod !== "POST") {
return { statusCode: 405, body: "method not allowed" };
}
let audioBuffer;
let language = "zh-CN";
try {
const payload = JSON.parse(event.body || "{}");
if (!payload.audioBase64) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "missing_audioBase64" }),
};
}
audioBuffer = Buffer.from(payload.audioBase64, "base64");
if (payload.language) language = payload.language;
} catch (err) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "invalid_body", message: err.message }),
};
}
try {
const response = await deepgram.listen.v1.media.transcribeFile(audioBuffer, {
model: "nova-3",
smart_format: true, // 自动加标点 + 数字格式化
language, // "zh-CN" / "en" / "multi" 等
// 短问句不需要 diarize / utterances,省一点延迟
});
const transcript =
response?.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || "";
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // 生产环境换成你的前端域名
},
body: JSON.stringify({ ok: true, transcript }),
};
} catch (err) {
console.error("deepgram failed", err);
return {
statusCode: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ok: false,
error: "deepgram_failed",
statusCode: err.statusCode,
message: err.message,
}),
};
}
};
package.json:
{
"name": "voice-stt",
"main": "index.js",
"dependencies": {
"@deepgram/sdk": "^4.0.0"
}
}
几个要点:
- 音频走 base64,不走 multipart:浏览器
MediaRecorder拿到 Blob 后用FileReader.readAsDataURL转 base64 直接 POST JSON,云函数侧Buffer.from(payload.audioBase64, 'base64')还原。multipart 在 Web 云函数里要自己解析 form-data,不如 JSON 朴素 - 音频别太长:Web 云函数请求体上限通常 ~6MB,base64 体积 +33%,留余量大概能装 30 秒 Opus 录音(Opus 32kbps 单声道 30 秒 ~120KB,远没到上限),真要传分钟级的录音改成「前端先上传云存储 → 云函数从 fileID 下载」,走 connect-deepgram-speech-to-text-cloud-function 那一套
language: "zh-CN"强烈建议显式指定,nova-3 默认en,中文不指定会音译成英文乱码;中英文混说传"multi"- 没开
diarize/utterances——单人短问句不需要,删掉省 200-500ms
第三步:写 voice-chat(Web 云函数,CloudBase AI 流式)
新建函数目录:
mkdir voice-chat && cd voice-chat
npm init -y
npm install --save @cloudbase/node-sdk
index.js:
const tcb = require("@cloudbase/node-sdk");
// 模块级缓存,Web 云函数实例 hot 状态下复用
let app = null;
function getApp() {
if (!app) {
// timeout 60s:streamText 长输出会击穿默认 15s
app = tcb.init({ env: process.env.TCB_ENV, timeout: 60000 });
}
return app;
}
// Web 云函数入口
exports.main = async (event) => {
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS",
},
};
}
if (event.httpMethod !== "POST") {
return { statusCode: 405, body: "method not allowed" };
}
let messages;
try {
const payload = JSON.parse(event.body || "{}");
messages = payload.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "missing_messages" }),
};
}
} catch (err) {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ok: false, error: "invalid_body", message: err.message }),
};
}
const ai = getApp().ai();
const model = ai.createModel("cloudbase");
// 给一段语音对话场景下专用的 system,提示模型短句、口语化
const systemMsg = {
role: "system",
content:
"你是一个语音助手,用户通过语音和你对话,回答要短、口语化、用完整句子(每句以句号/问号/感叹号结尾),不要用 markdown / bullet / 代码块,纯文本即可。",
};
const result = await model.streamText({
model: "hy3-preview",
messages: [systemMsg, ...messages],
});
// Web 云函数支持 streamingBody;不同版本写法略有差异,这里用最通用的 ReadableStream
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
} catch (err) {
console.error("streamText failed", err);
controller.error(err);
}
},
});
return {
statusCode: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
},
body: stream,
};
};
package.json:
{
"name": "voice-chat",
"main": "index.js",
"dependencies": {
"@cloudbase/node-sdk": "^3.16.0"
}
}
要点:
- 服务端 SDK + 环境凭证:不要用
@cloudbase/js-sdk+signInAnonymously(),后者匿名身份在 Web 云函数会被严格限频,详见 Web SDK 安全策略 provider: 'cloudbase':createModel('cloudbase')这一行,模型才会按 CloudBase 统一接入路径计费 / 鉴权timeout: 60000:模型流式输出 30 秒以上很常见,SDK 默认 15s 会断- CORS:OPTIONS 预检和正式响应都要带
Access-Control-Allow-Origin,前端在https://your-app.com上调跨域必须设置;生产环境把*换成具体域名 - Web 云函数的
body字段在新版运行时支持直接返回ReadableStream;如果你的运行时版本不支持,见本节末尾的「Web 云函数流式输出兼容写法」