上传返回的是任务,而不是转录文本。请为每次创建请求都加上 Idempotency-Key,确保重试时不会创建或计费第二次转录;然后轮询任务直至完成,并通过同一个 id 读取句子、词或导出文件。
文件转录是 VocalStack API 最简单的功能,它具有任何异步任务的典型流程:上传、等待、读取。上传请求会立即返回一个转录及其任务,但此时还没有任何文字。在你的程序轮询任务期间,服务器转录文件的速度远快于音频的播放速度。当状态变为 done 时,你可以以句子的形式读取转录文本(包含说话人和时间信息),也可以以四种格式之一导出文件,导出内容可以是原语言,也可以是译文。
任何批处理 API 都遵循这一流程:创建请求返回一个 id,你轮询其状态,之后再获取结果。只有字段名是 VocalStack 特有的。本教程将结合 TypeScript 和 Python SDK,并在必要时给出 HTTP 调用,逐步讲解这些内容。
在控制台中打开 Developers → API keys,创建一个同时具有 Read 和 Write 权限的密钥。密钥只会显示一次,请将其复制到环境变量中:
export VOCALSTACK_API_KEY="<your API key>"
密钥适用于服务器和脚本。任何发布到浏览器的代码都会连同密钥一起发布,因此请将密钥置于页面代码和 URL 之外,因为 URL 最终会出现在日志中。若要从浏览器进行流式传输,应由你的服务器为其创建一个实时会话,这是另一篇教程的内容。
每个调用都使用该密钥作为承载令牌进行身份验证:
Authorization: Bearer vs_key_...
环境变量中有了密钥,下一步就是创建一个用来发送它的客户端。
我们提供两个 SDK。每个 SDK 都有一个客户端 VocalStack,其方法按资源分组:vs.transcriptions、vs.jobs、vs.recordings、vs.realtime 以及其他几个。TypeScript SDK 可在 Node 20 或更高版本以及浏览器中运行;Python SDK 基于 asyncio 构建,需要 Python 3.10 或更高版本。
这两个 SDK 不在 npm 或 PyPI 上发布。API 会向所有账户提供构建包。针对你的环境填好的安装命令,位于 API 参考中快速开始 的 Install 部分,并且它们只会将你的密钥发送到 API 主机。如果你不想安装任何东西,下面的 HTTP 调用只需要 curl 和 jq。
无论你选择哪种方式,第一个调用都是上传。
POST /v1/transcriptions 以 multipart/form-data 格式接收文件:一个 file 部分,外加一个可选的 options 部分,其中包含 JSON。它会返回 201 和转录。其中 job.recordingId 是由该文件生成的录音的 id,之后的每个调用都通过该 id 进行寻址。
对于首次运行,有两个设置很关键,两者都位于 options.settings 中:
spokenLanguage:语言代码,例如 en。若省略,则自动检测语言。指定语言可以提高嘈杂录音和口音较重时的准确率。speakerLabels:auto、on 或 off。设为 on 时,每个句子都会带有说话人编号;设为 auto 时,只有在听到第二位说话人之后才会显示标签。大多数音频和视频文件都可以接受:mp3、wav、flac、ogg、m4a、mp4 等。对于视频,只保留其中的音频。文件格式对准确率的影响很小,因为语音识别是在 16 kHz 单声道上进行的,所有音频在接收时都会重采样为该格式。一段 48 kHz 立体声 WAV,记录的是一个人说话,其大部分字节都在以高保真度描述静音。它的转录效果与同一段讲话的 64 kbps 单声道 MP3 或 Opus 文件完全相同,但体积约为后者的二十倍,因此上传耗时也会是二十倍。如果你在上传前进行转换,值得调整的两个设置是单声道和适中的比特率。
每次上传都要发送一个 Idempotency-Key,并理解其原因。网络超时并不能告诉你服务器做了什么:它可能根本没有收到请求,也可能已经存储了文件并排入了任务队列,只是应答在途中丢失。因此,盲目重试可能导致第二次转录和第二次扣费。该键可以解决这个问题。当同一请求(相同的键和请求体)在 24 小时内被重复发送时,服务器会返回第一次的应答,而不会创建任何新内容,从而将“至少一次”变为“有效一次”。所以规则是:每次逻辑上的上传使用一个键,在第一次尝试之前生成,并在每次重试时重复使用。如果每次尝试都使用新键,那么每次重试都会成为一次新的上传,该请求头也就毫无保护作用了。
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
const uploadKey = crypto.randomUUID(); // one key for this upload, however many attempts it takes
const tx = await vs.transcriptions.create(
{ file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } },
{ idempotencyKey: uploadKey },
);
const recordingId = tx.job!.recordingId;
import asyncio
import os
import uuid
from vocalstack import VocalStack
async def main() -> None:
upload_key = str(uuid.uuid4()) # one key for this upload, however many attempts it takes
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
tx = await vs.transcriptions.create(
file="./talk.mp3",
name="Talk",
settings={"spokenLanguage": "en", "speakerLabels": "on"},
idempotency_key=upload_key,
)
assert tx.job is not None
recording_id = tx.job.recording_id
使用 curl 实现同样的调用:
API={{api}}
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
UPLOAD_KEY=$(uuidgen)
REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $UPLOAD_KEY" \
-F file=@talk.mp3 \
-F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \
| jq -r .job.recordingId)
201 在一个词都还未转录时就已返回;你手中拿到的只是一个任务 id,因此下一步是等待该任务。
任务依次经过 queued → transcribing → done,或以 failed 结束,并附带一条 error 消息;当原因可以由机器识别时,还会附带 errorCode。第四种状态 cropping 表示任务被暂停,直到有人选择要转录的录音片段,如同控制台中的修剪步骤所做的那样。普通上传永远不会进入该状态,不过 jobs.wait 在任务到达该状态时也会返回。请读取 GET /v1/recordings/{id}/job,直到状态为 done 或 failed。
这里没有 webhook,这是有意的取舍。webhook 需要你这边有一个公开的 HTTPS 端点、一项用于防止他人调用的签名校验,以及端点宕机时的重试策略;对于一个只需一分钟的任务来说,这些基础设施太过繁琐。轮询则无需这些。你的脚本会每两秒问一次“好了吗?”,就像坐在后座的孩子一样不停地问“到了吗?”,不过与孩子不同的是,它每次都能拿到一个进度百分比。
已经打开的网页可以改为订阅转录的事件 socket(WS /v1/transcriptions/{id}/events,任务推进时会收到 job.progress 帧)。对于脚本而言,轮询才是合适的工具。
SDK 会替你完成轮询:vs.jobs.wait 每 2 秒读取一次任务,并将每次读取结果交给 onProgress(Python 中为 on_progress)。任务的 progress 包含 pct(0 到 100)和 processedS,即已转录的媒体秒数。下面的程序只在状态或百分比变化时输出一行。
let last = '';
const job = await vs.jobs.wait(recordingId, {
onProgress: (j) => {
const line = `${j.state} ${j.progress.pct}%`;
if (line !== last) console.log((last = line));
},
});
if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`);
last = ""
def on_progress(j) -> None:
nonlocal last
line = f"{j.state} {j.progress.pct}%"
if line != last:
last = line
print(line)
job = await vs.jobs.wait(recording_id, on_progress=on_progress)
if job.state != "done":
raise RuntimeError(f"transcription {job.state}: {job.error}")
注意,wait 遇到 failed 时不会抛出异常,而是返回任务,检查由你负责。每两秒一次的间隔适合一场演讲。对于三小时的录音或数百个文件的批次,应加大间隔(intervalMs、interval_s),或在任务运行期间逐步退避:最初的几次读取能告诉你最多信息,之后的读取大多只是确认服务器仍在忙碌。如果不使用 SDK,你可以自己手写相同的循环:
while :; do
JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH")
STATE=$(jq -r .state <<<"$JOB")
echo "$STATE $(jq -r .progress.pct <<<"$JOB")%"
case $STATE in
done) break ;;
failed) jq -r .error <<<"$JOB" >&2; exit 1 ;;
esac
sleep 2
done
当状态显示为 done 时,转录文本即已就绪。
GET /v1/recordings/{id}/transcript 会以一个文档返回整个转录文本:
sentences:要展示给用户的内容。每项包含 text、speaker,以及以秒为单位的 start 和 end。segments:引擎输出的行,每行在 words 中带有词级时间信息(每个词都有 text、start 和 end)。speakers:每个说话人编号对应一项,包含其 name。说话人编号从 0 开始。speaker 为 -1 表示该录音未开启说话人标签。添加 ?lang=es 可读取翻译成西班牙语的转录文本;句子将以该语言返回。
const { sentences } = await vs.recordings.transcript(recordingId);
for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text);
snapshot = await vs.recordings.transcript(recording_id)
for s in snapshot.sentences:
print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text)
curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \
| jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end'
两个时间层级服务于两种用途。句子是阅读的单位,因此字幕或转录页面显示的就是句子。词是定位的单位,因此卡拉 OK 式高亮、搜索命中或片段剪辑应遍历 segments[].words。导出格式都是基于句子构建的。
GET /v1/recordings/{id}/export 会返回一个真实的文件。format 为 srt、json、xlsx 或 pdf 之一,文件中包含所有更正内容。添加 lang= 可获得翻译后的文件:此时导出的内容以该语言呈现,每个句子一行,尚未翻译的句子会先被翻译。
SRT 值得认识,因为它是几乎所有播放器都支持的字幕格式,并且是纯文本。一个文件是由空行分隔的字幕条目列表,每个条目包括一个序号、一个时间范围和文本。时间戳在毫秒前使用逗号,这是一个法国式的习惯,该格式至今未改:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
每个字幕条目一个句子是最合适的粒度:每个条目都能在它显示的时间内读完,且不会在句子中间截断。如果录音带有说话人标签,每个字幕条目的文本都会以说话人的名字加冒号开头。
在 TypeScript SDK 中,vs.recordings.export 返回一个 Response;使用 arrayBuffer() 读取其字节。在 Python 中,它返回 bytes。
import { writeFile } from 'node:fs/promises';
const srt = await vs.recordings.export(recordingId, 'srt');
await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer()));
const pdf = await vs.recordings.export(recordingId, 'pdf', { lang: 'es' });
await writeFile('talk.es.pdf', Buffer.from(await pdf.arrayBuffer()));
srt = await vs.recordings.export(recording_id, "srt")
with open("talk.srt", "wb") as f:
f.write(srt)
pdf = await vs.recordings.export(recording_id, "pdf", lang="es")
with open("talk.es.pdf", "wb") as f:
f.write(pdf)
asyncio.run(main())
curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt
curl -sSf "$API/v1/recordings/$REC/export?format=pdf&lang=es" -H "$AUTH" -o talk.es.pdf
转录已被存储,因此它会出现在控制台的 Transcriptions 下,可以通过链接分享,并且之后任何时候都可以再次读取。这就是核心流程:创建、等待、读取、导出。本教程的其余部分介绍各种变化:已在线的媒体、你会遇到的错误,以及文件的费用。
如果媒体已经在线,你可以把链接交给 API,由它自行获取文件:一个公开的 http(s) 地址(mp3、视频、播客单集),或一个 YouTube 视频。调用方式与 POST /v1/transcriptions 相同,只是请求体为 JSON { "url": "..." },代替多部分上传;name 和 settings 同前。在 SDK 中,vs.transcriptions.createFromUrl(url)(TypeScript)和 vs.transcriptions.create(url=...)(Python)发出的是同一个请求。
在创建任何内容之前,GET /v1/transcriptions/probe?url= 会告诉你链接是什么。它的 kind 是以下三者之一:
file:有明确结尾的录音;在此转录。live:YouTube 直播、没有尽头的电台流或没有结尾的 HLS 播放列表。直播链接永远不会结束,因此无法作为文件转录;应将其用作实时转录的源。unknown:服务器无法判断。当链接根本无法作为媒体打开时(例如一个网页),ok 为 false,并且 problem 会用一句话解释原因。探测是可选的,且限制为每分钟 60 次调用。
链接任务会先处于 fetching 状态,然后进入 transcribing 和 done。在获取期间,progress.phase 会说明服务器正在做什么:fetching 表示正在接收字节(若对方说明了总量,还会附带 fetchedBytes 和 fetchTotalBytes),extracting 表示正在从视频中提取音频。progress.phasePct 表示该阶段已完成的百分比。长视频需要一些时间,因此每隔几秒轮询一次即可;vs.jobs.wait 为此提供了 intervalMs(Python 中为 interval_s)参数。
const probe = await vs.transcriptions.probe(url);
if (!probe.ok) throw new Error(`that link did not open: ${probe.problem}`);
if (probe.kind === 'live') throw new Error('a live link: record it with a live transcription instead');
const linkKey = crypto.randomUUID();
const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: linkKey });
const job = await vs.jobs.wait(tx.job!.recordingId, {
intervalMs: 3000,
onProgress: (j) => {
const p = j.progress;
if (j.state === 'fetching') console.log(`${p.phase ?? 'fetching'} ${p.phasePct ?? 0}%`);
else console.log(`${j.state} ${p.pct}%`);
},
});
参考文档中的转录链接指南提供了两种语言和 HTTP 的完整程序。从链接获取会增加更多的失败途径,因此下面列出你实际会遇到的错误。
每个失败的调用都会返回一个 JSON 信封。code 是用于分支判断的字符串,status 是 HTTP 状态码;message 面向用户,requestId 用于联系支持。在 data 下,retryable 表示再次发送相同请求是否可能有帮助,details 包含该错误码所需的任何信息。SDK 抛出一种错误类型 VocalStackError,其字段相同。
有三个错误码值得在第一天就处理。
402 insufficient_credit:账户额度已用完,且未产生任何扣费。details.balance 为以美元计的余额,details.topUpUrl 为添加额度的地址。若费用事先已知(例如已知长度的文件),details.required 会说明应付的金额。429 rate_limited:等待 Retry-After 秒。当 details.metric 为 creates_per_hour 时,表示你已达到套餐的创建速率(每小时新建的转录数),而非突发限制;limit 和 resetsAt 会说明上限是多少以及何时可以恢复。503 unavailable:服务暂时不可用。等待 Retry-After 后再次发送请求。SDK 会在 Retry-After 之后对 429 和 503 进行重试,默认两次(客户端上的 maxRetries / max_retries)。不过,503 只会在可以安全地发送两次的调用上重试:即 GET,或携带 Idempotency-Key 的创建请求。没有该键的创建请求则由你自行处理,因为 SDK 无法得知第一次尝试是否已成功。这正是在每次上传上都放置一个键的实际原因。
每个响应还都会附带 RateLimit-Limit、RateLimit-Remaining 和 RateLimit-Reset,因此繁忙的流水线可以在触发 429 之前放慢速度。
{
"code": "insufficient_credit",
"status": 402,
"message": "this account has no credit left; add credit to continue",
"data": {
"retryable": false,
"details": { "balance": -0.42, "required": 0.075, "currency": "usd", "plan": "payg", "topUpUrl": "https://…" }
},
"requestId": "01J…"
}
所有错误码的完整表格见 错误、重试与限制。那个 402 是唯一与金钱相关的错误,因此接下来说明一个文件的费用。
文件转录按每小时音频 $0.20 计费,并按秒计费(即每分钟 $0.0033),最低计费时长为 5 分钟:一个 30 分钟的文件费用为 $0.10。这只是十美分,还不及你等进度条走完时喝的那杯咖啡。所有套餐的费率相同,通过 API 和通过控制台的费率也一致。新账户一开始会获得一次性的 $5 注册额度,且无需绑定信用卡。
有三项套餐限制与本教程相关。
GET /v1/realtime/capabilities 报告的 limits.maxUploadBytes。两者中较小者生效,超出上限的文件会在任何转录开始之前以 413 被拒绝。对于长录音,这是又一个应上传压缩单声道文件而非 WAV 的理由。GET /v1/billing 可随时报告你的套餐、余额和限制;完整表格见定价页面。
接下来的步骤包括:当你无法等待文件处理完成时,实时流式传输音频;或者将 AI 智能体连接到同一账户,通过 MCP 服务器让它替你转录和搜索,无需上述任何代码。