文档实时转录
实时转录
通过WebSocket把麦克风或标签页音频流式发送给transcript1,边说边收到字幕行,还可选实时翻译。
实时转录会在人们还在说话时,把音频流转换成字幕行。客户端通过WebSocket发送原始PCM音频,transcript1返回字幕行:每一行先是临时结果,随后变为最终结果。加上translate_to后,每一行还会附带译文。会话结束后,结果会保存为一条普通的转录。
实时转录没有单独的套餐,使用的分钟余额与其他功能相同。连接时间如何计算,见计费。
什么时候用实时转录
| 适合用实时转录 | 适合用普通转录 |
|---|---|
| 音频仍在产生:通话、演讲、直播,或你正在看的视频。 | 你有一个链接或一个已完成的文件。 |
| 你需要在说话后一两秒内看到字幕。 | 你要最高的准确率,并且可以等待结果。 |
| 你希望每一行旁边都有实时翻译。 | 你要翻译一份已完成的转录文本,或翻译成多种语言。 |
实时转录只接受原始PCM音频,不支持视频链接、上传文件、WebM、Opus或MP3。请在客户端采集音频,并先转换成PCM。在浏览器中采集音频演示了做法。
用SDK快速开始
请安装SDK 1.5.0或更新版本:npm i @transcript1/sdk@latest。t1.live.start会创建会话并连接,会话启动后才返回,所以可以立即发送音频。
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { Transcript1 } from '@transcript1/sdk';
const t1 = new Transcript1(); // reads TRANSCRIPT1_API_KEY
const live = await t1.live.start({ language: 'en', translateTo: 'es', sampleRate: 16000 });
live.on('line', line => {
const mark = line.final ? 'final ' : 'partial';
console.log(mark, line.text, '→', line.translation?.text ?? '');
});
live.on('warning', warning => console.warn(warning.code));
// speech.pcm: 16 kHz, mono, signed 16-bit little-endian, no header
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
live.send(chunk);
await sleep(100); // 3,200 bytes are 100 ms of audio: send at real-time speed
}
const result = await live.end();
console.log(result.reason, result.transcriptionId, result.minutesCharged);行会就地更新。收到相同id的行时,替换你已有的那一行,也可以直接从live.lines读取全部行。end()会结束会话,等待最后几行,并保存转录文本。所有方法和事件见Node.js SDK。
用原生WebSocket快速开始
不用SDK时分两步:先通过HTTPS创建会话,再用返回的一次性令牌打开WebSocket。
创建会话
curl -X POST https://api.transcript1.com/v1/live/sessions \
-H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"language": "en", "translate_to": "es", "sample_rate": 16000}'API返回201:
{
"id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"status": "waiting",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"websocket_url": "wss://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD/stream",
"token": "Zk3x...",
"token_expires_at": "2026-11-02T12:01:00Z",
"rate": 1
}当save为false时,transcription_id为null。令牌只能使用一次,60秒后过期,所以请立即连接。rate表示这个会话每开始一分钟扣除多少余额分钟,见计费。
请求体支持以下字段,全部可选,所需权限范围为transcriptions:write:
| 字段 | 默认值 | 规则 |
|---|---|---|
language | auto | auto,或实时转录支持的两位语言代码。 |
translate_to | 无 | 两位语言代码。每个会话只能有一个目标语言。见实时翻译。 |
diarize | false | 标注说话人。有些组合无法标注说话人,例如与language: "auto"一起使用,会返回422。请查看支持范围。 |
audio_format | pcm_s16le | 唯一的取值。保留该字段是为了以后可以增加格式。 |
sample_rate | 16000 | 8000到48000之间的整数,仅支持单声道。 |
save | true | 设为false时,会话结束后不保留任何内容。见保存的转录。 |
连接并发送音频流
把令牌作为第二个子协议来打开WebSocket。在浏览器和Node 22及以上版本中无需自定义请求头即可使用。请等收到session.started后再发送音频:更早发送的二进制帧会被丢弃,并收到audio_dropped警告。
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
const session = await fetch('https://api.transcript1.com/v1/live/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TRANSCRIPT1_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ language: 'en', translate_to: 'es', sample_rate: 16000 }),
}).then(response => response.json());
const ws = new WebSocket(session.websocket_url, ['t1-live', session.token]);
const lines = new Map();
ws.addEventListener('message', async message => {
const event = JSON.parse(message.data);
if (event.type === 'session.started') {
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
ws.send(chunk);
await sleep(100);
}
ws.send(JSON.stringify({ type: 'end' }));
} else if (event.type === 'line') {
lines.set(event.line.id, event.line); // the full current state: replace by id
console.log(event.line.final, event.line.text, event.line.translation?.text);
} else if (event.type === 'session.ended') {
console.log('ended:', event.reason, event.minutes_charged);
}
});
ws.addEventListener('close', event => console.log('closed', event.code));Node 20没有内置的WebSocket,请使用ws包,它支持相同的new WebSocket(url, protocols)写法。
令牌有效时,服务器只会回应t1-live子协议。令牌无效也不会让握手失败:套接字会先打开,你会收到一个error事件,随后套接字以4xxx代码关闭,因此浏览器里能看到真正的原因,而不是笼统的1006。见关闭代码。
在浏览器中采集音频
实时音频必须是16位有符号小端序的单声道PCM,采样率与会话的sample_rate一致。浏览器给出的是浮点采样,需要在AudioWorklet中转换。下面的代码以16 kHz采集麦克风,每100 ms一块,并通过浏览器版SDK入口发送。getAccessToken返回OAuth访问令牌,切勿把API密钥放进浏览器。见安全。
import { createLiveClient } from '@transcript1/sdk/live';
const workletSource = `
class Pcm16Processor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Int16Array(1600); // 100 ms at 16 kHz
this.filled = 0;
}
process(inputs) {
const channel = inputs[0][0];
if (!channel) return true;
for (let i = 0; i < channel.length; i++) {
const sample = Math.max(-1, Math.min(1, channel[i]));
this.buffer[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
if (this.filled === this.buffer.length) {
this.port.postMessage(this.buffer.slice());
this.filled = 0;
}
}
return true;
}
}
registerProcessor('pcm16', Pcm16Processor);
`;
const client = createLiveClient({ getAccessToken: () => myApp.getOAuthAccessToken() });
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
const context = new AudioContext({ sampleRate: 16000 });
const moduleUrl = URL.createObjectURL(new Blob([workletSource], { type: 'text/javascript' }));
await context.audioWorklet.addModule(moduleUrl);
// the context's real rate: a browser that cannot do 16 kHz picks its own
const live = await client.start({ language: 'auto', sampleRate: context.sampleRate });
live.on('line', renderLine);
const microphone = context.createMediaStreamSource(stream);
const capture = new AudioWorkletNode(context, 'pcm16');
capture.port.onmessage = event => live.send(event.data); // an Int16Array
const mute = context.createGain();
mute.gain.value = 0;
microphone.connect(capture).connect(mute).connect(context.destination);
async function stop() {
stream.getTracks().forEach(track => track.stop());
await context.close();
return live.end();
}这个worklet是按16 kHz编写的。如果上下文以其他采样率运行,每块就不再是100 ms,但由于sampleRate与上下文一致,PCM数据仍然正确。如果要采集标签页或视频的声音而不是麦克风,请用getDisplayMedia或媒体元素的captureStream()创建MediaStream,再用同样的方式连接。
实时翻译
在会话中加上translate_to,transcript1会在说话的同时翻译每一行。
- 每个会话只有一个目标语言。之后可以用翻译接口为保存的转录文本增加更多语言。
- 每个
line都包含原文(text、final)和一个translation对象(language、text、final)。未设置translate_to时,translation为null。 - 原文和译文各自独立定稿:一行的原文可能已是最终结果,译文却仍在变化。等两者都定稿后,再把这一行显示为最终状态。
- 已经是目标语言的行,会把自己的文本当作译文,并在这一行关闭时定稿。
- 一行会在以下情况关闭:说话人停顿、说话人切换、词间停顿超过0.9秒、该行达到两行字幕(84个字符)或15秒,或者你发送了
finalize。开启翻译后,一行可能要多等约3秒才会关闭,以等待译文送达。此时原文定稿后,译文可能仍保持未定稿(final: false)并继续增长。 - 翻译免费,不消耗分钟。
可用的目标语言和语言组合取决于实时语音服务商。请读取支持范围,不要把规则写死。不支持的目标语言会返回422 unsupported_translation。例如,有的服务商要求明确指定language而不能用auto,或只允许包含英语的语言组合。
WebSocket协议
连接
new WebSocket(websocket_url, ['t1-live', token]);令牌通过WebSocket子协议传递,不会出现在URL或访问日志中。不要发送Authorization请求头,令牌就是凭据。服务器每30秒发送一次ping,浏览器和ws包会自动回应。60秒内没有pong的套接字会被断开。
如果会话已经有一个套接字,新的有效连接会替换旧连接。见恢复与接管。
客户端消息
| 消息 | 含义 |
|---|---|
| 二进制帧 | 音频:与会话sample_rate一致的PCM s16le单声道。每帧长度为偶数字节,最大256 KB。每帧约100 ms比较合适。 |
{"type":"finalize"} | 立即关闭当前行,例如视频暂停时。 |
{"type":"end"} | 结束并保存会话。服务器回复session.ended并关闭套接字。 |
| 其他任何内容 | 收到一个不致命的error事件,代码为invalid_message。 |
长度为奇数或过大的帧属于协议违规,套接字会以4008关闭。
请在session.started之后再开始发送音频。如果发送音频的速度快于实时,会按音频时长而不是连接时长计费(见计费),所以请按音频产生的速度发送。
服务器事件
每个事件都是带有type的JSON文本帧。
| 事件 | 发送时机 |
|---|---|
session.started | 连接已被接受。从现在起可以发送音频。 |
line | 一行字幕被创建或发生变化。 |
usage | 每次扣费之后。 |
warning | 需要注意的情况,但会话继续。 |
error | 不致命的问题,或立即关闭连接的原因。 |
reconnect | 服务器即将重启,请恢复会话。 |
session.ended | 会话已结束并保存。正常关闭前的最后一个事件。 |
session.started会说明这是否是一次恢复,以及会话已经处理了多少音频:
{
"type": "session.started",
"session_id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"resumed": false,
"offset_ms": 0
}line携带某一行的完整当前状态,请用它替换你手里相同id的那一行。见line对象。
{
"type": "line",
"line": {
"id": 12,
"start_ms": 53120,
"end_ms": 56040,
"speaker": null,
"language": "en",
"text": "Welcome back to the stream",
"final": true,
"translation": { "language": "es", "text": "Bienvenidos de nuevo", "final": false }
}
}usage在每次扣费后发送,包含连接时长、音频时长、目前已扣除的余额分钟数,以及你剩余的分钟数:
{
"type": "usage",
"connected_ms": 61240,
"audio_ms": 60100,
"minutes_charged": 2,
"minutes_remaining": 898
}warning带有code:
code | 含义 |
|---|---|
balance_low | 下一次扣费会失败。到期时会话会以balance_exhausted结束。开启自动充值时不会发送。 |
audio_dropped | 会话就绪之前就收到了音频,或语音服务商连接期间收到的音频过多。最早的音频已被丢弃。 |
idle_timeout_soon | 已经4分钟没有音频。连续5分钟没有音频,会话就会结束。继续发送音频即可保持会话。 |
{ "type": "warning", "code": "idle_timeout_soon" }error带有code和message。实时套接字上可能出现这些代码:
code | 是否致命 | 含义 |
|---|---|---|
invalid_message | 否 | 收到了不是finalize或end的文本帧。 |
invalid_token | 是 | 令牌无效、已过期或已被使用。套接字以4001关闭。 |
not_found | 是 | 会话不存在。套接字以4004关闭。 |
session_ended | 是 | 会话已经结束。套接字以4004关闭。 |
{ "type": "error", "code": "invalid_message", "message": "Expected {\"type\":\"finalize\"} or {\"type\":\"end\"}" }reconnect会在服务器以1012关闭套接字之前到达,例如部署期间。请获取新令牌并恢复会话。
{ "type": "reconnect", "reason": "server_restart" }session.ended是每个会话的最后一个事件,其中的数字是最终合计:
{
"type": "session.ended",
"reason": "client_ended",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"minutes_charged": 3,
"connected_ms": 142500,
"audio_ms": 141900
}line对象
| 字段 | 含义 |
|---|---|
id | 从0开始的编号,每多一行加1。它也是保存后转录文本中的片段索引。 |
start_ms、end_ms | 以会话音频时间表示的起止时间。这个时间在重连后会继续累计。 |
speaker | 开启diarize时为说话人标签,否则为null。标签与普通转录文本相同。 |
language | 这一行检测到的或指定的语言,尚未确定时为null。 |
text | 目前为止的原文。 |
final | 原文不再变化时为true。 |
translation | 没有translate_to时为null,否则包含language、text和final。 |
final: false的行文本还可能变化,请用较淡的样式显示。final: true的行文本不会再变,最终行也不会被删除。
SDK的行字段使用camelCase:startMs、endMs。
结束原因
session.ended和GET /v1/live/sessions/:id会给出end_reason:
| 原因 | 含义 |
|---|---|
client_ended | 客户端发送了end。 |
balance_exhausted | 下一次扣费失败。会话在整分钟的边界结束并保存。 |
idle_timeout | 5分钟没有音频。 |
max_duration | 会话已连接300分钟。请开始新的会话。 |
disconnected | 客户端断开超过2分钟仍未恢复。 |
upstream_error | 语音服务商出错。 |
internal_error | transcript1一侧出了问题。 |
not_connected | 会话在5分钟内从未连接。不会计费,只能通过GET看到。 |
关闭代码
| 代码 | 含义 |
|---|---|
1000 | 正常关闭。总是出现在session.ended之后。 |
1011 | 服务器意外错误。服务器会恢复会话,请用新令牌恢复。 |
1012 | 服务器重启。出现在reconnect之后,请用新令牌恢复。 |
4001 | 令牌无效、已过期或已被使用。 |
4004 | 找不到会话,或会话已经结束。 |
4008 | 协议违规:二进制帧长度为奇数或过大。 |
4010 | 被同一会话的更新连接取代。 |
API重启期间,新的WebSocket请求可能收到HTTP 503。请稍后重试。
恢复与接管
会话可以经受网络中断、API部署和崩溃。断开后的2分钟内,会话会等你回来;超过这个时间,会话会以disconnected结束并保存。
- 调用
POST /v1/live/sessions/:id/token。响应是token、token_expires_at和websocket_url,字段与创建会话时相同。如果会话已经结束,会返回409 session_ended。 - 用这个令牌打开新的WebSocket。
- 收到
resumed: true的session.started。offset_ms是会话已处理的音频量:请从这个位置继续发送音频。行的id也会接着往下编。
已经定稿的行会保留。断开瞬间正在进行的末尾部分会丢失,所以如果你缓存了音频,请从offset_ms起重放几秒音频。重连期间的空档不计费。
接管是同一个调用。即使服务器认为还有一个套接字处于打开状态,新的有效连接也会替换它,旧套接字在其行保存后以4010关闭。恢复不会受并发会话数限制的阻挡。
SDK会替你完成这一切:连接断开或收到reconnect事件时,它会获取令牌,并以0.5到8秒的退避间隔重连,最长持续2分钟,同时重放空档期间发送的最后10秒音频。
计费
实时转录与批量转录使用同一份分钟余额。
- 按连接时间计费,从套接字被接受开始,到关闭为止。静音和暂停的视频都算在内。断开到恢复之间的时间不算。
- 按开始的分钟计,最少1分钟。 第一次连接被接受时扣第一分钟,之后每一分钟在开始时扣费。
- 快于实时按音频时长计费。 如果你发送的音频比连接时间更长,就按音频时长计费。
- 费率从API读取。 开始的每一分钟扣除
rate个余额分钟,向上取整。费率通常是1,也可能带小数。实时费率可从GET /v1/account的rates.live、GET /v1/live/capabilities的rate,以及你创建的会话中的rate读取。会话的费率在创建时就固定了。 - 翻译免费。
- 从未连接则不计费。 5分钟内没有连接的会话会以
not_connected结束。 - 失败会退款。 如果会话在产生任何一行之前就以
upstream_error或internal_error结束,所有扣费都会退回。因静音而为空的会话会照常计费,并保存为空。 - 余额用完时,如果开启了自动充值,transcript1会在会话持续传输的同时从已保存的银行卡扣款,会话继续进行。如果自动充值关闭或已暂停,或扣款失败,会话会定稿、保存,并在整分钟边界以
balance_exhausted结束。会先收到balance_low警告,开启自动充值时除外。
当你可用的余额低于按当前费率开始一分钟所需的量时,创建会话会返回402 insufficient_minutes。如果开启了自动充值,transcript1会先尝试充值。见计费与限额。
限额
| Free | Monthly | Annual | |
|---|---|---|---|
| 同时进行的实时会话 | 1 | 2 | 3 |
- 会话处于
waiting、connected或disconnected(在2分钟窗口内)时都会计数。超出限额时,POST /v1/live/sessions返回429 too_many_live_sessions,并带有Retry-After: 30。 - 实时会话不计入活跃任务上限。
- 一个会话最多连接300分钟,之后会以
max_duration结束。 - 5分钟没有音频的会话会以
idle_timeout结束;4分钟后你会收到idle_timeout_soon。暂停的音频流仍会计连接时间,并被视为空闲,所以暂停很久时请结束会话。 - transcript1对所有实时会话的总数也有上限。全部占满时,
POST /v1/live/sessions返回503 live_capacity,这个错误可以重试。请等待Retry-After(30秒)后再试。这个检查只在创建会话时进行,不会在会话进行中触发。 - 创建会话和获取令牌都计入每分钟请求数。
- 音频只能是原始PCM:
pcm_s16le,单声道,8000到48000 Hz。
支持范围
GET /v1/live/capabilities说明实时转录当前支持什么,客户端因此不必写死服务商的规则。所需权限范围为transcriptions:read。
{
"available": true,
"auto_language": true,
"languages": ["en", "es"],
"translation": {
"targets": ["es", "de"],
"pairs": "any",
"requires_language": false
},
"diarize": true,
"audio_formats": ["pcm_s16le"],
"sample_rate": { "min": 8000, "max": 48000 },
"max_session_minutes": 300,
"rate": 1
}这里的列表做了缩减,请从API读取完整内容。
translation.pairs为"any"(任何检测到的语言可翻译成任何目标语言),或是由from和to组成的对象列表。translation.requires_language为true时,翻译需要明确指定language,不能用auto。- 实时转录未配置时,响应是只含
rate的available: false,创建会话会返回503 live_unavailable。
使用SDK:await t1.live.capabilities()。
查询会话
GET /v1/live/sessions/:id返回会话的状态,所需权限范围为transcriptions:read。
curl https://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD \
-H "Authorization: Bearer $TRANSCRIPT1_API_KEY"{
"id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"status": "ended",
"end_reason": "client_ended",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"options": {
"language": "en",
"translate_to": "es",
"diarize": false,
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"save": true
},
"connected_ms": 142500,
"audio_ms": 141900,
"minutes_charged": 3,
"rate": 1,
"created_at": "2026-11-02T11:58:12Z",
"ended_at": "2026-11-02T12:00:35Z"
}status为waiting、connected、disconnected或ended。会话结束前,end_reason为null。使用SDK:await t1.live.get(id)。
错误
请求错误使用常见的错误信封。以下是POST /v1/live/sessions及相关接口返回的错误:
| HTTP状态码 | code | 可重试 | 触发场景 |
|---|---|---|---|
| 422 | invalid_request | 否 | 字段缺失或无效,例如sample_rate不在8000到48000范围内。 |
| 422 | unsupported_language | 否 | 实时转录不支持该language。 |
| 422 | unsupported_translation | 否 | 不支持该目标语言或语言组合。 |
| 402 | insufficient_minutes | 否 | 你可用的余额低于按当前费率开始一分钟所需的量。 |
| 403 | email_not_verified | 否 | 请先验证邮箱。 |
| 429 | too_many_live_sessions | 是 | 你的套餐允许的同时实时会话已用满。请等待Retry-After。 |
| 503 | live_capacity | 是 | 实时转录的容量已全部占用。请等待Retry-After(30秒)。 |
| 503 | live_unavailable | 否 | 实时转录暂时不可用。 |
| 409 | session_ended | 否 | 对已结束的会话调用了POST /v1/live/sessions/:id/token。 |
套接字上的问题会以error事件和关闭代码的形式出现。
保存的转录
save: true(默认)时,会话结束后会保存为一条普通转录。音频本身不会被保存。
- 创建会话时就会返回
transcription_id。会话进行期间它的状态是live,结束后是completed。从未连接的会话会留下一条canceled的转录。 - 该转录的
source为{ "platform": "live", "session_id": "lv_…" },没有链接或文件。控制台会把它列为实时会话。 - 每一行成为一个片段,索引与行的
id相同。你可以像读取其他转录一样,以JSON、文本、SRT或VTT读取,见API参考。 - 实时翻译也会一并保存,可用
translation参数读取,详见翻译。 - 转录变为
completed之后,可以再用POST /v1/transcriptions/:id/translations增加更多翻译语言,见翻译。
save: false时,会话结束后不会保留任何内容:没有转录,transcription_id为null。你仍会通过套接字收到每一行,并照常计费。
安全
- 创建会话时拿到的令牌只能使用一次,60秒后过期。它通过WebSocket子协议传递,而不是放在URL中,因此不会出现在访问日志里。泄露的令牌一旦用过或过期就失效了。
- API密钥只能在你自己控制的服务器上使用,绝不要把API密钥放进网页或浏览器扩展。
- 在浏览器或扩展中,请通过OAuth让用户登录,并把OAuth访问令牌传给
createLiveClient的getAccessToken。令牌需要transcriptions:write权限范围才能开始会话。OAuth访问令牌1小时后过期,每次恢复都会向getAccessToken获取新的令牌。 - 你的服务器也可以用API密钥创建会话,只把
websocket_url和token交给浏览器,由客户端直接连接。这种情况下,没有你提供的新令牌,客户端无法恢复会话。
最后更新 2026年11月3日