Back to all posts
Tutorial

用 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。两个关键要求:

  1. export const runtime = 'edge'——必须声明,否则 getRequestContext() 在 Cloudflare Pages 上无法正常工作
  2. 将音频 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 最通用的输入格式。


注意事项与技巧

  1. export const runtime = 'edge' 是必须的。 没有它,getRequestContext() 在 Cloudflare Pages 上不会返回任何内容。

  2. Edge Runtime 没有 Buffer。 Base64 编码不能用 Buffer.from(bytes).toString('base64'),要用 btoa() 分块处理,避免大文件时调用栈溢出。

  3. turbo 和 base 模型的 audio 字段类型不同。 文档中没有明确说明这一差异——我是通过报错和实验发现的。

  4. 必须关闭 AudioContext。 浏览器对同时存在的 AudioContext 实例数量有上限,解码后必须 await audioCtx.close()。

  5. 防范空录音。 如果用户误触麦克风即触即松,decodeAudioData 会对空 blob 抛出异常。检查 blob.size < 1000 并提前返回。

  6. Base64 会增加约 33% 体积。 10 秒语音录音(约 160KB 原始 PCM)转 Base64 后约 215KB,远在 Cloudflare 限制内。

  7. iOS 需要用户手势才能创建 AudioContext。 使用 onPointerDown(而非定时器或 useEffect)满足此要求。


最终效果

实现这套流程后,我的 Chatbot 支持:

  • 长按说话:按住麦克风按钮说话,松手后开始识别
  • 点击切换:点一下开始录音,说完再点一下停止识别
  • 99 种语言:中文、日文、韩文、英文等识别效果优秀
  • 零成本:完全在 Cloudflare 免费套餐内
  • 无第三方 API 密钥:一切都在 Cloudflare 边缘运行

如果你正在构建多语言 AI 产品并需要语音输入,Cloudflare Workers AI Whisper 就是答案。我遇到的两个 Bug 在文档中没有明确说明——希望这篇文章能为你节省数小时的调试时间。


完整实现已上线 elvisli.ca。