Транскрипт во реално време стигнува двапати: прво како привремен текст што се менува, а потоа како финална линија што повеќе не се менува. Прикажете го привремениот текст како заменлива опашка, а финалната линија како историја; остатокот од приказот со титлови во живо е само инфраструктура.
Сесијата во реално време е најмалото нешто што го прави VocalStack API. Вашиот сервер прави еден HTTP повик и добива потпишан streamUrl. Вашиот клиент отвора WebSocket до таа URL-адреса, испраќа сирово аудио додека се снима и го чита транскриптот како 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 во прелистувачот не може да поставува заглавија на барање, па нема 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 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 и често во стерео. Нешто мора да ја промени фреквенцијата на примерување (resample) и да ги спои каналите во еден пред да се испратат бајтите: овде тоа е 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 е големината на тој ред, а на здрава врска е блиску до нула. Аудиото во живо не може да се паузира, па корисната реакција на растечки ред е да се забележи, а не да се отфрлаат рамки. Серверот применува back-pressure исто така. Прима аудио неколку секунди однапред во однос на реалното време (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-то. Целосната референца за рамките, со секое поле, е водичот Stream a microphone во референцата на API-то.