16.09-音频转文本

要点

  • AI 项目中音频转文本(ASR)是语音消息、会议录音、播客转文字等场景的前置步骤
  • Workers 环境无法运行本地 ASR 模型,依赖 Workers AI 或外部语音识别 API
  • 方案对比:Workers AI Whisper 集成简单但功能有限,OpenAI / AssemblyAI / Deepgram 各有侧重
  • 长音频(>25MB)需要分段处理——Workers 环境缺少音频编解码工具,分段通常在客户端或外部服务完成
  • 输出格式化包括带时间戳的字幕生成、口语化清理和说话人分离

1. 音频转文本在 AI 项目里的位置

音频转文本(Automatic Speech Recognition,ASR)在 AI 项目里通常是文本处理的前置步骤:

录音/语音消息 → ASR 转文本 → 文本 → LLM 处理(摘要、提取、问答)

典型场景:

  • 会议录音转写,再做会议纪要和待办提取
  • 播客音频转写,做内容摘要和关键词提取
  • 语音消息转文本,进入后续语义分析流程
  • 客服通话录音转写,分析客户问题和满意度

ASR 的转写质量直接影响下游处理结果。如果识别阶段漏词错词太多,后续的摘要和提取也会跟着出错。选型时需要在精度、延迟和成本之间做权衡。

2. Workers 环境的限制

Workers 运行在 V8 隔离环境里,不是完整的 Linux 容器。这意味着:

  • 不能跑本地 ASR 模型(Whisper.cpp、Vosk、Kaldi 都需要原生编译)
  • 没有 ffmpeg——音频格式转换、采样率调整、分段都无法在 Worker 内完成
  • 内存上限 128MB,处理大文件会触发 OOM
  • 请求有 CPU 时长限制,免费 10ms、Paid 30s

所以在 Workers 上做音频转文本,方案归结为两类:

  1. Workers AI:Cloudflare 内置的 Whisper 模型,集成最简单
  2. 外部 API:调用 OpenAI Whisper API、AssemblyAI、Deepgram 等服务

Workers 本身只做请求编排、格式校验和结果整合,不碰音频编解码。

3. 方案对比

Workers AI — @cf/openai/whisper

Workers AI 内置了 Whisper 模型,集成最方便:

async function transcribeWithWorkersAI(
  audioBuffer: ArrayBuffer,
  ai: Ai
): Promise<string> {
  const response = await ai.run(
    '@cf/openai/whisper',
    { audio: [...new Uint8Array(audioBuffer)] }
  )
  return response.text
}

优势:

  • 不需要额外的 API key,Workers AI 直接可用
  • 边缘运行,网络延迟低
  • 计费走 Workers AI 额度,无需单独签约

限制:

  • 模型版本由 Cloudflare 管理,不能指定 Whisper 的 tiny/base/small/medium/large
  • 音频大小限制比较严格(约 10MB 以内比较稳)
  • 不支持说话人分离、自定义热词等高级功能
  • 输出只有纯文本,没有 segment 级时间戳

OpenAI Whisper API

转写质量稳定,中文识别效果不错。注意 Workers 里不能用 openai npm 包——它依赖 Node.js 的 fsstream 模块,在 V8 Isolate 里跑不了。直接走 REST API:

async function transcribeWithOpenAI(
  audioBuffer: ArrayBuffer,
  apiKey: string
): Promise<{ text: string; segments?: any[] }> {
  const formData = new FormData()
  formData.append(
    'file',
    new Blob([audioBuffer], { type: 'audio/mpeg' }),
    'audio.mp3'
  )
  formData.append('model', 'whisper-1')
  formData.append('language', 'zh')
  // 加这两行可以拿到 segment 级时间戳
  formData.append('response_format', 'verbose_json')
  formData.append('timestamp_granularities[]', 'segment')
 
  const response = await fetch(
    'https://api.openai.com/v1/audio/transcriptions',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}` },
      body: formData,
    }
  )
 
  const data = await response.json()
  return {
    text: data.text,
    segments: data.segments,
  }
}

限制:文件 25MB 以内,大约 1 小时以内的音频。

AssemblyAI

对说话人分离支持比较完整,适合会议场景。它要求音频通过 URL 传入——在 Workers 里可以先存到 R2,再签临时 URL:

async function transcribeWithAssemblyAI(
  audioUrl: string,
  apiKey: string
) {
  // 发起异步转写请求
  const response = await fetch('https://api.assemblyai.com/v2/transcript', {
    method: 'POST',
    headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      audio_url: audioUrl,
      language_code: 'zh',
      speaker_labels: true,
      auto_chapters: true,
    }),
  })
  const { id } = await response.json()
 
  // 轮询直到完成(Workers 里可以用 Cron Trigger 或 Durable Object 替代)
  let result: any
  while (true) {
    const status = await fetch(
      `https://api.assemblyai.com/v2/transcript/${id}`,
      { headers: { Authorization: apiKey } }
    )
    result = await status.json()
    if (result.status === 'completed') break
    if (result.status === 'error') throw new Error(result.error)
    await new Promise((r) => setTimeout(r, 3000))
  }
 
  return {
    text: result.text,
    utterances: result.utterances ?? [],
    chapters: result.chapters ?? [],
  }
}

