Ein Echtzeit-Transkript kommt zweimal an: zuerst als Entwurf, der sich laufend ändert, dann als endgültige Zeile, die sich nicht mehr ändert. Zeichnen Sie den Entwurf als austauschbares Ende und die endgültige Zeile als Verlauf, und der Rest einer Live-Untertitelansicht ist Verkabelung.
Eine Echtzeit-Sitzung ist das Kleinste, was die VocalStack API leistet. Ihr Server macht einen HTTP-Aufruf und erhält eine signierte streamUrl zurück. Ihr Client öffnet einen WebSocket zu dieser URL, sendet rohes Audio, sobald es aufgenommen wird, und liest das Transkript als JSON-Textframes, die dem Sprecher nur Augenblicke hinterherlaufen. Wenn das Audio endet, senden Sie eof; der Server leert seinen Puffer, meldet die gemessenen Werte der Sitzung und schließt die Verbindung.
Nichts wird gespeichert. Das Transkript existiert nur auf der Leitung, und genau das braucht eine Untertitelansicht, ein Meeting-Overlay oder eine Sprachfunktion in Ihrer eigenen App. Wenn Sie die Worte hinterher behalten, teilen und exportieren möchten, brauchen Sie stattdessen eine Live-Transkription und eine Übertragung; der letzte Abschnitt verweist dorthin.
Die Form ist die, die die meisten Streaming-Sprach-APIs teilen: ein Austausch von Zugangsdaten über HTTP, ein langlebiger Socket, in den Audio hineingeht und aus dem JSON herauskommt, und eine Möglichkeit, vorläufigen Text von endgültigem zu unterscheiden. Die Namen unterscheiden sich von einer API zur nächsten; die tragenden Teile selten. Hier, der Reihe nach:
POST /v1/realtime/sessions mit Ihrem API-Schlüssel, auf Ihrem Server. Die Antwort lautet { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-Heartbeats ab, bis session.ready kommt.transcript.draft (ersetzt das Live-Ende) und transcript.committed (hängt eine endgültige Zeile an).{"type":"eof"}; lesen Sie session.done.Nur Schritt 1 braucht Ihren Schlüssel, deshalb läuft er auf Ihrem Server.
Legen Sie unter Developers, API keys einen Schlüssel mit der Berechtigung Write an. Ein Schlüssel ist für Server und Skripte bestimmt: Die API weist einen Schlüssel ab, der von einer Webseite oder in einer URL gesendet wird. Deshalb legen Sie die Sitzung hinter einer eigenen Route an, und die Seite sieht nur die streamUrl.
Die URL trägt ihr eigenes Token wegen einer Browser-Einschränkung, die man allgemein kennen sollte. Der Konstruktor des Browser-WebSocket kann keine Request-Header setzen, es gibt also keinen Authorization-Header, in den man einen Schlüssel legen könnte. Deshalb authentifiziert jede browserseitige Sprach-API ihren Socket mit einem kurzlebigen Token in der URL (oder eingeschmuggelt in einen Subprotokoll-Namen). Kurzlebig ist der Witz: Eine URL landet in Logs und im Verlauf, also muss das, was sie trägt, ablaufen. Verbinden Sie sich vor expiresAt.
Die SDKs werden aus dem Developers-Bereich heruntergeladen, nicht aus einer Paket-Registry; im Abschnitt Install der Schnellstart-Anleitung stehen die Befehle für diese Umgebung. Mit dem TypeScript-SDK sieht eine Server-Route so aus:
// 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 };
}
Ohne SDK ist es ein einziges fetch. Dieses hier fragt außerdem Spanisch an und schaltet die Satzbildung ab, passend zum Beispiel auf der Leitung weiter unten:
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 };
Jede Option hat einen Standardwert. Die wichtigsten zum Einstieg sind spokenLanguage (lassen Sie sie weg, dann wird die Sprache erkannt), translate (eine Liste von Zielsprachen), speakerLabels und latencyMode; der Rest kommt weiter unten, wenn er gebraucht wird.
Übergeben Sie die streamUrl an alles, was das Audio aufnimmt; ab hier ist kein Schlüssel mehr im Spiel.
Verbinden Sie sich mit streamUrl unverändert, ohne Header. Während die Sitzung startet, sendet der Server session.loading-Heartbeats. Danach kommt session.ready. Senden Sie davor kein Audio. Früh gesendetes Audio wird nicht für später aufbewahrt; der Server behandelt es als Fehler, sendet einen error-Frame und schließt den Socket.
Sobald der Socket offen ist, hat jeder Fehler die Form dieses Frames: {"type":"error","code":"…","message":"…","retryable":true|false}. Der code ist eine geschlossene Liste (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message ist für Ihre Logs gedacht, und retryable sagt Ihnen, ob sich eine neue Sitzung lohnt. Behandeln Sie ihn vom ersten Frame an:
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;
}
});
Ein roher Client muss zwei weitere Dinge selbst behandeln; die SDKs erledigen sie bereits. Erstens trägt ein abgelehnter Upgrade die übliche HTTP-Fehlerhülle der API: 401 invalid_token, 410 session_expired oder 503 unavailable mit einem Retry-After-Header, den Sie beachten sollten.
Zweitens sieht Überlastung wie Stille aus, nicht wie ein Fehler. Kommt innerhalb von 30 Sekunden kein einziger Frame an, behandeln Sie das als capacity_exceeded und verbinden Sie sich erneut; meist kommt eine neue Sitzung durch. Kommen weiterhin Loading-Frames, aber session.ready nie, behandeln Sie das als engine_unavailable, was ebenfalls wiederholbar ist. In den SDKs löst RealtimeSession.connect bei session.ready auf und scheitert nach readyDeadlineMs mit dem passenden Code.
Sobald session.ready eintrifft, erwartet der Server Audio in dem Tempo, in dem gesprochen wird.
Audio geht als binäre Frames aus rohem PCM hinein: 32-Bit-Float, Little-Endian, 16 kHz, mono (pcm_f32le, das Standardformat audio der Sitzung). Senden Sie es, sobald es aufgenommen wird, in Blöcken von etwa 100 bis 250 ms, ohne zwischen den Blöcken auf das Transkript zu warten.
Die Byte-Rechnung lohnt sich einmal. 16.000 Samples pro Sekunde mal 4 Byte ergeben 64.000 Byte pro Sekunde, also sind 100 ms 6.400 Byte und 250 ms 16.000 Byte. Sprachmodelle werden üblicherweise auf 16 kHz Mono trainiert, weshalb so viele Echtzeit-APIs genau das verlangen, während Mikrofone und Browser mit 44,1 oder 48 kHz aufnehmen, oft in Stereo. Irgendetwas muss das Audio resamplen und die Kanäle zu einem zusammenführen, bevor die Bytes hinausgehen: Hier ist das ffmpeg, im Browser ist es der Mikrofon-Helfer des SDK. 48-kHz-Audio zu senden und 16 kHz zu deklarieren, schlägt nicht fehl; es transkribiert Sprache in Zeitlupe, und das schlecht.
In Node ist ffmpeg das einfachste Aufnahmegerät:
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 blockiert nie; es reiht ein. bufferedAmount ist die Größe dieser Warteschlange, und bei einer gesunden Leitung liegt sie nahe null. Live-Audio lässt sich nicht pausieren, deshalb besteht die sinnvolle Reaktion auf eine wachsende Warteschlange darin, sie zu bemerken, nicht darin, Frames zu verwerfen. Auch der Server übt Gegendruck aus. Er nimmt Audio einige Sekunden vor der Echtzeit an (limits.audioBufferS) und liest dann nicht weiter, bis er aufgeholt hat. Auf dem Rückweg werden Commits verlustfrei eingereiht, während Entwürfe zusammengefasst werden, sodass ein langsamer Leser weniger, dafür aktuellere Entwürfe sieht. Ein Client, der selbst mit den Commits nicht mithalten kann, erhält einen error-Frame mit slow_consumer und retryable: false und wird getrennt. Wenn Sie den sehen, leistet Ihr Nachrichtenhandler zu viel Arbeit.
Im Browser fragt streamMicrophone aus @vocalstack/sdk/mic nach dem Mikrofon, resampelt auf 16 kHz und sendet die Blöcke zusammen mit clock-Frames, mit denen der Server Lücken markieren kann, wenn der Browser den Tab drosselt. Die Seite bekommt die Sitzung von Ihrer Route und sieht nie den Schlüssel:
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, '');
}
}
}
Die Signatur render(committed, tail) ist das ganze UI-Muster; der nächste Abschnitt erklärt, warum.
Zwei Frame-Typen bauen das Transkript auf, und sie zu verwechseln ist der häufigste Fehler.
Zuerst, warum es überhaupt zwei gibt. Ein Sprachmodell entscheidet, welches Wort gemeint war, teilweise anhand der Wörter, die danach folgen: „I scream“ und „ice cream“ klingen gleich, bis der Satz weitergeht. Deshalb gibt ein Echtzeit-Erkenner früh eine Vermutung aus und überarbeitet sie, sobald mehr Audio ankommt, und erst wenn er genug gehört hat, wird sie festgelegt. Jede Streaming-Sprach-API legt das irgendwie offen. Manche nennen die Vermutungen „interim“ oder „partial“ und markieren die festgelegten mit is_final; die Begriffe variieren, die Disziplin nicht. Hier heißt die Vermutung Entwurf und der festgelegte Text Commit.
transcript.draft ist das Live-Ende: die Wörter seit dem letzten Commit, die sich noch ändern. Jeder Entwurf ersetzt den vorherigen vollständig. Zeichnen Sie das Live-Ende anhand seiner segments neu; hängen Sie es nie an. Entwürfe tragen mit Absicht kein seq, versuchen Sie also nicht, sie den Commits zuzuordnen, zu denen sie werden.
transcript.committed ist endgültig: ein segment mit seq (0, 1, 2, lückenlos), start, end, text, speaker und words mit Zeitangaben je Wort. Hängen Sie es an und verwerfen Sie den Entwurf, den es abdeckt; der nächste Entwurf beginnt dort, wo dieser Commit endete.
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
Eine UI, die Entwürfe anhängt, zeigt doppelten, flackernden Text. Ihre Untertitelansicht bekommt ein Stocken, das der Sprecher nie hatte. Zeichnen Sie alle festgeschriebenen Segmente, dann den neuesten Entwurf danach, und leeren Sie den Entwurf, sobald ein Commit eintrifft. Ein Commit kann Wörter enthalten, die in keinem Entwurf je vorkamen.
Hier sind ein paar Sekunden Sprache auf der Leitung, aus dem fetch-Beispiel oben (translate: ["es"], Satzbildung aus):
{"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}]}
Zwei weitere Frames liegen um diese herum. transcript.amended korrigiert ein festgeschriebenes Segment an Ort und Stelle (gleiches seq, neuer text oder speaker), wenn ein Wort darin korrigiert wurde; wenden Sie es auf die Zeile an, die Sie bereits gezeichnet haben. transcript.words streamt Wortzeiten für das Live-Ende kurz bevor es festgeschrieben wird. Dafür braucht es events.words, was standardmäßig eingeschaltet ist; ein Commit enthält immer seine words, egal was die Ereignisoptionen sagen. Schließlich ignorieren Sie unbekannte Frame-Typen: Neue kommen ohne Ankündigung dazu.
Hier ist die Leseschleife des Python-SDK, mit dem Terminal als Untertitelansicht. Auch hier ist die Satzbildung abgeschaltet, damit Übersetzungen als transcript.translated ankommen; der error-Frame erscheint als VocalStackError, die aus der Schleife geworfen wird:
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())
Übersetzung pro Segment, wie oben, ist die einfache Form. Die Form, die Leser bevorzugen, bekommt einen eigenen Abschnitt.
translate: ["es", "fr"] fordert Übersetzungen während der Sitzung an. Wie sie ankommen, hängt von der Satzbildung ab (sentences: { enabled: true }), die standardmäßig eingeschaltet ist.
Bei ausgeschalteten Sätzen folgt auf jeden Commit kurz danach ein transcript.translated-Frame, der seine seq nennt und translations trägt, nach Sprache geschlüsselt; hängen Sie sie an dieses Segment. So haben es die bisherigen Beispiele getan. Das ist einfach und liest sich wie ein Commit-Protokoll: ein Segment, eine Übersetzung, und die Satzgrenze liegt dort, wo der Sprecher Luft holte.
Bei eingeschalteten Sätzen fasst der Server Commits zu ganzen Sätzen zusammen und übersetzt diese, was sich deutlich besser liest. Jeder transcript.sentence-Frame ersetzt einen Satz vollständig, identifiziert über sentenceId. Sein status geht von open zu closed (oder dropped, also verwerfen), und seine translations sind vorläufig, solange er offen ist, und endgültig, sobald er geschlossen ist. Einfügen oder Aktualisieren nach sentenceId, nie anhängen. Das TypeScript-SDK liefert dafür genau diesen Zweck mit, 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}`);
}
Übersetzungen folgen dem Transkript um eine oder zwei Sekunden. Wenn Sie sie zusätzlich gesprochen haben wollen, fügen Sie voice: ["es"] hinzu (das braucht Sätze). Die spanische Fassung jedes geschlossenen Satzes kommt dann als transcript.voice-Frame an, mit dem Clip als Base64-Ogg/Opus mit 24 kHz Mono. Ein langer Satz kommt in Stücken (part von parts), die Sie hintereinander abspielen. Vorläufiger Text wird nie gesprochen. Der Leitfaden zur Live-Übersetzung behandelt Stimmen ausführlich, und wohin die Sekunden gehen erklärt die Verzögerungskette.
Die Zielsprachen stehen bei der Erstellung nicht fest.
Ein session.update-Textframe ändert spokenLanguage, translate, speakerLabels, latencyMode, voice oder voiceByLang ab diesem Punkt; Felder, die Sie weglassen, bleiben unverändert.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Die SDKs stellen das als session.update(…) bereit. translate ersetzt die gesamte Liste, und [] schaltet die Übersetzung aus. Bereits gelieferte Segmente werden weder neu transkribiert noch neu übersetzt, und wenn Sie Sprecherlabels einschalten, werden Sprecher erst ab da gekennzeichnet. Was gesagt wurde, bleibt gesagt; die Einstellungen gelten nur für das, was danach kommt.
Wenn das Audio endet, senden Sie {"type":"eof"} (session.eof() in den SDKs). Der Server transkribiert, was er noch hält, sendet die letzten Commits, dann session.done und schließt. Ein cancel stattdessen stoppt sofort und verwirft den Rest.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason ist eof, max_duration (die Sitzung hat die maximale Länge des Tarifs erreicht und lief denselben Abschluss) oder cancelled. stats misst der Server: audioS ist das Audio, das Sie gesendet haben, processingS die Wanduhrzeit, die die Sitzung lief, und realtimeFactor deren Verhältnis. Eine Live-Sitzung landet knapp unter 1, denn die Wanduhrzeit enthält den finalen Abschluss nach Ihrem eof. Ein Wert deutlich darunter bedeutet, dass die Sitzung spürbar länger brauchte als das Audio dauerte; als Erstes sollten Sie prüfen, wie schnell das Audio eintraf. Bei einer Datei sagt dieselbe Zahl, wie viel schneller als die Wiedergabe das Transkript zurückkam.
Eine Sitzung dauert höchstens limits.maxDurationS aus GET /v1/realtime/capabilities, und ihr Audio verbraucht Ihr Guthaben pro Sekunde zum Live-Tarif Ihres Plans, mit derselben Mindestdauer von 5 Minuten wie jede Transkription: Jede Sitzung, die Audio empfängt, wird mindestens mit 5 Minuten abgerechnet (Preise). Derselbe Socket nimmt auch eine ganze Datei an: Legen Sie die Sitzung mit input: { kind: "file" } an, senden Sie die eigenen Bytes der Datei (mp3, wav, flac, ogg, m4a) und eof, und das Transkript kommt viele Male schneller zurück, als das Audio abgespielt wird, mit transcript.progress-Frames unterwegs.
Es gibt kein Fortsetzen. Eine abgebrochene Verbindung beendet die Sitzung; Wiederverbinden heißt, eine neue Sitzung anzulegen, die ihre eigene Mindestdauer berechnet bekommt, und Ihre Live-Quelle erneut zu streamen, wobei seq wieder bei 0 beginnt. Das Wiederverbinden selbst ist das übliche Muster für jede Socket-API und lohnt sich, einmal richtig gemacht zu werden:
retryable: true in einem error-Frame, ein 503 mit seinem Retry-After, oder 30 Sekunden ohne Frame, was für capacity_exceeded steht. invalid_token und session_expired bedeuten eine neue streamUrl von Ihrem Server, nicht einen weiteren Versuch mit der alten.Halten Sie die Route, die Sitzungen anlegt, günstig im Aufruf, und behandeln Sie einen Abbruch als frischen Start. Dass es kein Fortsetzen gibt, ist der Preis dafür, nichts zu speichern; wenn das Transkript die Verbindung überleben muss, speichern Sie es selbst.
Wenn Sie denselben Stream als Aufnahme speichern, nachträglich korrigieren, exportieren und von einem Publikum in dessen eigenen Sprachen lesen lassen wollen, veröffentlichen Sie ihn stattdessen als Live-Transkription. Der Leitfaden zu Go live führt denselben PCM-Socket durch eine Transkription und einen Übertragungslink, und der Leitfaden zu Listen zeigt die Zuhörerseite, auf der jeder Zuhörer eine Sprache wählt und ein später Beitretender über einen lastSeq-Cursor aufholt. Wie das für einen Raum aussieht, behandelt ein Link, jede Sprache.
Für eine Datei, die Sie behalten wollen, führt der gespeicherte Weg über eine Datei mit der API transkribieren. Die vollständige Frame-Referenz, mit jedem Feld, ist der Leitfaden Ein Mikrofon streamen in der API-Referenz.