面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
给网站加朗读功能(三):语音留言与 ASR 转写

给网站加朗读功能(三):语音留言与 ASR 转写

2026年8月2日2,2777分钟

语音留言的链路乍看简单:录音、上传、播放、转写。但每一步都有不明显的坑:MediaRecorder 的跨平台 MIME 兼容、WebM duration 写不回文件头、私有 Blob 没有公开 URL 可传给 ASR、DashScope 的 data: URL 限制和默认同步模式。记录完整的实现过程和三层根因的排查路径。


目录
  • TL;DR
  • 1. 浏览器录音:MediaRecorder 和跨平台的兼容问题
  • 2. WebM 的 duration 问题
  • 3. 录音预览界面的状态机
  • 4. 上传到私有 Blob
  • 5. 播放代理:服务端转发私有 Blob
  • 6. ASR 转写:两次迭代踩了两个不同的坑
  • 6.1 第一次:DashScope Paraformer 不接受 base64 data URL
  • 6.2 第二次:presigned URL 解决了一半
  • 6.3 第三次(是的,还有一个坑):必须加 X-DashScope-Async 头
  • 7. 异步转写的轮询
  • 总结
目录
  • TL;DR
  • 1. 浏览器录音:MediaRecorder 和跨平台的兼容问题
  • 2. WebM 的 duration 问题
  • 3. 录音预览界面的状态机
  • 4. 上传到私有 Blob
  • 5. 播放代理:服务端转发私有 Blob
  • 6. ASR 转写:两次迭代踩了两个不同的坑
  • 6.1 第一次:DashScope Paraformer 不接受 base64 data URL
  • 6.2 第二次:presigned URL 解决了一半
  • 6.3 第三次(是的,还有一个坑):必须加 X-DashScope-Async 头
  • 7. 异步转写的轮询
  • 总结
目录
  1. TL;DR
  2. 1. 浏览器录音:MediaRecorder 和跨平台的兼容问题
  3. 2. WebM 的 duration 问题
  4. 3. 录音预览界面的状态机
  5. 4. 上传到私有 Blob
  6. 5. 播放代理:服务端转发私有 Blob
  7. 6. ASR 转写:两次迭代踩了两个不同的坑
  8. 6.1 第一次:DashScope Paraformer 不接受 base64 data URL
  9. 6.2 第二次:presigned URL 解决了一半
  10. 6.3 第三次(是的,还有一个坑):必须加 X-DashScope-Async 头
  11. 7. 异步转写的轮询
  12. 总结
TTS
相关文章
  • 01
    给网站加朗读功能(二):声音复刻的实现与原理2026/07
  • 02
    给网站加朗读功能(一):从 Web Speech API 到云端 TTS 的完整实现2026/03
  • 03
    金融机构的双因素认证(八):合规倒推方案2026/07
← 上一篇开灯之后

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

TL;DR

这个系列的前两篇写的是「机器读文字」:第一篇是给文章页加「听全文」按钮,用阿里云 NLS 合成通用音色;第二篇是给留言板的文字留言加朗读,用 CosyVoice 声音复刻,让每条留言用写作人自己的声音读出来。

这篇写的是一个反向链路:「用户录声音,机器转文字」,也就是语音留言。

语音留言的技术链路乍看简单:录音 → 上传 → 播放 → 转写。但每一步都有不明显的坑,而且几个坑叠在一起,调试起来需要逐层排除。整个实现过程完整记录如下。

图1:语音留言的完整数据流
注:录音和播放走不同路径,转写是独立触发的后处理

1. 浏览器录音:MediaRecorder 和跨平台的兼容问题

浏览器端录音的标准 API 是 MediaRecorder。用法直观:

