Расшифровка в реальном времени приходит дважды: сначала как черновик, который продолжает меняться, а затем как окончательная строка, которая уже не меняется. Выводите черновик как заменяемый хвост, а окончательную строку как историю, и остальное в экране живых субтитров окажется обвязкой.
Сессия в реальном времени — самая простая операция VocalStack API. Ваш сервер делает один HTTP-запрос и получает подписанный streamUrl. Клиент открывает WebSocket по этому адресу, отправляет необработанное аудио по мере записи и читает расшифровку обратно в виде текстовых JSON-кадров, которые отстают от говорящего на считаные мгновения. Когда аудио заканчивается, вы отправляете eof: сервер дописывает оставшееся, сообщает измеренные показатели сессии и закрывает соединение.
Ничего не сохраняется. Расшифровка существует только в канале передачи, и именно это нужно экрану субтитров, наложению для видеовстречи или голосовой функции в вашем приложении. Если же текст нужно сохранить, потом поделиться им и экспортировать, вместо этого нужны транскрибация в реальном времени и трансляция; о них говорится в последнем разделе.
Такая схема общая для большинства потоковых речевых API: обмен учётными данными по HTTP, долгоживущий сокет, в который поступает аудио, а из которого выходит JSON, и способ отличить предварительный текст от окончательного. Названия у разных API отличаются, а движущиеся части почти никогда. Вот они по порядку:
POST /v1/realtime/sessions с вашим API-ключом, на вашем сервере. Ответ — { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, пока не придёт session.ready.transcript.draft (заменить хвост) и transcript.committed (добавить окончательную строку).{"type":"eof"} и прочитайте session.done.Ключ нужен только на первом шаге, поэтому этот шаг выполняется на вашем сервере.
Создайте ключ в разделе Developers, API keys с правом Write. Ключ предназначен для серверов и скриптов: API отклоняет ключ, переданный с веб-страницы или в URL, поэтому сессию создают за собственным маршрутом, а страница видит только streamUrl.
URL несёт собственный токен из-за ограничения браузеров, которое полезно знать в целом. Конструктор браузерного WebSocket не умеет задавать заголовки запроса, поэтому заголовка Authorization, куда можно было бы поместить ключ, нет. Поэтому все речевые API, работающие с браузером, аутентифицируют сокет короткоживущим токеном в URL (или передают его в имени подпротокола). Короткий срок жизни — в этом и смысл: URL попадает в логи и историю, поэтому то, что он несёт, должно истекать. Подключитесь до expiresAt.
SDK скачиваются из раздела Developers, а не из реестра пакетов; раздел Install в быстром старте показывает команды для вашей среды. С 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 и закрывает сокет.
Когда сокет открыт, любой сбой имеет вид такого кадра: {"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 или 503 unavailable с заголовком Retry-After, который нужно соблюсти.
Во-вторых, перегрузка выглядит как тишина, а не как ошибка. Если в течение 30 секунд не приходит ни одного кадра, считайте это capacity_exceeded и подключайтесь снова: новая сессия обычно проходит. Если кадры session.loading продолжают приходить, а session.ready так и не появляется, считайте это engine_unavailable, что тоже допускает повтор. В SDK RealtimeSession.connect завершается по session.ready и при превышении readyDeadlineMs выдаёт ошибку с нужным кодом.
Когда придёт session.ready, сервер ожидает аудио в том темпе, в котором оно звучит.
Аудио передаётся бинарными кадрами необработанного PCM: 32-битные числа с плавающей точкой, little-endian, 16 кГц, моно (pcm_f32le — формат audio по умолчанию для сессии). Отправляйте его по мере записи, фрагментами примерно по 100–250 мс, не дожидаясь расшифровки между фрагментами.
Арифметику байтов полезно сделать один раз. 16 000 отсчётов в секунду по 4 байта каждый — это 64 000 байт в секунду, значит, 100 мс — это 6 400 байт, а 250 мс — 16 000. Речевые модели обычно обучаются на моно-аудио 16 кГц, поэтому так много API в реальном времени запрашивают именно его, тогда как микрофоны и браузеры записывают на 44,1 или 48 кГц и часто в стерео. До отправки байтов аудио нужно пересэмплировать и свести каналы в один: здесь это делает ffmpeg, а в браузере — вспомогательный модуль микрофона из SDK. Отправка аудио 48 кГц с объявлением 16 кГц не вызывает ошибки; распознавание просто выходит замедленным и плохим.
В 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), а затем перестаёт читать, пока не догонит. В обратную сторону фиксации ставятся в очередь без потерь, а черновики объединяются, так что медленный читатель видит меньше, но более свежих черновиков. Клиент, который не успевает даже за фиксациями, получает кадр error с slow_consumer и retryable: false и отключается; если вы это видите, значит, обработчик сообщений делает слишком много.
В браузере streamMicrophone из @vocalstack/sdk/mic запрашивает доступ к микрофону, пересэмплирует звук до 16 кГц и отправляет фрагменты вместе с кадрами 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) — это весь шаблон интерфейса; следующий раздел объясняет, почему.
Расшифровку строят два типа кадров, и самая частая ошибка — перепутать их.
Сначала о том, почему их два. Речевая модель решает, что было сказано, отчасти по словам, которые идут следом: «ни разу» и «на раз» звучат почти одинаково, пока фраза не продолжится. Поэтому распознаватель в реальном времени сначала выдаёт догадку, потом пересматривает её по мере поступления аудио и окончательно фиксирует текст, только когда услышит достаточно. Каждый потоковый речевой 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
Интерфейс, который дописывает черновики, покажет дублирующийся мерцающий текст. Экран субтитров начнёт заикаться, чего у говорящего не было. Выводите все зафиксированные сегменты, а затем последний черновик после них, и очищайте черновик, когда приходит фиксация. Фиксация может содержать слова, которых не было ни в одном черновике.
Вот несколько секунд речи в передаче из примера с 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 кГц, моно. Длинное предложение приходит частями (part из parts), которые воспроизводятся одна за другой. Предварительный текст никогда не озвучивается. Руководство по живому переводу подробно описывает озвучку, а куда уходят секунды объясняет цепочку задержек.
Целевые языки не фиксируются при создании.
Текстовый кадр 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. Заметно меньшее число означает, что сессия заняла заметно больше времени, чем длилось аудио; смотреть нужно прежде всего на то, как быстро поступало аудио. Для файла то же число показывает, во сколько раз расшифровка была готова быстрее воспроизведения.
Сессия длится не дольше limits.maxDurationS из GET /v1/realtime/capabilities, а её аудио списывает кредиты по секундам по живой ставке вашего тарифа, с тем же минимумом в 5 минут, что и для любой транскрибации: каждая сессия, которая получила аудио, оплачивается минимум за 5 минут (цены). Тот же сокет принимает и целый файл: создайте сессию с input: { kind: "file" }, отправьте собственные байты файла (mp3, wav, flac, ogg, m4a) и eof, и расшифровка вернётся во много раз быстрее, чем воспроизводится аудио, с кадрами transcript.progress по пути.
Возобновления нет. Разорванное соединение завершает сессию; переподключение означает создание новой, которая оплачивается собственным минимумом, и новую передачу из вашего живого источника, с seq, снова начинающимся с 0. Само переподключение — стандартный приём для любого сокетного API, и его стоит сделать правильно один раз:
retryable: true в кадре error, 503 с Retry-After или 30 секунд без кадров, что соответствует capacity_exceeded. invalid_token и session_expired означают новый streamUrl с вашего сервера, а не очередную попытку со старым.Держите маршрут создания сессии дешёвым для вызова и считайте закрытие соединения новым стартом. Отсутствие возобновления — цена за то, что ничего не хранится; если расшифровка должна пережить сокет, сохраняйте её сами.
Если нужно, чтобы тот же поток сохранялся как запись, потом исправлялся, экспортировался и читался аудиторией на её языках, публикуйте его как транскрибацию в реальном времени. Руководство по выходу в эфир показывает тот же PCM-сокет через транскрибацию и ссылку на трансляцию, а руководство по прослушиванию описывает сторону подписчика: каждый слушатель выбирает язык, а опоздавший догоняет с помощью курсора lastSeq. Как это выглядит для зала, рассказано в статье одна ссылка — все языки.
Если нужен файл, который должен сохраниться, путь хранения — транскрибация файла через API. Полный справочник кадров, со всеми полями, — это руководство Потоковая передача с микрофона в справочнике API.