Транскриптът в реално време пристига два пъти: първо като чернова, която се променя, после като финален ред, който вече не се променя. Показвайте черновата като заменяем край, а финалните редове като история – и останалото в изгледа със субтитри е инфраструктура.
Сесията в реално време е най-малкото нещо, което API-то на VocalStack прави. Вашият сървър изпраща една 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.Само стъпка 1 се нуждае от вашия ключ, затова тя се изпълнява на вашия сървър.
Създайте ключ в Developers, API keys с разрешението Write. Ключът е предназначен за сървъри и скриптове: API-то отхвърля ключ, изпратен от уеб страница или в URL, затова сесията се създава зад маршрут на ваше собствено приложение, а страницата вижда само streamUrl.
URL адресът носи собствен токен заради ограничение на браузъра, което си заслужава да знаете като цяло. Конструкторът на браузърния WebSocket не може да задава HTTP заглавки, така че няма заглавие 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-битова плаваща запетая, little-endian, 16 kHz, моно (pcm_f32le, стандартният формат audio на сесията). Изпращайте го, докато се записва, на части от около 100 до 250 ms, без да чакате транскрипта между отделните части.
Аритметиката с байтовете си заслужава да се направи веднъж. 16 000 семпла в секунда по 4 байта са 64 000 байта в секунда, така че 100 ms са 6 400 байта, а 250 ms са 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), после спира да чете, докато не го настигне. На обратния път финалните сегменти се поставят в опашка без загуба, докато черновите се сливат, така че бавният читател вижда по-малко, но по-свежи чернови. Клиент, който не може да следва дори финалните сегменти, получава рамка error с slow_consumer и retryable: false и се прекъсва; ако я видите, обработчикът ви на съобщения върши твърде много работа.
В браузър streamMicrophone от @vocalstack/sdk/mic иска достъп до микрофона, преоразмерява до 16 kHz и изпраща частите заедно с рамки 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
Интерфейс, който добавя чернови, показва дублиран, мъждукащ текст. Вашият изглед със субтитри започва да заеква, каквото говорещият никога не е правил. Покажете всички финални сегменти, после последната чернова след тях, и изчистете черновата, когато пристигне финален сегмент. Финалният сегмент може да съдържа думи, които никога не са се появявали в нито една чернова.
Ето няколко секунди реч по протокола, от горния пример с 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 моно. Дълго изречение идва на части (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 справочника.