语音留言的链路乍看简单:录音、上传、播放、转写。但每一步都有不明显的坑:MediaRecorder 的跨平台 MIME 兼容、WebM duration 写不回文件头、私有 Blob 没有公开 URL 可传给 ASR、DashScope 的 data: URL 限制和默认同步模式。记录完整的实现过程和三层根因的排查路径。
这个系列的前两篇写的是「机器读文字」:第一篇是给文章页加「听全文」按钮,用阿里云 NLS 合成通用音色;第二篇是给留言板的文字留言加朗读,用 CosyVoice 声音复刻,让每条留言用写作人自己的声音读出来。
这篇写的是一个反向链路:「用户录声音,机器转文字」,也就是语音留言。
语音留言的技术链路乍看简单:录音 → 上传 → 播放 → 转写。但每一步都有不明显的坑,而且几个坑叠在一起,调试起来需要逐层排除。整个实现过程完整记录如下。
浏览器端录音的标准 API 是 MediaRecorder。用法直观:
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(chunks, { type: "audio/webm;codecs=opus" });
// 上传 blob
};
recorder.start();但 MIME(Chen 注:Multipurpose Internet Mail Extensions,互联网媒体类型标准。用 `类型/子类型` 的格式描述数据格式,如 `audio/webm`、`image/jpeg`。浏览器和服务器通过 MIME 类型协商文件格式,MediaRecorder 的 `mimeType` 参数告诉浏览器用哪种格式封装录音数据。) 类型支持因浏览器而异:
audio/webm;codecs=opus(Chen 注:WebM 是 Google 主导的开放容器格式(`.webm`),Opus 是其配套的音频编解码器,高压缩率、低延迟,特别适合实时录音场景。`codecs=opus` 是 MIME 参数,告诉浏览器在 WebM 容器里用 Opus 编码音频轨道。)audio/mp4,完全不支持 WebM所以需要运行时检测:
export function getSupportedAudioMimeType(): string {
if (typeof MediaRecorder === "undefined") return "";
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg",
];
return candidates.find((t) => MediaRecorder.isTypeSupported
依次尝试,取第一个支持的。iOS Safari 会跳过前三个,选到 audio/mp4;Chrome 取第一个。服务端接收时不用区分格式,按 Content-Type 存储即可。
WebM 有一个长期存在的问题:MediaRecorder 录制出来的 WebM 文件,duration 字段通常是 Infinity 或根本没有写入。原因是 WebM 格式的时长信息存在文件头,而录制时文件头是提前写好的,录制结束后没有回填。
这会导致:
<audio> 元素不显示时长修复方式是录制完成后用 fix-webm-duration 库打一遍补丁,把实际录制时长写回文件头:
import fixWebmDuration from "fix-webm-duration";
recorder.onstop = async () => {
const durationMs = Date.now() - startTimeRef.current;
const rawBlob = new Blob(chunks, { type: mimeType });
const blob = mimeType.includes("webm")
? await fixWebmDuration(rawBlob, durationMs, { logger: false
startTimeRef.current 在 recorder.start() 时记录。MP4 格式没有这个问题,只对 WebM 做修复。
另一个容易踩的坑是 recorder.start() 的 timeslice 参数。如果不传 timeslice,ondataavailable 只在停止时触发一次,拿到完整的数据块;如果传了(比如 recorder.start(100),每 100ms 触发一次),需要自己拼接所有 chunk。这里选择不传 timeslice,停止时一次性取数据,更简单。
录音 UI 有三个阶段,用一个 VoicePhase 类型来建模:
type VoicePhase = "idle" | "recording" | "preview";idle:显示「点击开始录音」按钮recording:显示计时器和「停止」按钮,红点闪烁preview:显示播放/暂停、进度条、时长,以及「重新录制」预览阶段用 URL.createObjectURL(blob) 生成一个本地 URL 驱动 <audio> 播放,不需要先上传。组件卸载或重新录制时 revokeObjectURL 释放内存。
进度条是手动驱动的,没有用 <audio> 的原生控件:
audio.ontimeupdate = () => {
if (!audio.duration) return;
setPreviewProgress(audio.currentTime / audio.duration);
setPreviewElapsed(Math.floor(audio.currentTime));
};previewProgress 驱动一个绝对定位的进度填充 div,宽度设为 width: `${previewProgress * 100}%`。
语音文件上传到 Vercel Blob,用 access: "private" 存为私有。
const pathname = `annex-voice/${nanoid(12)}.${ext}`;
const result = await put(pathname, voiceFile, {
access: "private",
token: process.env.BLOB_READ_WRITE_TOKEN,
contentType: voiceFile.type,
addRandomSuffix: false,
});
return NextResponse.json({ pathname: result.pathname }, { status: 201 });存的是 pathname(如 annex-voice/abc123xyz789.webm),不是完整 URL。这个路径后续用于:
私有存储的好处是音频文件不会被外部直接访问到。代价是播放需要走一个服务端代理路由(具体实现见第 5 节)。
浏览器无法直接播放私有 Blob(没有 token 就是 403),所以需要一个代理路由:
// GET /api/annex/cards/[id]/voice
export async function GET(req, { params }) {
const name = await verifyGuestSession(req.cookies.get(GUEST_COOKIE_NAME)?.value);
if (!name) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const card = await getCard(id);
if
鉴权在服务端完成(验证 cookie),通过后把 Blob 的 ReadableStream 直接 pipe 给响应。get() 返回的是流,不需要先把整个文件读进内存。
前端用 AudioButton 组件播放,首次点击 fetch("/api/annex/cards/${id}/voice") 拿到 Blob,转成 object URL 缓存在 useRef,后续播放直接复用,不再发请求:
const blob = await res.blob();
const url = URL.createObjectURL(blob);
urlRef.current = url;
const audio = new Audio(url);
await audio.play();语音留言发完之后,页面会在展示时提供一个「转写」按钮,点击后调用后端 API 把语音内容转成文字,写进 card.content,之后这条留言同时有文字和语音两种形式。
选的 ASR 服务是阿里云 DashScope 的 Paraformer-v2(Chen 注:阿里达摩院开源的非自回归 ASR 模型。传统自回归模型逐 token 生成,Paraformer 用 CIF(Continuous Integrate-and-Fire)机制一次性预测整句的 token 数量,再并行解码,推理速度比自回归快约 10 倍。Paraformer-v2 是其升级版,中英文混合识别效果更好,支持带时间戳输出。),中英文混合效果不错,API 也相对简单。但整个接入过程经历了两次失败。
既然不能生成公开 URL,那能不能把音频内容直接编码进请求?把音频 buffer 转成 data:audio/webm;base64,... 传给 file_urls。
这个做法的依据是:之前给 CosyVoice 声音克隆注册音频样本时,url 字段就是传的 data: URL,实测可用。
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
const submitResp = await fetch(`${DASHSCOPE_BASE}/services/audio/asr/transcription`, {
body: JSON.stringify({
model: "paraformer-v2",
input: { file_urls: [dataUrl] },
}),
});这次返回 403,错误是:
{"code":"AccessDenied","message":"current user api does not support synchronous calls"}这条错误信息有点误导性:看起来像是说「不支持同步调用」,但实际上 403 的原因是 Paraformer 的 file_urls 字段不接受 data: URL,只接受 HTTP(S) URL。CosyVoice 的 url 字段文档里也说只接受公开地址,之前传 data: 能用可能只是个侧门,但 Paraformer 并没有开放。
正确的做法是用 Vercel Blob 的 presigned URL 功能。Blob store 是 private-only,但可以给特定文件生成一个短期的签名 URL,持有这个 URL 的任何人都可以在有效期内访问该文件,不需要 token。
// 颁发签名 token(服务端操作,需要 BLOB_READ_WRITE_TOKEN)
const validUntil = Date.now() + 5 * 60 * 1000; // 5 分钟
const signedToken = await issueSignedToken({
pathname: card.voice,
operations: ["get"],
validUntil,
token: process.env.BLOB_READ_WRITE_TOKEN,
});
// 用签名 token 生成 presigned GET URL
const { presignedUrl: audioUrl }
issueSignedToken 在服务端签发一个 delegation token,presignUrl 用这个 token 生成一个带 HMAC 签名的 CDN URL,CDN 验证签名和有效期后直接把文件返回给请求方(这里是 DashScope 的服务器)。整个过程不需要任何内容传输,也不需要上传临时文件。
这个方案跟 AWS S3 的 presigned URL 机制类似,原理是把授权信息编码进 URL 本身而不是请求头。
presigned URL 方案本身是对的,但提交任务之后 DashScope 仍然返回 403:
{"code":"AccessDenied","message":"current user api does not support synchronous calls"}这次才真正看懂了这条错误:DashScope 的 /services/audio/asr/transcription 接口既支持同步也支持异步,默认是同步模式。这个 API key 的账户没有开通同步 ASR(同步 ASR 对音频时长限制更严,需要单独申请权限),所以返回 403。
于是,改用异步(批量转写)模式,且必须加一个请求头:
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable", // ← 没有这个头,走同步模式,403
},加上这个头之后,接口返回 task_id,进入第 7 节的轮询流程。
DashScope Paraformer 的异步转写是典型的任务队列模式:提交 → 轮询状态 → 拿结果。
// 提交,拿到 task_id
const { output: { task_id: taskId } } = await submitResp.json();
// 每 2s 轮询一次,最多 25 次(约 50s)
for (let i = 0; i < 25; i++) {
await new Promise((r) => setTimeout(r, 2000));
const pollData
几个细节:
setInterval 轮询,而是在一个 Next.js API route 里用 async/await + setTimeout 等待。因为 Vercel Functions 默认超时是 300s,短音频通常能在整个流程里的一个请求里跑完。transcription_url 指向一个 DashScope 生成的 JSON 文件,需要再 fetch 一次才能拿到文本内容。card.content:转写完成后写进 Redis,之后再请求同一条卡片的转写接口,直接返回已缓存的结果,不重复调 ASR。整个语音链路从前到后:
MediaRecorder 录音,fix-webm-duration 修复 WebM 时长 bug,iOS 用 MP4 格式issueSignedToken + presignUrl 生成 5 分钟 presigned URL,传给 DashScopeX-DashScope-Async: enable 头,服务端轮询结果card.content最后踩坑记录汇总:
| 坑 | 原因 | 解法 |
|---|---|---|
| WebM 无时长 | MediaRecorder 不回填文件头 | fix-webm-duration 事后打补丁 |
put(access:"public") 500 | Blob store 是 private-only | 改用 presigned URL |
| data URL 被拒 | Paraformer file_urls 只接受 HTTP URL | presigned URL |
| 403 "does not support synchronous calls" | 默认走同步 ASR,账户没权限 | 加 X-DashScope-Async: enable |