实时转录结果会先以不断变化的草稿形式到达,随后才是不再改变的定稿行。将草稿渲染为可替换的尾部、将定稿行作为历史记录,字幕视图的其余部分就只是管道代码。
实时会话是 VocalStack API 最精简的功能。您的服务器发起一次 HTTP 调用,拿回一个带签名的 streamUrl。客户端打开一个指向该 URL 的 WebSocket,在采集音频时持续发送原始音频,并以 JSON 文本帧的形式读回转录结果,延迟仅比说话人慢片刻。音频结束时,您发送 eof,服务器完成剩余处理、汇报本次会话的测量数据,然后关闭连接。
不会存储任何内容。转录文本只存在于传输过程中,这正是字幕视图、会议叠加层或您自有应用中语音功能所需要的。如果您希望事后保存、分享或导出这些文字,应当改用实时转录加直播,最后一节会介绍具体做法。
这种结构与大多数流式语音 API 相同:通过 HTTP 进行凭证交换,一条长连接让音频流入、JSON 流出,并且有某种方式区分临时文本与最终文本。不同 API 的名称各不相同,但核心组件几乎一致。以下按顺序列出:
POST /v1/realtime/sessions。返回结果为 { sessionId, streamUrl, expiresAt }。streamUrl 的 WebSocket。session.loading 心跳,直到收到 session.ready。transcript.draft(替换尾部)和 transcript.committed(追加一行定稿)。{"type":"eof"},然后读取 session.done。只有第 1 步需要您的密钥,因此它在您的服务器上完成。
在 Developers, API keys 中创建一个具有 Write 权限的密钥。密钥专供服务器和脚本使用:API 会拒绝来自网页或 URL 中的密钥,因此您应当在自己的路由后面创建会话,页面只会拿到 streamUrl。
该 URL 自带令牌,原因是浏览器的一项限制,这一点值得一般性地了解。浏览器的 WebSocket 构造函数无法设置请求头,因此没有可以放置密钥的 Authorization 头;所有面向浏览器的语音 API 都是用短期令牌来对 Socket 进行身份验证,要么放在 URL 中,要么塞进子协议名称里。短期有效是关键:URL 会出现在日志和历史记录中,所以其中携带的内容必须会过期。请在 expiresAt 之前完成连接。
SDK 从开发者专区下载,而不是从包注册表获取;快速入门的安装部分列出了本环境专用的命令。使用 TypeScript SDK 时,服务器端路由如下:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
不使用 SDK 的话,只需一次 fetch。下面这个示例还请求了西班牙语,并关闭了句子组装,与后文的线路示例保持一致:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
每个选项都有默认值。开始时最值得了解的是 spokenLanguage(省略则自动检测语言)、translate(目标语言列表)、speakerLabels 和 latencyMode;其余选项会在下文需要时再介绍。
将 streamUrl 交给负责采集音频的程序。从这里开始,就不再涉及任何密钥了。
按原样连接 streamUrl,不附加任何请求头。会话启动期间,服务器会发送 session.loading 心跳,随后发出 session.ready。在此之前不要发送任何音频。过早发送的音频不会留待之后处理;服务器会将其视为错误,发送一个 error 帧并关闭 Socket。
WebSocket 打开之后,所有失败都以该帧的格式呈现:{"type":"error","code":"…","message":"…","retryable":true|false}。code 是封闭列表(invalid_audio、invalid_options、invalid_token、capacity_exceeded、session_expired、slow_consumer、engine_unavailable、internal),message 供您写入日志,retryable 则表示是否值得重新创建会话再试。请从第一帧起就处理它:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
原始客户端还需要自行处理另外两种情况,SDK 已经替您处理好了。第一,升级请求被拒绝时,会附带 API 常规的 HTTP 错误信封:401 invalid_token、410 session_expired,或者带有 Retry-After 头的 503 unavailable,需要遵守其中的等待时间。
第二,服务器过载时表现为沉默,而不是报错。如果 30 秒内没有收到任何帧,请将其视为 capacity_exceeded,然后重新连接;通常新会话能够连上。如果加载帧持续到达,但 session.ready 始终不出现,则将其视为 engine_unavailable,这同样可以重试。在 SDK 中,RealtimeSession.connect 会在收到 session.ready 时返回,若超过 readyDeadlineMs 仍未就绪,则以对应的错误码失败。
一旦收到 session.ready,服务器就会按语音的实际节奏接收音频。
音频以原始 PCM 二进制帧的形式发送:32 位浮点数、小端序、16 kHz、单声道(pcm_f32le,即会话的默认 audio 格式)。采集到音频后即刻发送,每块约 100 到 250 毫秒,无需等待两块之间的转录结果。
字节数值得算清楚一次:16,000 个采样每秒,每个采样 4 字节,即每秒 64,000 字节,因此 100 毫秒为 6,400 字节,250 毫秒为 16,000 字节。语音模型通常基于 16 kHz 单声道训练,这也是许多实时 API 要求此格式的原因;而麦克风和浏览器通常以 44.1 或 48 kHz 采集,且常为立体声。在字节发出之前,必须有人对音频进行重采样,并将声道合并为一个:这里使用的是 ffmpeg,在浏览器中则由 SDK 的麦克风辅助工具完成。如果以 48 kHz 发送音频却声明为 16 kHz,不会报错,但转录出来的将是慢动作般的语音,而且效果很差。
在 Node 中,ffmpeg 是最简单的采集设备:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.send 从不阻塞,只会排队。bufferedAmount 是该队列的大小,在链路健康时通常接近于零。实时音频无法暂停,因此面对不断增长的队列,正确的做法是及时发现它,而不是丢弃帧。服务器同样会施加背压:它最多只提前接收几秒钟的音频(limits.audioBufferS),之后便暂停读取,直到追上进度。反向来看,定稿在排队时不会丢失,而草稿会被合并,因此读取较慢的客户端会收到更少但更新的草稿。若客户端即使跟上了定稿也依然无法应付,服务器会发送 slow_consumer 的 error 帧,且 retryable: false,随后断开连接;如果您看到这一情况,说明您的消息处理程序做了太多工作。
在浏览器中,@vocalstack/sdk/mic 中的 streamMicrophone 会请求麦克风权限,重采样至 16 kHz,并连同 clock 帧一起发送音频块。clock 帧使服务器在浏览器节流标签页时能够标记间隔。页面从您的路由获取会话,且永远看不到密钥:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
render(committed, tail) 这个签名就是整个界面的模式,下一节将解释原因。
有两种帧类型共同构建转录文本,混淆二者是最常见的错误。
首先,为什么会有两种。语音模型判断一个词是什么,部分依赖于它后面的词:"I scream" 和 "ice cream" 在句子继续之前听起来是一样的。因此,实时识别器会先给出一个早期猜测,随着更多音频到达再加以修正,直到听到足够多才定下来。所有流式语音 API 都以某种方式体现这一点。有些将猜测称为"中间"或"部分"结果,并用 is_final 标记已定稿的部分;叫法各不相同,但原则是一致的。在这里,猜测称为草稿,定稿的文本称为定稿。
transcript.draft 是实时尾部:自上一次定稿以来听到的词语,仍在不断变化。每个草稿都会完整替换前一个。应使用其 segments 重绘尾部,绝不能追加。草稿按设计不带 seq,因此不要试图将其与最终成为的定稿进行匹配。
transcript.committed 是定稿:一个 segment,包含 seq(0、1、2,连续无间断)、start、end、text、speaker,以及带有逐词时间戳的 words。将其追加,并丢弃它所覆盖的草稿;下一个草稿从该定稿结束的地方开始。
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
如果 UI 追加草稿,就会显示重复且闪烁的文字,您的字幕视图会出现说话人本身并不存在的卡顿。正确的做法是渲染所有定稿片段,再在其后渲染最新草稿,并在定稿到达时清除草稿。定稿中可能包含从未出现在任何草稿中的词语。
下面是上文 fetch 示例中几秒钟语音的线路数据(translate: ["es"],关闭句子组装):
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
这两种帧前后还有另外两种。transcript.amended 会原地修正一个已定稿的片段(seq 相同,text 或 speaker 为新值),当其中某个词被修正时发送;应将其应用到您已经绘制的那一行。transcript.words 会在尾部定稿前不久,流式发送该尾部的词级时间戳。它需要 events.words,此项默认开启;定稿始终内嵌 words,无论事件选项如何设置。最后,请忽略您不认识的帧类型:新类型会在不另行通知的情况下添加。
下面是 Python SDK 的读取循环,以终端作为字幕视图。这里同样关闭了句子组装,因此翻译以 transcript.translated 的形式到达,而 error 帧会作为 VocalStackError 从循环中抛出:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
如上所示,按片段翻译是最简单的形式。读者更偏好的形式,将在单独一节中介绍。
translate: ["es", "fr"] 会让会话过程中请求翻译。翻译的到达方式取决于句子组装(sentences: { enabled: true }),该选项默认开启。
关闭句子组装时,每个定稿之后不久都会跟随一个 transcript.translated 帧,其中包含对应的 seq,以及以语言为键的 translations;将其附加到该片段上。前面的示例都是这样做的。这种方式简单,读起来像一份定稿日志:一个片段对应一个翻译,句子边界出现在说话人换气的地方。
开启句子组装时,服务器会将定稿组装成完整的句子并翻译整句,阅读体验好得多。每个 transcript.sentence 帧都会完整替换某一个句子,以 sentenceId 为键。其 status 从 open 变为 closed(或 dropped,表示应丢弃),其 translations 在句子开放时为临时结果,句子关闭后即为最终结果。按 sentenceId 进行插入或更新,绝不追加。TypeScript SDK 为此提供了 applySentence:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
let sentences: SentenceSegment[] = [];
for await (const ev of session) {
if (ev.type !== 'transcript.sentence') continue;
sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`);
}
翻译会比转录稍晚一两秒。如果您还希望将其朗读出来,可以添加 voice: ["es"](需要开启句子组装)。每个已关闭句子的西班牙语随后会以 transcript.voice 帧到达,内含 base64 编码的 Ogg/Opus 音频片段,采样率 24 kHz、单声道。过长的句子会分段到达(parts 中的各个 part),以便连续播放。临时文本绝不会被朗读。实时翻译指南详细介绍了语音功能,而延迟都花在哪里则解释了这段延迟的整条链路。
目标语言并非在创建时固定。
session.update 文本帧会从该时刻起更改 spokenLanguage、translate、speakerLabels、latencyMode、voice 或 voiceByLang;省略的字段保持不变。
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK 将其暴露为 session.update(…)。translate 会替换整个列表,传入 [] 则关闭翻译。已交付的片段不会被重新转录或重新翻译;如果开启说话人标签,则只从该时刻起为说话人加标签。已经说过的内容仍然保持原样,这些设置只影响之后的内容。
音频结束时,发送 {"type":"eof"}(SDK 中为 session.eof())。服务器会转录其仍持有的内容,发送最后的定稿,然后发送 session.done 并关闭连接。若改为发送 cancel,则会立即停止,并丢弃剩余内容。
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason 为 eof、max_duration(会话达到套餐允许的最长时长,并执行相同的收尾流程)或 cancelled。stats 由服务器测量:audioS 是您发送的音频时长,processingS 是会话运行的挂钟时间,realtimeFactor 是两者之比。实时会话的结果略低于 1,因为挂钟时间包括您发送 eof 之后的最终收尾。若该数值明显低于此值,说明会话耗时远超音频本身的时长;首先应检查的是音频到达的速度。对于文件,同一数值表示转录结果以多少倍于播放的速度返回。
会话时长上限为 GET /v1/realtime/capabilities 返回的 limits.maxDurationS。其音频按您套餐的实时费率消耗额度,并与任何转录一样有 5 分钟的最低计费时长:每个接收到音频的会话至少计费 5 分钟(价格)。同一个 Socket 也可以处理整个文件:创建会话时指定 input: { kind: "file" },发送文件本身的字节(mp3、wav、flac、ogg、m4a)以及 eof,转录结果会以远快于播放的速度返回,并在过程中伴随 transcript.progress 帧。
会话无法恢复。连接断开后会话即告结束;重连意味着创建一个新会话,它需要单独计费最低时长,并从您的实时源重新推流,seq 从 0 重新开始。重新连接本身是任何 Socket API 的标准模式,值得一次性做对:
error 帧中的 retryable: true、带 Retry-After 的 503,或 30 秒内没有任何帧(即代表 capacity_exceeded)。invalid_token 和 session_expired 意味着需要从您的服务器获取新的 streamUrl,而不是用旧的 URL 再次尝试。让创建会话的路由保持低成本调用,并将连接关闭视为一次全新的开始。不保留会话恢复能力,是不存储任何内容的代价;如果转录文本必须比 Socket 存活得更久,就需要自行保存。
如果您希望将同一条流存为录音、事后校正、导出,并让不同语言的听众收听,请改为发布到实时转录。直播指南演示了如何通过转录和直播链接使用同一条 PCM Socket,收听指南则介绍订阅端,其中每位听众可以选择语言,晚加入的听众可通过 lastSeq 游标追赶进度。关于这在一个房间里的效果,请参阅一个链接,所有语言。
若需要保存文件,存储型路由请参阅用 API 转录文件。完整的帧参考(包括每一个字段)位于 API 参考中的麦克风推流指南。