用 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: "deepseek-v4-flash",
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 云函数流式输出兼容写法」
第四步:前端三件套(录音 → STT → 流式聊天 + TTS)
完整 HTML(单文件 demo,生产环境拆成 React/Vue 组件即可):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>语音对话 chatbot</title>
</head>
<body>
<button id="btn">按住说话</button>
<div id="status">就绪</div>
<div id="dialog"></div>
<script type="module">
// 部署后替换成你的 Web 云函数 HTTPS URL
const STT_URL = "https://your-env.service.tcloudbase.com/voice-stt";
const CHAT_URL = "https://your-env.service.tcloudbase.com/voice-chat";
const btn = document.getElementById("btn");
const statusEl = document.getElementById("status");
const dialogEl = document.getElementById("dialog");
let mediaRecorder = null;
let chunks = [];
const messages = []; // 多轮对话历史
// ========== 录音 ==========
btn.addEventListener("mousedown", startRecord);
btn.addEventListener("mouseup", stopRecord);
btn.addEventListener("touchstart", (e) => {
e.preventDefault();
startRecord();
});
btn.addEventListener("touchend", (e) => {
e.preventDefault();
stopRecord();
});
async function startRecord() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = onRecordStop;
mediaRecorder.start();
statusEl.textContent = "录音中...";
} catch (err) {
console.error("getUserMedia failed", err);
statusEl.textContent = "麦克风获取失败:" + err.message;
}
}
function stopRecord() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach((t) => t.stop());
}
}
// ========== STT ==========
async function onRecordStop() {
const blob = new Blob(chunks, { type: "audio/webm" });
if (blob.size < 1000) {
statusEl.textContent = "录音太短";
return;
}
statusEl.textContent = "识别中...";
const audioBase64 = await blobToBase64(blob);
let transcript = "";
try {
const res = await fetch(STT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audioBase64, language: "zh-CN" }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || data.message);
transcript = data.transcript;
} catch (err) {
statusEl.textContent = "STT 失败:" + err.message;
return;
}
if (!transcript.trim()) {
statusEl.textContent = "没听清,再试一次";
return;
}
appendBubble("user", transcript);
messages.push({ role: "user", content: transcript });
statusEl.textContent = "AI 生成中...";
await streamChatAndSpeak();
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
// data:audio/webm;base64,xxxxx → 去掉前缀
const result = reader.result;
const idx = result.indexOf(",");
resolve(result.slice(idx + 1));
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// ========== AI 流式 + TTS 按句切片 ==========
async function streamChatAndSpeak() {
// 先把这一轮先取消之前队列里没念完的(用户开新一轮通常想打断)
window.speechSynthesis.cancel();
const res = await fetch(CHAT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!res.ok || !res.body) {
statusEl.textContent = "AI 请求失败 " + res.status;
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let acc = ""; // 整段 transcript,渲染用
let sentenceBuf = ""; // 还没遇到标点的句子 buffer
const aiBubble = appendBubble("assistant", "");
// 中文标点 + 英文标点 + 换行 都算句末
const sentenceEnd = /[。!?!?\n]/;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 不能漏,跨 chunk 边界的中文会乱码
const text = decoder.decode(value, { stream: true });
acc += text;
aiBubble.textContent = acc;
sentenceBuf += text;
// 反复切句:每次遇到标点就立刻拆出一段塞 TTS
let m;
while ((m = sentenceBuf.match(sentenceEnd))) {
const cut = m.index + 1;
const sentence = sentenceBuf.slice(0, cut).trim();
sentenceBuf = sentenceBuf.slice(cut);
if (sentence) speak(sentence);
}
}
// 流结束,buffer 里可能还剩没有标点结尾的尾句,也念掉
if (sentenceBuf.trim()) speak(sentenceBuf.trim());
messages.push({ role: "assistant", content: acc });
statusEl.textContent = "就绪";
}
function speak(text) {
const u = new SpeechSynthesisUtterance(text);
u.lang = "zh-CN";
u.rate = 1.05; // 略快一点,语音对话场景更自然
u.pitch = 1;
window.speechSynthesis.speak(u);
}
// ========== UI 辅助 ==========
function appendBubble(role, text) {
const div = document.createElement("div");
div.style.padding = "8px";
div.style.margin = "8px 0";
div.style.background = role === "user" ? "#eef" : "#f5f5f5";
div.style.whiteSpace = "pre-wrap";
div.textContent = (role === "user" ? "我:" : "AI:") + text;
dialogEl.appendChild(div);
return {
set textContent(t) {
div.textContent = (role === "user" ? "我:" : "AI:") + t;
},
get textContent() {
return div.textContent;
},
};
}
</script>
</body>
</html>
要点拆开讲:
切句正则:/[。!?!?\n]/ 同时匹配中英文句末标点,英文逗号 , 不算句末——按逗号切会让 TTS 念出来卡顿。如果对话以英文为主,把 ! 后面加 ;(分号)也能让停顿更自然。
TTS 队列就是 speechSynthesis 自带的队列:连续 speak() 多次会自动排队播放,不需要自己 await 上一句念完。speechSynthesis.cancel() 一调用清空整个队列,所以新一轮开始要先 cancel——否则上一轮没念完的会和新一轮抢话。
stream: true 是中文流式的硬底线:一个汉字 UTF-8 占 3 字节,流式 chunk 边界经常把汉字劈成两半,不带 stream: true 会出现 \uFFFD(��� 替换字符);带上之后 TextDecoder 会缓存边界字节,下次 decode 再拼回去。
多轮对话:messages 数组完整保留历史,每次发都带过去,模型按 OpenAI 风格 role: user / assistant 自己理解上下文。 语音 chatbot 因为模型 system 写了「短句口语化」,token 消耗一般比纯文字 chat 还低。
Push-to-talk vs 长按:本例用 mousedown / touchstart 开始 mouseup / touchend 结束,移动端用户体验最稳。如果想用 VAD(Voice Activity Detection)做「自动检测说完」,要么前端引一个 vad-web 之类的包,要么直接走 Deepgram WebSocket 流式 + 内置 endpointing,本篇不展开。
第五步:部署
部署两个 Web 云函数:
cd voice-stt
tcb fn deploy voice-stt -e your-env-id --type http
cd ../voice-chat
tcb fn deploy voice-chat -e your-env-id --type http
到控制台:
- voice-stt 环境变量:
DEEPGRAM_API_KEY,超时改 30s,内存 256MB 够 - voice-chat 环境变量:
TCB_ENV=你的环境 ID;Vercel/自建机器还要TENCENTCLOUD_SECRETID+TENCENTCLOUD_SECRETKEY(部署在 CloudBase 自家会自动注入);超时改 60s 起步,内存 512MB - 两个函数都要在「函数配置 → HTTP 触发」里看到生成的 HTTPS URL,形如
https://your-env.service.tcloudbaseapp.com/voice-stt,前端STT_URL/CHAT_URL用这个
前端可以挂到 CloudBase 静态托管(把 HTML 扔进去),也可以本地 python3 -m http.server + localhost(注意 getUserMedia 必须 HTTPS 或 localhost)。
运行验证
- 浏览器打开你部署的 HTML 页面,首次会弹「允许使用麦克风」,选允许
- 按住「按住说话」按钮说一句话,例如「介绍一下 CloudBase」,松开
- 状态栏依次显示:
录音中...→识别中...→AI 生成中...→就绪 - 对话框看到「我:介绍一下 CloudBase」「AI:CloudBase 是腾讯云推出的...」逐字增长
- 同时浏览器会朗读第一句完整句,然后继续朗读后续句子,边生成边读,延迟应当在 1.5-3 秒内出第一个字音
- 再问第二句话(例如「它能做什么?」),AI 应当能基于上一轮上下文回答(说明多轮对话正常)
- 浏览器 DevTools Network 看
/voice-stt(JSON 返回,带transcript)和/voice-chat(Response 流式累积纯文本,不是单个 JSON) - CloudBase 控制台 → AI+ → 调用记录,看到 token 计数;Deepgram Console → Usage,看到本次调用记 0.x 分钟