Транскрипт у реальному часі надходить двічі: спершу як чернетка, яка постійно змінюється, а потім як остаточний рядок, який уже не змінюється. Показуйте чернетку як замінюваний хвіст, а остаточний рядок як історію, і решта подання субтитрів — це лише обв’язка.
Сесія реального часу — найменша річ, яку робить API VocalStack. Ваш сервер робить один HTTP-виклик і отримує підписаний streamUrl. Ваш клієнт відкриває WebSocket до цієї адреси, надсилає сире аудіо в міру його захоплення і читає транскрипт як текстові JSON-фрейми, що йдуть буквально за мовцем. Коли аудіо закінчується, ви надсилаєте eof; сервер завершує обробку, повідомляє виміряні показники сесії і закриває з’єднання.
Нічого не зберігається. Транскрипт існує лише в мережі, і саме це потрібно для субтитрів, накладки на відеозустріч чи голосової функції у вашому застосунку. Якщо ж слова мають бути збережені, спільні та експортовані згодом, вам потрібні транскрибування наживо і трансляція; останній розділ вказує на них.
Така структура типова для більшості потокових API розпізнавання мовлення: обмін обліковими даними по HTTP, довгоживучий сокет, у який аудіо надходить, а з якого приходить JSON, і спосіб відрізнити попередній текст від остаточного. Назви різняться від API до API, а рухомі частини майже ніколи. Ось вони по порядку:
POST /v1/realtime/sessions з вашим API-ключем, на вашому сервері. Відповідь — { sessionId, streamUrl, expiresAt }.streamUrl.session.loading (сигнали, що сесія запускається), доки не прийде session.ready.transcript.draft (замінити хвіст) і transcript.committed (додати остаточний рядок).{"type":"eof"}; прочитайте session.done.Лише крок 1 потребує вашого ключа, тож він виконується на вашому сервері.
Створіть ключ у розділі 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 це вже роблять. По-перше, відхилене оновлення з’єднання містить звичний HTTP-конверт помилки API: 401 invalid_token, 410 session_expired або 503 unavailable із заголовком Retry-After, який слід дотримати.
По-друге, перевантаження виглядає як тиша, а не як помилка. Якщо жоден фрейм не надійшов протягом 30 секунд, вважайте це capacity_exceeded і підключіться знову; нова сесія зазвичай проходить. Якщо фрейми завантаження надходять, але session.ready так і не приходить, вважайте це engine_unavailable, що теж дає змогу повторити спробу. У SDK RealtimeSession.connect повертається на session.ready і після readyDeadlineMs завершується з відповідним кодом.
Щойно приходить session.ready, сервер очікує аудіо в тому темпі, в якому йде мова.
Аудіо надсилається бінарними фреймами сирого PCM: 32-бітний float, 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) — увесь шаблон UI; наступний розділ пояснює, чому.
Два типи фреймів будують транскрипт, і їх плутання — найпоширеніша помилка.
Спершу про те, чому їх два. Модель розпізнавання визначає, яким було слово, частково з тих слів, що йдуть за ним: «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
Інтерфейс, який додає чернетки, показує дубльований, мерехтливий текст. Ваш субтитровий вигляд починає заїкатися, чого в мовця не було. Малюйте всі закомічені сегменти, потім останню чернетку після них, а чернетку очищайте, коли приходить коміт. Коміт може містити слова, яких не було в жодній чернетці.
Ось кілька секунд мовлення в мережі з прикладу 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"} (session.eof() у SDK). Сервер транскрибує те, що ще утримує, надсилає останні коміти, потім 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.