src/components/annex/ComposeForm.tsx
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` 参数告诉浏览器用哪种格式封装录音数据。) 类型支持因浏览器而异:

  • Chrome/Edge(桌面 + Android):支持 audio/webm;codecs=opus(Chen 注:WebM 是 Google 主导的开放容器格式(`.webm`),Opus 是其配套的音频编解码器,高压缩率、低延迟,特别适合实时录音场景。`codecs=opus` 是 MIME 参数,告诉浏览器在 WebM 容器里用 Opus 编码音频轨道。)
  • Safari(macOS + iOS):只支持 audio/mp4,完全不支持 WebM

所以需要运行时检测:

src/lib/imageUpload.ts
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 存储即可。


2. WebM 的 duration 问题

WebM 有一个长期存在的问题:MediaRecorder 录制出来的 WebM 文件,duration 字段通常是 Infinity 或根本没有写入。原因是 WebM 格式的时长信息存在文件头,而录制时文件头是提前写好的,录制结束后没有回填。

这会导致:

  • <audio> 元素不显示时长
  • 拖动进度条无效
  • 部分 ASR 服务处理出错

修复方式是录制完成后用 fix-webm-duration 库打一遍补丁,把实际录制时长写回文件头:

src/components/annex/ComposeForm.tsx
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,停止时一次性取数据,更简单。


3. 录音预览界面的状态机

录音 UI 有三个阶段,用一个 VoicePhase 类型来建模:

src/components/annex/ComposeForm.tsx
type VoicePhase = "idle" | "recording" | "preview";
  • idle:显示「点击开始录音」按钮
  • recording:显示计时器和「停止」按钮,红点闪烁
  • preview:显示播放/暂停、进度条、时长,以及「重新录制」

预览阶段用 URL.createObjectURL(blob) 生成一个本地 URL 驱动 <audio> 播放,不需要先上传。组件卸载或重新录制时 revokeObjectURL 释放内存。

进度条是手动驱动的,没有用 <audio> 的原生控件:

src/components/annex/ComposeForm.tsx
audio.ontimeupdate = () => {
  if (!audio.duration) return;
  setPreviewProgress(audio.currentTime / audio.duration);
  setPreviewElapsed(Math.floor(audio.currentTime));
};

previewProgress 驱动一个绝对定位的进度填充 div,宽度设为 width: `${previewProgress * 100}%`。


4. 上传到私有 Blob

语音文件上传到 Vercel Blob,用 access: "private" 存为私有。

src/app/api/annex/uploads/route.ts
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。这个路径后续用于:

  1. 播放时服务端代理
  2. 转写时生成 presigned URL

私有存储的好处是音频文件不会被外部直接访问到。代价是播放需要走一个服务端代理路由(具体实现见第 5 节)。


5. 播放代理:服务端转发私有 Blob

浏览器无法直接播放私有 Blob(没有 token 就是 403),所以需要一个代理路由:

src/app/api/annex/cards/[id]/voice/route.ts
// 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,后续播放直接复用,不再发请求:

src/components/annex/AudioButton.tsx
const blob = await res.blob();
const url = URL.createObjectURL(blob);
urlRef.current = url;
const audio = new Audio(url);
await audio.play();

6. ASR(Chen 注:Automatic Speech Recognition,自动语音识别。把音频中的语音内容转换成文字的技术,通常基于深度学习声学模型(如 Transformer)。) 转写:两次迭代踩了两个不同的坑

语音留言发完之后,页面会在展示时提供一个「转写」按钮,点击后调用后端 API 把语音内容转成文字,写进 card.content,之后这条留言同时有文字和语音两种形式。

选的 ASR 服务是阿里云 DashScope 的 Paraformer-v2(Chen 注:阿里达摩院开源的非自回归 ASR 模型。传统自回归模型逐 token 生成,Paraformer 用 CIF(Continuous Integrate-and-Fire)机制一次性预测整句的 token 数量,再并行解码,推理速度比自回归快约 10 倍。Paraformer-v2 是其升级版,中英文混合识别效果更好,支持带时间戳输出。),中英文混合效果不错,API 也相对简单。但整个接入过程经历了两次失败。

图2:两次方案演进
注:问题逐层暴露,最终用 presigned URL 解决

6.1 第一次:DashScope Paraformer 不接受 base64 data URL

既然不能生成公开 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,错误是:

DashScope 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 并没有开放。

6.2 第二次:presigned URL(Chen 注:预签名 URL。把访问授权信息(签名者身份、有效期、允许的操作)用 HMAC 签进 URL 的查询参数里,持有该 URL 的任何人都可以在有效期内访问资源,不需要额外的 Authorization 头。AWS S3、Google Cloud Storage、Vercel Blob 都支持这种机制,常用于让第三方服务临时访问私有对象。) 解决了一半

正确的做法是用 Vercel Blob 的 presigned URL 功能。Blob store 是 private-only,但可以给特定文件生成一个短期的签名 URL,持有这个 URL 的任何人都可以在有效期内访问该文件,不需要 token。

src/app/api/annex/cards/[id]/transcribe/route.ts
// 颁发签名 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 本身而不是请求头。

6.3 第三次(是的,还有一个坑):必须加 X-DashScope-Async 头

presigned URL 方案本身是对的,但提交任务之后 DashScope 仍然返回 403:

DashScope 403 响应(方案二)
{"code":"AccessDenied","message":"current user api does not support synchronous calls"}

这次才真正看懂了这条错误:DashScope 的 /services/audio/asr/transcription 接口既支持同步也支持异步,默认是同步模式。这个 API key 的账户没有开通同步 ASR(同步 ASR 对音频时长限制更严,需要单独申请权限),所以返回 403。

于是,改用异步(批量转写)模式,且必须加一个请求头:

src/app/api/annex/cards/[id]/transcribe/route.ts
headers: {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
  "X-DashScope-Async": "enable",  // ← 没有这个头,走同步模式,403
},

加上这个头之后,接口返回 task_id,进入第 7 节的轮询流程。


7. 异步转写的轮询

DashScope Paraformer 的异步转写是典型的任务队列模式:提交 → 轮询状态 → 拿结果。

图3:异步转写时序(整个轮询在服务端一个 Function 调用里完成,前端只等一个 HTTP 响应)
src/app/api/annex/cards/[id]/transcribe/route.ts
// 提交,拿到 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,短音频通常能在整个流程里的一个请求里跑完。
  • 转写结果是一个临时 URL:transcription_url 指向一个 DashScope 生成的 JSON 文件,需要再 fetch 一次才能拿到文本内容。
  • 缓存在 card.content:转写完成后写进 Redis,之后再请求同一条卡片的转写接口,直接返回已缓存的结果,不重复调 ASR。

总结

整个语音链路从前到后:

  1. 浏览器 MediaRecorder 录音,fix-webm-duration 修复 WebM 时长 bug,iOS 用 MP4 格式
  2. 上传到私有 Blob,存 pathname
  3. 服务端代理播放(鉴权后 pipe stream)
  4. 转写时用 issueSignedToken + presignUrl 生成 5 分钟 presigned URL,传给 DashScope
  5. DashScope 异步任务,加 X-DashScope-Async: enable 头,服务端轮询结果
  6. 转写结果写回 card.content

最后踩坑记录汇总:

坑原因解法
WebM 无时长MediaRecorder 不回填文件头fix-webm-duration 事后打补丁
put(access:"public") 500Blob store 是 private-only改用 presigned URL
data URL 被拒Paraformer file_urls 只接受 HTTP URLpresigned URL
403 "does not support synchronous calls"默认走同步 ASR,账户没权限加 X-DashScope-Async: enable
(t))
??
""
;
}
})
: rawBlob;
// blob 现在有正确的 duration
};
(
!
card?.voice)
return
new
NextResponse
(
"not found"
, { status:
404
});
const result = await get(card.voice, {
access: "private",
token: process.env.BLOB_READ_WRITE_TOKEN,
});
return new NextResponse(result.stream, {
headers: {
"Content-Type": result.blob.contentType ?? "audio/webm",
"Cache-Control": "private, max-age=3600",
},
});
}
=
await
presignUrl
(signedToken, {
operation: "get",
pathname: card.voice,
access: "private",
validUntil,
});
// 把 presigned URL 传给 DashScope
const submitResp = await fetch(`${DASHSCOPE_BASE}/services/audio/asr/transcription`, {
body: JSON.stringify({
model: "paraformer-v2",
input: { file_urls: [audioUrl] },
}),
});
=
await
fetch
(
`${
DASHSCOPE_BASE
}/tasks/${
taskId
}`
, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
const status = pollData?.output?.task_status;
if (status === "FAILED") {
return NextResponse.json({ error: "transcription failed" }, { status: 502 });
}
if (status === "SUCCEEDED") {
// results[0].transcription_url 是一个临时 URL,存放 JSON 格式的转写结果
const transcriptData = await fetch(transcriptionUrl).then((r) => r.json());
const transcript = transcriptData?.transcripts?.[0]?.text ?? "";
await patchCardTranscript(id, transcript);
return NextResponse.json({ transcript });
}
}
return NextResponse.json({ error: "transcription timeout" }, { status: 504 });