音频转写接口的上传与供应商解耦实践
文章用 fetch 和 FormData 上传音频,强调由浏览器自动生成 multipart boundary,避免手动设置 Content-Type 导致解析失败。服务端通过适配层统一转写结果,并将上传鉴权、任务记录、转写和后续分析拆开,必要时采用异步任务。
AI 深度解读
音频转文字,前端用 fetch 上传文件并不难。真正麻烦的是:上传超时后要不要重试、录音格式不被支持怎么办、换一家语音服务是否要重改业务。作者给出的答案是,把上传、转写和代码评审拆开,让浏览器只面对自己的 REST API。
这篇值得读的地方,是把「能调用语音识别」推进到「能稳定接进产品」。不过,它提供的是架构建议、示例代码和测试方案,没有给出多家服务的实测结果,不能当成选型报告。
上传成功和转写完成,应该是两件事
原文的场景是教育产品:学生提交一段录音,解释自己为什么这样修改代码;系统先转成文字,再交给代码评审流程。
这其实是三个步骤:认证并接收文件、执行转写、根据文本生成评审结果。把它们塞进一个同步请求,短录音可能够用,录音变长或遇到排队,请求就容易卡住。拆开后,评审流程只依赖文本,语音服务可以独立更换。
作者建议浏览器提交 Blob,拿到任务标识,再查询标准化状态。下面的代码解决文件上传和任务返回的问题:
type TranscriptionJob = {
id: string;
status: "queued" | "running" | "completed" | "failed";
text?: string;
error?: { code: string; message: string };
};
export async function uploadAudio(file: Blob, filename = "answer.webm"): Promise<TranscriptionJob> {
const form = new FormData();
form.append("audio", file, filename);
const response = await fetch("/api/transcriptions", {
method: "POST",
body: form,
credentials: "include",
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
return response.json() as Promise<TranscriptionJob>;
}
这里最该看的是 body: form 和 AbortSignal.timeout(30_000)。前者让浏览器自动生成 multipart 请求及其 boundary,也就是分隔各个表单部分的标记;不要自行填写 multipart 的 Content-Type,否则可能缺少匹配的 boundary,服务端无法解析。
后者限制的是这次上传请求等待的时间,不保证转写在 30 秒内完成。
上传完成,只能说明文件提交这一步完成,不能等同于已经拿到转写结果。
服务端可以立即转交文件,也可以先存储再交给 worker。原文没有提供任务查询接口,也没有实现队列和存储,因此这段代码只是客户端契约的起点。
最容易出错的地方,发生在识别开始之前
文件名可能缺失,也可能带路径分隔符;浏览器录出 audio/webm,下游却不支持对应容器;反向代理可能在请求进入应用前就拦下大文件。用户遇到超时再次点击,还可能创建两份转写任务。
这些都不是识别准确率问题。
作者把防线放在服务端:检查声明的媒体类型、字节数和文件名,必要时检查文件签名;只接受当前适配器确实支持的格式;存储时使用不透明的对象键,不信任客户端传来的路径。
重试则需要幂等键(idempotency key)。同一份提交携带相同键再次到达时,服务端应返回原任务,而不是再启动一次识别。请求标识用于追踪,幂等键用于避免重复创建,两者不能混用。
不过,示例上传函数没有传入幂等键。键由谁生成、放在哪里、如何与任务创建保持一致,原文没有说清,实际接入时必须补齐。
测试也应围绕这条边界展开:发送真实 multipart 请求,覆盖错误媒体类型、刚好达到字节上限和略超上限的文件,确认非法请求在调用供应商前就被拒绝,任务表里没有残留的虚假任务。再模拟客户端超时,使用相同幂等键重试,检查是否返回原任务。
长录音还需要先保存文件、持久化状态,再入队处理。作者把这种额外状态视为恢复处理和执行保留策略的代价。但要区分:存储加队列能帮助任务恢复,上传断点续传是否可用,原文并未给出实现。
换供应商的成本,取决于业务看见了多少细节
作者提出的适配器(adapter),就是把各家服务的认证、字段、轮询方式和返回结构,转换成应用内部统一的接口。业务只接收 Transcript,不直接处理供应商响应。
下面这段代码展示了转写和评审如何通过统一接口衔接:
type AudioInput = {
bytes: Uint8Array;
contentType: string;
filename: string;
};
type Transcript = {
text: string;
language?: string;
segments?: Array<{ startMs: number; endMs: number; text: string }>;
};
interface SpeechAdapter {
transcribe(input: AudioInput, signal: AbortSignal): Promise<Transcript>;
}
export async function reviewSpokenPatch(
adapter: SpeechAdapter,
audio: AudioInput,
signal: AbortSignal,
): Promise<{ transcript: Transcript; findings: string[] }> {
const transcript = await adapter.transcribe(audio, signal);
const findings = await reviewCodeExplanation(transcript.text, signal);
return { transcript, findings };
}
async function reviewCodeExplanation(text: string, signal: AbortSignal): Promise<string[]> {
const response = await fetch("/internal/code-review", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal,
});
if (!response.ok) throw new Error("Code review request failed");
return response.json() as Promise<string[]>;
}
关键是 reviewCodeExplanation(transcript.text, signal):评审接口只拿文字,不需要知道录音由谁识别。这里发送的是 JSON,因此设置 content-type: application/json 与前面的 multipart 上传并不冲突。
公共结构也要克制。作者建议从文本和可选分段开始,只有产品确实需要、并且有测试时,才加入更多字段。否则,把每家的时间戳、说话人区分等特性全部塞进公共类型,会让更换服务越来越困难。
选型方面,原文建议用同一批五分钟录音,比较 AWS Transcribe、Google Cloud Speech-to-Text、Azure AI Speech,再加一个本地或自托管候选。除了识别质量,还要检查容器支持、最长输入时长、区域限制、重试行为,以及词级或分段时间戳经过适配器后是否保留。
这是建议执行的测试矩阵,不是作者已经完成的横评。原文没有准确率、延迟或价格对照数据。
我的判断
我赞成把供应商细节挡在服务端。对已有代码评审流程的团队,这能让语音输入成为一个独立入口,避免更换识别服务时连评审接口一起修改。
但统一类型不等于完整的可替换性。SpeechAdapter 隐藏了调用差异,各家支持的格式、区域和时长仍然不同;AudioInput 使用 Uint8Array,也没有展示大文件如何读取和传递。这套代码适合说明边界,距离长录音的完整处理链路还有距离。
作者反对浏览器直连供应商,理由涉及凭据、滥用控制和数据保留,方向有道理。不过,原文没有比较受限授权或临时凭据方案,不能据此认定所有直连设计都不合理。团队需要结合授权方式、数据路径和运维成本取舍。
短录音、规模有限的业务,也未必需要一开始就上完整队列。原文本身认可同步端点的适用范围。更稳妥的做法是先保住应用接口,再按录音长度和失败恢复需求增加任务状态。这些属于架构经验,原文没有对照实验来证明收益大小。
能拿来做什么
- 在前端录音上传中直接使用
FormData,保留文件名,让浏览器生成 multipart 请求头;界面分别表达上传状态和转写状态,避免把 30 秒请求超时显示成识别失败。 - 在 Node 服务端先完成格式、大小和文件名校验,再调用识别服务;补齐示例缺少的幂等键传递和去重逻辑,用超时后重复提交验证是否返回同一任务。
- 为语音入口定义最小
Transcript,让代码评审接口只依赖文本;选型时使用同一批五分钟录音测试三家托管服务和一个本地候选,记录格式、区域、重试和时间戳差异。 - 上线前覆盖空文件、上传断连、适配器超时、供应商限流、畸形 JSON 和 worker 重启;日志记录 request id、job id、队列等待及处理耗时,分开统计上传与转写失败,不把音频或转写全文写进普通请求日志。