Deepgram

主打低延迟,支持流式识别,对长音频处理稳定:

async function transcribeWithDeepgram(
  audioBuffer: ArrayBuffer,
  apiKey: string
): Promise<string> {
  const response = await fetch(
    'https://api.deepgram.com/v1/listen?model=nova-2&language=zh',
    {
      method: 'POST',
      headers: {
        Authorization: `Token ${apiKey}`,
        'Content-Type': 'audio/mpeg',
      },
      body: audioBuffer,
    }
  )
 
  const data = await response.json()
  return data.results.channels[0].alternatives[0].transcript
}

Deepgram 同时支持 WebSocket 流式识别,适合实时字幕、直播转写等场景。

选型建议

场景推荐方案理由
快速验证 / 短音频Workers AI Whisper零配置,集成简单
通用转写OpenAI Whisper API质量稳定,支持时间戳
会议记录AssemblyAI说话人分离 + 自动章节
实时 / 流式DeepgramWebSocket 流式识别

4. 支持的音频格式与预处理

各方案支持的格式

  • Workers AI Whisper:WAV、MP3,对 WebM 支持不稳定
  • OpenAI Whisper API:MP3、WAV、M4A、FLAC、OGG、WebM,25MB 以内
  • AssemblyAI:MP3、WAV、M4A、FLAC、OGG、WebM、AAC
  • Deepgram:MP3、WAV、M4A、FLAC、OGG、WebM、AAC、AMR

大部分 ASR 模型在 16kHz 单声道 WAV 上效果最好。其他格式通常能自动处理,但如果遇到识别率下降的情况,可以先转成这个格式再送进去。

格式转换

Workers 里跑不了 ffmpeg。格式转换有几条路:

  1. 客户端转换:浏览器有 Web Audio API + MediaRecorder,可以录制为 WebM 或 WAV
  2. 外部转换服务:用 Cloud Run / Lambda 起一个 ffmpeg 服务,Workers 调接口
  3. 要求用户上传标准格式:在产品层面约束输入格式,把转换成本推给客户端

如果客户端可以介入,推荐在浏览器端完成格式转换和采样率调整:

// 浏览器端代码:用 Web Audio API 重采样为 16kHz 单声道
async function resampleTo16k(audioFile: File): Promise<AudioBuffer> {
  const arrayBuffer = await audioFile.arrayBuffer()
  const audioCtx = new AudioContext({ sampleRate: 16000 })
  return audioCtx.decodeAudioData(arrayBuffer)
}
 
// 拿到 AudioBuffer 后,用 OfflineAudioContext 或第三方库编码为 WAV Blob
// 也可以用 lamejs 编码为 MP3,recorder.js 导出为 WebM

WAV 文件头是固定格式的 44 字节结构——采样率、位深、声道数写在固定偏移。如果不想手写文件头,可以用 audiobuffer-to-wav 这类轻量库在浏览器端完成编码。

5. 长音频分段处理

大部分 ASR API 有文件大小限制(OpenAI 25MB、Workers AI 更严格)。超过限制的音频需要分段转写再合并。

async function transcribeLongAudio(
  chunks: ArrayBuffer[],
  apiKey: string
): Promise<string> {
  const results: string[] = []
 
  for (const chunk of chunks) {
    const text = await transcribeWithOpenAI(chunk, apiKey)
    results.push(text.text)
  }
 
  return results.join('\n\n')
}

分段的难点在 Workers 环境里——没有 ffmpeg 就不能按音频帧边界切分,按字节硬切会破坏文件结构。实际做法:

  1. 客户端分段:浏览器端用 Web Audio API 按时间段切分,每段独立编码后上传
  2. 预上传到 R2:客户端把完整音频存到 R2,Worker 触发外部处理服务(Cloud Run + ffmpeg)分段,再逐段调 ASR
  3. 用户端预处理:要求用户在上传前先压缩(录制时用低码率),尽量让文件落在 25MB 以内

分段转写合并后,如果需要保持时间戳连续性,每段的 segment.startsegment.end 要加上前面所有段的累计时长。这个偏移量取上一段最后一个 segment 的 end 即可。

6. 输出格式化与后处理

带时间戳的字幕输出

Whisper API 的 verbose_json 返回 segment 级时间戳,可以转成 SRT 字幕格式:

interface Segment {
  start: number
  end: number
  text: string
}
 
function formatSRT(segments: Segment[]): string {
  return segments
    .map((seg, i) => {
      const start = formatTime(seg.start)
      const end = formatTime(seg.end)
      return `${i + 1}\n${start} --> ${end}\n${seg.text.trim()}\n`
    })
    .join('\n')
}
 
