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 上做音频转文本,方案归结为两类:
- Workers AI:Cloudflare 内置的 Whisper 模型,集成最简单
- 外部 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 的 fs 和 stream 模块,在 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 | 说话人分离 + 自动章节 |
| 实时 / 流式 | Deepgram | WebSocket 流式识别 |
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。格式转换有几条路:
- 客户端转换:浏览器有 Web Audio API + MediaRecorder,可以录制为 WebM 或 WAV
- 外部转换服务:用 Cloud Run / Lambda 起一个 ffmpeg 服务,Workers 调接口
- 要求用户上传标准格式:在产品层面约束输入格式,把转换成本推给客户端
如果客户端可以介入,推荐在浏览器端完成格式转换和采样率调整:
// 浏览器端代码:用 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 导出为 WebMWAV 文件头是固定格式的 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 就不能按音频帧边界切分,按字节硬切会破坏文件结构。实际做法:
- 客户端分段:浏览器端用 Web Audio API 按时间段切分,每段独立编码后上传
- 预上传到 R2:客户端把完整音频存到 R2,Worker 触发外部处理服务(Cloud Run + ffmpeg)分段,再逐段调 ASR
- 用户端预处理:要求用户在上传前先压缩(录制时用低码率),尽量让文件落在 25MB 以内
分段转写合并后,如果需要保持时间戳连续性,每段的 segment.start 和 segment.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 不支持说话人分离。实现这个功能有几个方向:
- AssemblyAI:原生支持
speaker_labels,返回每个 utterance 的说话人编号(见第 3 节代码) - Google Speech-to-Text:设置
enableSpeakerDiarization: true,支持指定说话人数量 - 自建后端:部署 pyannote-audio 等专用说话人分离模型
- 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 或自建后端
下一篇讲文件存储——上传的音频和生成的文本存到哪里、怎么管理。