用 Cloudflare Whisper 为 Chatbot 添加 $0 成本多语言语音识别
如果你正在构建一个 Chatbot,想要添加语音输入,又不想为 Google Speech-to-Text、AWS Transcribe 或 OpenAI Whisper API 付费,那么你已经拥有了一切所需——Cloudflare 免费套餐里就有。
这篇文章将详细介绍我如何把 Cloudflare Workers AI Whisper large-v3-turbo 集成到我的作品集网站 Chatbot 中,以完全零成本支持 99 种语言的语音识别。同时,我也会分享遇到的两个生产 Bug,以及精确的修复方法。
为什么选择 Cloudflare Workers AI?
Cloudflare 通过 Workers AI 提供语音识别能力,运行在 Cloudflare 全球边缘网络上。免费套餐包含:
- Workers AI 推理:免费,额度参见官方限制文档,足够个人项目和小型应用使用
- 零冷启动:运行在边缘节点,距离用户最近
- 无需管理 API 密钥:通过
env.AI直接绑定到 Worker - 99 种语言支持:包括中文、日文、韩文、法文、西班牙文、阿拉伯文等
最大的优势:你不需要托管模型、管理 GPU 实例,也不用处理第三方 API 的速率限制。开箱即用。
Whisper Base vs Whisper Large v3 Turbo:深度对比
Cloudflare Workers AI 提供两个 Whisper 模型。选对模型至关重要。
@cf/openai/whisper(基础版)
这是 Whisper base 原版模型(约 3900 万参数)。
优点:
- 推理速度快——适合对延迟敏感的场景
- 每次请求资源消耗低
- 在 Cloudflare 上经过充分验证,非常稳定
缺点:
- 词错率(WER)较高,在非英语语言上表现一般
- 对口音、专业词汇、混合语言输入识别较弱
- audio 参数格式:
number[](将 Uint8Array 展开成普通 JS 数组)
适合场景: 高并发、速度优先的应用,或纯英语使用场景。
@cf/openai/whisper-large-v3-turbo(Turbo 版)
这是 Whisper large-v3 的精简蒸馏版(约 8.09 亿参数),在保留大部分精度的同时,推理速度比 large-v3 快约 2 倍。
优点:
- 与基础版相比词错率降低约 50%——中日韩等语言效果显著提升
- 多语言精度优秀,对各种口音表现良好
- 支持可选参数:
language、task(transcribe/translate)、vad_filter、beam_size - 返回丰富输出:
text、word_count、带时间码的segments(WebVTT 格式)
缺点:
- 比基础版重——单次请求延迟略高
- audio 参数格式:Base64 字符串(不是数字数组——这导致了我的第一个 Bug)
- 无法稳定解码
webm/opus容器(这导致了我的第二个 Bug)
适合场景: 多语言 Chatbot、语音助手、无障碍工具、对精度要求高的转录流水线。
对比汇总
| | @cf/openai/whisper | @cf/openai/whisper-large-v3-turbo |
|---|---|---|
| 参数量 | 约 3900 万 | 约 8.09 亿 |
| 支持语言 | 99 种 | 99 种 |
| 识别精度 | 良好 | 优秀 |
| 推理速度 | 最快 | 快(比 large-v3 快约 2×) |
| audio 参数格式 | number[] | Base64 字符串 |
| 支持容器格式 | webm、wav、mp3 | wav、mp3(webm 不稳定) |
| 额外输出字段 | 仅 text | text、word_count、segments |
| Cloudflare 费用 | 免费套餐 | 免费套餐 |
架构设计
完整的数据处理流程:
浏览器麦克风
↓
MediaRecorder(录制 webm/opus——浏览器默认格式)
↓ 【客户端转码】
AudioContext.decodeAudioData() → 解码 webm/opus
↓
encodeWAV() → 16-bit PCM WAV(单声道,原始采样率)
↓ HTTP POST multipart/form-data
/api/transcribe(Next.js Edge Route,运行在 Cloudflare Pages)
↓
getRequestContext().env.AI(Cloudflare AI 绑定)
↓
btoa() → 分块 Base64 编码(每批 8192 字节)
↓
env.AI.run('@cf/openai/whisper-large-v3-turbo', { audio: base64 })
↓
{ text: "识别结果" } → 回填 Chatbot 输入框
第一步:在 Wrangler 中绑定 AI
在 wrangler.jsonc(或 wrangler.toml)中添加 AI 绑定:
{
"ai": {
"binding": "AI"
}
}
在 env.d.ts 中声明类型:
interface CloudflareEnv {
AI: any;
}
运行 npx wrangler types 可从实时绑定生成精准类型定义。
第二步:服务端转录路由
创建 src/app/api/transcribe/route.ts。两个关键要求:
export const runtime = 'edge'——必须声明,否则getRequestContext()在 Cloudflare Pages 上无法正常工作- 将音频 Base64 编码——turbo 模型要求此格式;Edge Runtime 没有 Node.js 的
Buffer
import { NextRequest, NextResponse } from 'next/server';
import { getRequestContext } from '@cloudflare/next-on-pages';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
}
const { env } = getRequestContext();
if (!env.AI) {
return NextResponse.json({ error: 'Cloudflare AI binding is missing' }, { status: 500 });
}
const arrayBuffer = await file.arrayBuffer();
const audioBytes = new Uint8Array(arrayBuffer);
// Edge Runtime 没有 Buffer——分块 Base64 编码,避免大数组栈溢出
let binary = '';
for (let i = 0; i < audioBytes.length; i += 8192) {
binary += String.fromCharCode(...audioBytes.subarray(i, i + 8192));
}
const audioBase64 = btoa(binary);
const response = await env.AI.run('@cf/openai/whisper-large-v3-turbo', {
audio: audioBase64,
});
if (!response || !response.text) {
throw new Error('No transcription returned from AI model');
}
return NextResponse.json({ text: response.text });
} catch (err: unknown) {
console.error('Transcription API Error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}
第三步:客户端录音与 WAV 转码
浏览器 MediaRecorder API 默认录制 webm/opus 格式——而 turbo 模型无法稳定解码(报错 3030)。解决方案:在浏览器端将录音转码为 WAV 后再上传。
WAV 编码器
function encodeWAV(audioBuffer: AudioBuffer): Blob {
const numChannels = 1; // 单声道对语音识别足够
const sampleRate = audioBuffer.sampleRate;
const samples = audioBuffer.getChannelData(0);
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeString = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
// RIFF/WAVE 文件头
writeString(0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true); // 16-bit
writeString(36, 'data');
view.setUint32(40, samples.length * 2, true);
// float32 → int16 PCM
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
return new Blob([view], { type: 'audio/wav' });
}
录音逻辑
mediaRecorder.onstop = async () => {
const webmBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
stream.getTracks().forEach((track) => track.stop());
// 守卫:空录音(误触)→ 静默忽略
if (webmBlob.size < 1000) return;
setIsLoading(true);
try {
const arrayBuffer = await webmBlob.arrayBuffer();
const audioCtx = new AudioContext();
const decoded = await audioCtx.decodeAudioData(arrayBuffer);
await audioCtx.close(); // 必须关闭,避免 "too many AudioContexts"
const wavBlob = encodeWAV(decoded);
const formData = new FormData();
formData.append('file', wavBlob, 'recording.wav');
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
// ... 处理响应
} finally {
setIsLoading(false);
}
};
第四步:双模式麦克风按钮
使用 Pointer Events(兼容鼠标和触屏),通过按压时长区分两种交互模式:
const TAP_THRESHOLD_MS = 400;
const handleMicPressStart = async (e: React.PointerEvent) => {
e.preventDefault();
if (isLoading) return;
if (recordModeRef.current === 'toggle') { stopListening(); return; }
if (recordModeRef.current !== 'idle') return;
pressStartRef.current = Date.now();
recordModeRef.current = 'pending';
await startListening();
};
const handleMicPressEnd = (e: React.PointerEvent) => {
e.preventDefault();
if (recordModeRef.current !== 'pending') return;
const elapsed = Date.now() - pressStartRef.current;
if (elapsed < TAP_THRESHOLD_MS) {
recordModeRef.current = 'toggle'; // 点击 → 继续录音,等待第二次点击
} else {
stopListening(); // 长按 → 松手立即停止
}
};
我在生产中遇到的两个 Bug
Bug 1:错误码 5006 — 类型不匹配
从 @cf/openai/whisper 切换到 whisper-large-v3-turbo 后,API 返回错误 5006。
根因: 我沿用了旧格式 audio: [...audioBytes](数字数组),这是基础模型的格式。turbo 模型要求的是 Base64 字符串。两者的输入 schema 完全不同。
解决方案: 在服务端用 btoa() 分块转换,将 Uint8Array 编码为 Base64 字符串后传入。
Bug 2:错误码 3030 — 音频文件解码失败
修复 Base64 格式问题后,又遇到错误 3030。
根因: 浏览器以 webm/opus 格式录制。虽然 Base64 编码已正确,但 turbo 模型无法解码 webm/opus 容器。Cloudflare Whisper turbo 只稳定支持 WAV 和 MP3。
解决方案: 在浏览器端用 AudioContext.decodeAudioData() 解码 webm/opus,再用自定义 encodeWAV() 重新编码为 16-bit PCM WAV 上传。WAV 是 Whisper 最通用的输入格式。
注意事项与技巧
-
export const runtime = 'edge'是必须的。 没有它,getRequestContext()在 Cloudflare Pages 上不会返回任何内容。 -
Edge Runtime 没有
Buffer。 Base64 编码不能用Buffer.from(bytes).toString('base64'),要用btoa()分块处理,避免大文件时调用栈溢出。 -
turbo 和 base 模型的
audio字段类型不同。 文档中没有明确说明这一差异——我是通过报错和实验发现的。 -
必须关闭
AudioContext。 浏览器对同时存在的AudioContext实例数量有上限,解码后必须await audioCtx.close()。 -
防范空录音。 如果用户误触麦克风即触即松,
decodeAudioData会对空 blob 抛出异常。检查blob.size < 1000并提前返回。 -
Base64 会增加约 33% 体积。 10 秒语音录音(约 160KB 原始 PCM)转 Base64 后约 215KB,远在 Cloudflare 限制内。
-
iOS 需要用户手势才能创建
AudioContext。 使用onPointerDown(而非定时器或useEffect)满足此要求。
最终效果
实现这套流程后,我的 Chatbot 支持:
- 长按说话:按住麦克风按钮说话,松手后开始识别
- 点击切换:点一下开始录音,说完再点一下停止识别
- 99 种语言:中文、日文、韩文、英文等识别效果优秀
- 零成本:完全在 Cloudflare 免费套餐内
- 无第三方 API 密钥:一切都在 Cloudflare 边缘运行
如果你正在构建多语言 AI 产品并需要语音输入,Cloudflare Workers AI Whisper 就是答案。我遇到的两个 Bug 在文档中没有明确说明——希望这篇文章能为你节省数小时的调试时间。
完整实现已上线 elvisli.ca。