function formatTime(seconds: number): string {
  const h = Math.floor(seconds / 3600)
  const m = Math.floor((seconds % 3600) / 60)
  const s = Math.floor(seconds % 60)
  const ms = Math.round((seconds % 1) * 1000)
  return `${pad(h)}:${pad(m)}:${pad(s)},${pad(ms, 3)}`
}
 
function pad(n: number, digits = 2): string {
  return String(n).padStart(digits, '0')
}

转写文本清理

ASR 输出通常带有口语化特征:重复词(口吃)、填充词(嗯、啊、那个)、缺少标点。可以做基础清理:

function cleanTranscription(text: string): string {
  return text
    // 去除重复词(口吃)
    .replace(/(\S+)\s+\1+/g, '$1')
    // 去除常见填充词
    .replace(/\s*(嗯||那个|就是|然后|这个|呃)\s*/g, '')
    // 合并连续空格
    .replace(/\s{2,}/g, ' ')
    .trim()
}

标点缺失的转写结果,可以交给 LLM 补全:

async function addPunctuation(
  text: string,
  apiKey: string
): Promise<string> {
  const response = await fetch(
    'https://api.openai.com/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        messages: [
          { role: 'system', content: '为以下 ASR 转写文本添加标点符号。保持原文内容不变,不增删文字。' },
          { role: 'user', content: text },
        ],
      }),
    }
  )
  const data = await response.json()
  return data.choices[0]?.message?.content ?? text
}

说话人分离

Workers AI Whisper 不支持说话人分离。实现这个功能有几个方向:

  1. AssemblyAI:原生支持 speaker_labels,返回每个 utterance 的说话人编号(见第 3 节代码)
  2. Google Speech-to-Text:设置 enableSpeakerDiarization: true,支持指定说话人数量
  3. 自建后端:部署 pyannote-audio 等专用说话人分离模型
  4. Whisper 时间戳 + 间隔检测:根据 segment 之间的静音间隔粗略切分发言人——这不是严格的说话人分离,但在两人对话且交替发言的场景下可以作为简易替代方案

7. 完整示例:上传音频 → 转文本 → 返回结果

把前面讨论的各个部分串起来,做一个完整的接口:

import { Hono } from 'hono'
 
type Bindings = {
  AI: Ai
  BUCKET: R2Bucket
  ASR_PROVIDER: 'workers-ai' | 'openai'
  OPENAI_API_KEY: string
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.post('/api/transcribe', async (c) => {
  const body = await c.req.parseBody()
  const file = body['file'] as File
 
  if (!file) {
    return c.json({ error: '缺少音频文件' }, 400)
  }
 
  // 校验文件格式
  const allowedTypes = [
    'audio/mpeg',
    'audio/wav',
    'audio/mp4',
    'audio/x-m4a',
    'audio/webm',
  ]
  if (!allowedTypes.includes(file.type)) {
    return c.json({ error: `不支持的格式:${file.type}` }, 400)
  }
 
  // 校验文件大小(25MB)
  const maxSize = 25 * 1024 * 1024
  if (file.size > maxSize) {
    return c.json({ error: '文件超过 25MB 限制' }, 400)
  }
 
  const audioBuffer = await file.arrayBuffer()
 
  // 根据配置选择转写方案
  let text: string
  let segments: Segment[] | undefined
 
  if (c.env.ASR_PROVIDER === 'workers-ai') {
    text = await transcribeWithWorkersAI(audioBuffer, c.env.AI)
  } else {
    const result = await transcribeWithOpenAI(
      audioBuffer,
      c.env.OPENAI_API_KEY
    )
    text = result.text
    segments = result.segments
  }
 
  // 清理口语化表达
  const cleanedText = cleanTranscription(text)
 
  return c.json({
    text: cleanedText,
    segments,
    metadata: {
      originalSize: file.size,
      mimeType: file.type,
      provider: c.env.ASR_PROVIDER,
    },
  })
})
 
export default app

这个接口做了格式校验、大小限制、方案选择和结果清理。如果音频超过 25MB,返回 400 让客户端先分段再上传。

总结

回顾这篇的要点:

  • Workers 环境做音频转文本,方案归结为 Workers AI 内置模型和外部 ASR API 两类
  • Workers AI Whisper 集成简单,适合短音频快速验证;OpenAI / AssemblyAI / Deepgram 各有适用场景
  • 音频格式转换在 Workers 里做不了,建议在客户端或外部服务完成
  • 长音频分段处理是工程难点——Workers 不能按帧切分,需要客户端或外部服务配合
  • 带时间戳的输出可以生成 SRT 字幕,转写文本可以做口语化清理和标点补全
  • 说话人分离需要 AssemblyAI、Google Speech-to-Text 或自建后端

下一篇讲文件存储——上传的音频和生成的文本存到哪里、怎么管理。