Wynik transkrypcji w czasie rzeczywistym przychodzi dwukrotnie: najpierw jako szkic, który wciąż się zmienia, a potem jako ostateczna linia, która już się nie zmienia. Szkic wyświetl jako wymienialny ogon, a ostateczną linię jako historię, a reszta widoku napisów na żywo to już zwykła hydraulika aplikacji.
Sesja czasu rzeczywistego to najmniejsza rzecz, jaką robi API VocalStack. Twój serwer wykonuje jedno wywołanie HTTP i dostaje z powrotem podpisany streamUrl. Klient otwiera WebSocket pod ten adres, wysyła surowy dźwięk w miarę jego przechwytywania i odczytuje transkrypcję jako tekstowe ramki JSON, z opóźnieniem liczonym w chwilach za mówiącym. Gdy dźwięk się kończy, wysyłasz eof; serwer dopisuje resztę, podaje zmierzone parametry sesji i zamyka połączenie.
Nic nie jest zapisywane. Transkrypcja istnieje tylko w przesyłanych danych, i właśnie tego potrzebuje widok napisów, nakładka na spotkanie czy funkcja głosowa w twojej aplikacji. Jeśli chcesz, by słowa były zachowane, udostępniane i eksportowane po fakcie, potrzebujesz zamiast tego transkrypcji na żywo i transmisji; ostatnia sekcja wskazuje tę drogę.
Ten schemat jest wspólny dla większości strumieniowych interfejsów rozpoznawania mowy: wymiana danych uwierzytelniających przez HTTP, długo żyjące gniazdo, którym dźwięk płynie do środka, a JSON wraca na zewnątrz, oraz sposób rozróżnienia tekstu wstępnego od ostatecznego. Nazwy różnią się między API; elementy składowe prawie nigdy. Oto one, w kolejności:
POST /v1/realtime/sessions z kluczem API, na twoim serwerze. Odpowiedzią jest { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, aż przyjdzie session.ready.transcript.draft (zastąp ogon) i transcript.committed (dopisz ostateczną linię).{"type":"eof"}; odczytaj session.done.Tylko krok 1 wymaga twojego klucza, więc dzieje się na twoim serwerze.
Utwórz klucz w sekcji Developers, API keys z uprawnieniem Write. Klucz jest przeznaczony dla serwerów i skryptów: API odrzuca klucz wysłany ze strony internetowej lub w adresie URL, więc sesję tworzysz za własną trasą na serwerze, a strona widzi tylko streamUrl.
Adres URL zawiera własny token z powodu ograniczenia przeglądarek, które warto znać ogólnie. Konstruktor WebSocket w przeglądarce nie pozwala ustawiać nagłówków żądania, więc nie ma nagłówka Authorization, w którym mógłby się znaleźć klucz; każde API mowy dostępne z przeglądarki uwierzytelnia gniazdo krótkotrwałym tokenem w adresie URL (albo wpakowanym w nazwę subprotokołu). Krótkotrwałość jest tu istotą: adres URL trafia do logów i historii, więc to, co zawiera, musi wygasać. Połącz się przed expiresAt.
SDK pobiera się z sekcji Developers, a nie z rejestru pakietów; sekcja Install przewodnika szybkiego startu pokazuje polecenia właściwe dla tego środowiska. Z SDK w TypeScript trasa serwera wygląda tak:
// 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 };
}
Bez SDK to jedno fetch. Ten przykład prosi też o hiszpański i wyłącza składanie zdań, zgodnie z przykładem na przewodzie niżej:
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 };
Każda opcja ma wartość domyślną. Warte poznania od początku są spokenLanguage (jeśli je pominiesz, język zostanie wykryty), translate (lista języków docelowych), speakerLabels i latencyMode; reszta pojawi się w miarę potrzeby poniżej.
Przekaż streamUrl temu, co przechwytuje dźwięk; od tej chwili żaden klucz nie jest już potrzebny.
Połącz się z streamUrl tak, jak jest, bez nagłówków. Podczas startu sesji serwer wysyła heartbeaty session.loading. Potem przychodzi session.ready. Nie wysyłaj dźwięku wcześniej. Dźwięk wysłany za wcześnie nie jest zachowywany na później; serwer traktuje to jako błąd, wysyła ramkę error i zamyka gniazdo.
Gdy gniazdo jest otwarte, każdy błąd ma kształt tej ramki: {"type":"error","code":"…","message":"…","retryable":true|false}. code należy do zamkniętej listy (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message jest przeznaczony dla twoich logów, a retryable mówi, czy warto spróbować nowej sesji. Obsłuż to od pierwszej ramki:
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;
}
});
Surowy klient musi obsłużyć jeszcze dwie rzeczy samodzielnie; SDK robią to już za ciebie. Po pierwsze, odrzucone uaktualnienie połączenia niesie zwykłą dla API kopertę błędu HTTP: 401 invalid_token, 410 session_expired albo 503 unavailable z nagłówkiem Retry-After, który trzeba uszanować.
Po drugie, przeciążenie wygląda jak cisza, a nie jak błąd. Jeśli w ciągu 30 sekund nie przyjdzie żadna ramka, traktuj to jako capacity_exceeded i połącz się ponownie; nowa sesja zwykle przechodzi. Jeśli ramki loading wciąż napływają, ale session.ready nigdy nie przychodzi, traktuj to jako engine_unavailable, co również jest odpowiednie do ponowienia. W SDK RealtimeSession.connect kończy się na session.ready, a po readyDeadlineMs zawodzi z właściwym kodem.
Gdy przyjdzie session.ready, serwer oczekuje dźwięku w tempie, w jakim jest mówiony.
Dźwięk idzie jako binarne ramki surowego PCM: 32-bitowe liczby zmiennoprzecinkowe, little-endian, 16 kHz, mono (pcm_f32le, domyślny format audio sesji). Wysyłaj go w miarę przechwytywania, w porcjach około 100 do 250 ms, bez czekania na transkrypcję między porcjami.
Warto raz policzyć bajty. 16 000 próbek na sekundę po 4 bajty to 64 000 bajtów na sekundę, więc 100 ms to 6400 bajtów, a 250 ms to 16 000. Modele mowy są zwykle trenowane na 16 kHz mono, dlatego tak wiele API czasu rzeczywistego tego właśnie wymaga, podczas gdy mikrofony i przeglądarki nagrywają z częstotliwością 44,1 lub 48 kHz, często w stereo. Coś musi przeliczyć próbkowanie i połączyć kanały w jeden, zanim bajty wyjdą: tu jest to ffmpeg, a w przeglądarce zrobi to pomocnik mikrofonu w SDK. Wysłanie dźwięku 48 kHz z zadeklarowanymi 16 kHz nie zakończy się błędem; transkrybuje mowę w zwolnionym tempie, źle.
W Node ffmpeg jest najprostszym urządzeniem przechwytującym:
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 nigdy nie blokuje; kolejkuje. bufferedAmount to rozmiar tej kolejki, a przy zdrowym łączu oscyluje w pobliżu zera. Dźwięku na żywo nie da się wstrzymać, więc sensowną reakcją na rosnącą kolejkę jest ją zauważyć, a nie odrzucać ramki. Serwer też stosuje kontrolę przepływu (backpressure). Przyjmuje dźwięk kilka sekund przed czasem rzeczywistym (limits.audioBufferS), a potem przestaje czytać, dopóki nie nadrobi. W drugą stronę zatwierdzenia są kolejkowane bez utraty, a szkice są scalane, więc wolny czytelnik widzi mniej, za to świeższych szkiców. Klient, który nie nadąża nawet z zatwierdzeniami, dostaje ramkę error z slow_consumer i retryable: false i zostaje rozłączony; jeśli to zobaczysz, twoja obsługa wiadomości wykonuje zbyt dużo pracy.
W przeglądarce streamMicrophone z @vocalstack/sdk/mic prosi o mikrofon, przelicza próbkowanie do 16 kHz i wysyła porcje wraz z ramkami clock, dzięki którym serwer zaznacza luki, gdy przeglądarka przydusi kartę. Strona dostaje sesję z twojej trasy i nigdy nie widzi klucza:
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, '');
}
}
}
Ta sygnatura render(committed, tail) to cały wzorzec interfejsu; następna sekcja wyjaśnia dlaczego.
Transkrypcję buduje dwa typy ramek, a pomylenie ich to najczęstszy błąd.
Najpierw dlaczego w ogóle są dwa. Model mowy ustala, czym było słowo, częściowo z tego, co następuje po nim: „I scream” i „ice cream” brzmią tak samo, dopóki zdanie się nie dokończy. Dlatego rozpoznawanie w czasie rzeczywistym emituje wczesne przypuszczenie, potem je poprawia w miarę napływu dźwięku i dopiero, gdy usłyszy dość, się ustala. Każde strumieniowe API mowy wystawia to w jakiś sposób. Niektóre nazywają przypuszczenia wynikami „pośrednimi” lub „częściowymi” i oznaczają ustalone flagą is_final; słownictwo się różni, dyscyplina nie. Tu przypuszczenie to szkic, a ustalony tekst to zatwierdzenie.
transcript.draft to żywy ogon: słowa usłyszane od ostatniego zatwierdzenia, wciąż się zmieniające. Każdy szkic w całości zastępuje poprzedni. Przerysuj ogon z jego segments; nigdy go nie dopisuj. Szkice z założenia nie mają seq, więc nie próbuj dopasowywać ich do zatwierdzeń, w które się zamienią.
transcript.committed to wersja ostateczna: jeden segment z seq (0, 1, 2, bez przerw), start, end, text, speaker i words z czasami poszczególnych słów. Dopisz go i usuń szkic, który obejmuje; następny szkic zaczyna się tam, gdzie skończyło się to zatwierdzenie.
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
UI, które dopisuje szkice, pokazuje zdublowany, migający tekst. Widok napisów zaczyna się zacinać, czego mówca nie miał. Renderuj wszystkie zatwierdzone segmenty, potem najnowszy szkic po nich, a szkic czyść, gdy nadejdzie zatwierdzenie. Zatwierdzenie może zawierać słowa, które nigdy nie pojawiły się w żadnym szkicu.
Oto kilka sekund mowy na przewodzie, z przykładu fetch powyżej (translate: ["es"], składanie zdań wyłączone):
{"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}]}
Dwie kolejne ramki otaczają te. transcript.amended poprawia zatwierdzony segment na miejscu (ten sam seq, nowy text lub speaker), gdy skorygowano słowo w nim; zastosuj ją do linii, którą już narysowałeś. transcript.words strumieniuje czasy słów dla ogona tuż przed zatwierdzeniem. Wymaga events.words, które jest domyślnie włączone; zatwierdzenie zawsze zawiera swoje words, niezależnie od opcji zdarzeń. Na koniec ignoruj nieznane typy ramek: nowe pojawiają się bez zapowiedzi.
Oto pętla odczytu z Python SDK, z terminalem jako widokiem napisów. Składanie zdań jest tu też wyłączone, więc tłumaczenia przychodzą jako transcript.translated, a ramka error wypływa z pętli jako 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())
Tłumaczenie per segment, jak powyżej, to prosta forma. Forma, którą czytelnicy wolą, dostaje osobną sekcję.
translate: ["es", "fr"] prosi o tłumaczenia w trakcie sesji. Sposób ich dostarczania zależy od składania zdań (sentences: { enabled: true }), które jest domyślnie włączone.
Przy wyłączonych zdaniach każdemu zatwierdzeniu po chwili towarzyszy ramka transcript.translated, która podaje jego seq i niesie translations kluczowane językiem; dołącz je do tego segmentu. Tak działają dotychczasowe przykłady. To proste i przypomina dziennik zatwierdzeń: jeden segment, jedno tłumaczenie, granica zdania tam, gdzie mówca nabrał tchu.
Przy włączonych zdaniach serwer składa zatwierdzenia w pełne zdania i tłumaczy je, co czyta się znacznie lepiej. Każda ramka transcript.sentence w całości zastępuje jedno zdanie, kluczowane przez sentenceId. Jej status przechodzi z open na closed (albo dropped, co znaczy: odrzuć), a translations są tymczasowe, dopóki zdanie jest otwarte, i ostateczne po jego zamknięciu. Aktualizuj po sentenceId (upsert), nigdy nie dopisuj. SDK w TypeScript zawiera applySentence właśnie do tego:
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}`);
}
Tłumaczenia podążają za transkrypcją o sekundę lub dwie. Jeśli chcesz je też usłyszeć, dodaj voice: ["es"] (wymaga zdań). Hiszpańskie tłumaczenie każdego zamkniętego zdania przychodzi wtedy jako ramka transcript.voice, niosąca klip jako base64 Ogg/Opus 24 kHz mono. Długie zdanie przychodzi w kawałkach (part z parts), które odtwarza się jeden po drugim. Tekst tymczasowy nigdy nie jest czytany na głos. Przewodnik po tłumaczeniu na żywo opisuje głosy szczegółowo, a gdzie uciekają sekundy wyjaśnia łańcuch opóźnień.
Języki docelowe nie są ustalane przy tworzeniu.
Tekstowa ramka session.update zmienia spokenLanguage, translate, speakerLabels, latencyMode, voice lub voiceByLang od tego momentu dalej; pola, które pominiesz, pozostają bez zmian.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK udostępniają to jako session.update(…). translate zastępuje całą listę, a [] wyłącza tłumaczenie. Segmenty już dostarczone nie są ponownie transkrybowane ani tłumaczone, a jeśli włączysz etykiety mówców, mówcy są oznaczani dopiero od tego momentu. Co zostało powiedziane, pozostaje powiedziane; ustawienia rządzą tylko tym, co nadejdzie.
Gdy dźwięk się kończy, wyślij {"type":"eof"} (session.eof() w SDK). Serwer transkrybuje to, co jeszcze trzyma, wysyła ostatnie zatwierdzenia, potem session.done i zamyka połączenie. Wysłanie zamiast tego cancel zatrzymuje od razu i odrzuca resztę.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason to eof, max_duration (sesja osiągnęła maksymalną długość planu i przeszła to samo domykanie) albo cancelled. stats mierzy serwer: audioS to dźwięk, który wysłałeś, processingS to czas ścienny, przez jaki sesja trwała, a realtimeFactor to ich stosunek. Sesja na żywo ląduje tuż poniżej 1, bo czas ścienny obejmuje końcowe domknięcie po twoim eof. Liczba wyraźnie niższa oznacza, że sesja trwała zauważalnie dłużej niż trwał dźwięk; pierwsze miejsce do sprawdzenia to tempo, w jakim dźwięk docierał. Dla pliku ta sama liczba mówi, ile razy szybciej niż odtwarzanie wróciła transkrypcja.
Sesja trwa najwyżej limits.maxDurationS z GET /v1/realtime/capabilities, a jej dźwięk zużywa twoje środki według stawki na sekundę w stawce na żywo twojego planu, z tym samym minimum 5 minut co każda transkrypcja: każda sesja, która otrzyma dźwięk, jest rozliczana co najmniej na 5 minut (cennik). To samo gniazdo przyjmuje też cały plik: utwórz sesję z input: { kind: "file" }, wyślij własne bajty pliku (mp3, wav, flac, ogg, m4a) i eof, a transkrypcja wróci wielokrotnie szybciej niż dźwięk się odtwarza, z ramkami transcript.progress po drodze.
Nie ma wznawiania. Zerwane połączenie kończy sesję; ponowne połączenie oznacza utworzenie nowej sesji, rozliczanej osobnym minimum, i ponowne strumieniowanie ze źródła na żywo, z seq od nowa od 0. Samo ponowne połączenie to standardowy wzorzec dla każdego API opartego na gniazdach i warto wdrożyć go raz, a porządnie:
retryable: true w ramce error, 503 z Retry-After, albo 30 sekund bez ramki, co oznacza capacity_exceeded. invalid_token i session_expired oznaczają nowy streamUrl z twojego serwera, a nie kolejną próbę ze starym.Trzymaj trasę tworzącą sesje tanią w wywołaniu i traktuj zamknięcie jako nowy start. Brak wznawiania to cena za niezapisywanie niczego; jeśli transkrypcja ma przeżyć gniazdo, zachowaj ją.
Gdy chcesz, by ten sam strumień był zapisany jako nagranie, poprawiony później, wyeksportowany i czytany przez publiczność w jej własnych językach, opublikuj go jako transkrypcję na żywo. Przewodnik po transmisji na żywo prowadzi to samo gniazdo PCM przez transkrypcję i link do transmisji, a przewodnik dla słuchaczy pokazuje stronę subskrybenta, gdzie każdy słuchacz wybiera język, a spóźniony dołącza, nadrabiając od kursora lastSeq. Jak to wygląda dla sali, opisuje jeden link, każdy język.
Dla pliku, który chcesz zachować, trasą zapisującą jest transkrypcja pliku przez API. Pełny opis ramek, z każdym polem, znajduje się w przewodniku Strumieniowanie mikrofonu w dokumentacji API.