Транскрипт у реалном времену стиже два пута: прво као нацрт који се стално мења, па као коначан ред који се не мења. Нацрт приказујте као део који се може заменити, а коначан ред као историју, а све остало у приказу титлова уживо је инфраструктура.
Сесија у реалном времену је најмањи посао који VocalStack API обавља. Ваш сервер шаље један HTTP позив и добија назад потписани streamUrl. Ваш клијент отвара WebSocket на тај URL, шаље сирови звук чим се снима и чита транскрипт назад као JSON текстуалне оквире, који стижу одмах иза говорника. Када звук стане, шаљете eof; сервер празни оно што је задржао, пријављује шта је сесија измерила и затвара везу.
Ништа се не чува. Транскрипт постоји само у преносу, што је оно што желе приказ титлова, преклапање на састанку или глас функција у вашој апликацији. Ако желите да речи буду сачуване, дељене и касније извезене, потребни су вам транскрипција уживо и пренос; последњи одељак то објашњава.
Облик је онај који већина API-ја за стриминг говора дели: размена акредитива преко HTTP-а, дуготрајан сокет на којем звук иде унутра, а JSON излази, и начин да се привремени текст разликује од коначног. Називи се разликују од једног API-ја до другог; покретни делови ретко. Ево их, по реду:
POST /v1/realtime/sessions са вашим API кључем, на вашем серверу. Одговор је { sessionId, streamUrl, expiresAt }.streamUrl.session.loading откуцаје (heartbeat) до 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-ови то већ раде. Прво, одбијено надоградњу везе носи уобичајени HTTP формат грешке API-ја: 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-битни float, 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 је величина тог реда и на здравој вези се креће око нуле. Звук уживо не може да се паузира, па је корисна реакција на растући ред да га приметите, а не да отпуштате оквире. И сервер примењује повратни притисак (backpressure). Прихвата звук неколико секунди унапред у односу на стварно време (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) је цео UI образац; следећи одељак објашњава зашто.
Два типа оквира граде транскрипт, а мешање их је најчешћа грешка.
Прво, зашто их уопште има два. Модел за препознавање говора одлучује шта је нека реч делом на основу речи које следе: „I scream” и „ice cream” звуче исто док реченица не настави. Зато препознавач у реалном времену прво емитује погодак, па га ревидира како стиже још звука, и тек када чује довољно, он се учвршћује. Сваки API за стриминг говора то некако излаже. Неки погодке зову „interim” или „partial” резултатима и учвршћене означавају са 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-ја.