Ett realtidstranskript kommer två gånger: först som ett utkast som hela tiden ändras, sedan som en slutgiltig rad som inte ändras. Visa utkastet som en svans som kan bytas ut och den slutgiltiga raden som historik, så är resten av en live-textvy bara rörläggning.
En realtidssession är det minsta som VocalStack-API:et gör. Din server gör ett HTTP-anrop och får tillbaka en signerad streamUrl. Din klient öppnar en WebSocket mot den URL:en, skickar rå ljuddata i takt med att den fångas, och läser tillbaka transkriptet som JSON-textramar några ögonblick efter talaren. När ljudet tar slut skickar du eof, servern tömmer sina buffertar, rapporterar vad sessionen mätte och stänger.
Inget lagras. Transkriptet finns bara på nätverket, vilket är vad en textvy, ett överlägg för möten eller en röstfunktion i din egen app behöver. Om du vill spara orden, dela dem och exportera dem i efterhand behöver du i stället en livetranskribering och en sändning; sista avsnittet pekar dit.
Formen är densamma som de flesta strömmande tal-till-text-API:er delar: ett utbyte av autentiseringsuppgifter över HTTP, en långlivad socket med ljud in och JSON ut, och något sätt att skilja preliminär text från slutgiltig text. Namnen skiljer sig mellan API:er; de rörliga delarna gör sällan det. Här, i ordning:
POST /v1/realtime/sessions med din API-nyckel, på din server. Svaret är { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-heartbeats tills session.ready.transcript.draft (ersätt svansen) och transcript.committed (lägg till en slutgiltig rad).{"type":"eof"}; läs session.done.Bara steg 1 behöver din nyckel, så det sker på din server.
Skapa en nyckel under Developers, API keys med behörigheten Write. En nyckel är till för servrar och skript: API:et nekar en nyckel som skickas från en webbsida eller i en URL, så du skapar sessionen bakom en egen route, och sidan ser aldrig något annat än streamUrl.
URL:en bär sin egen token på grund av en begränsning i webbläsare som är bra att känna till i allmänhet. Konstruktorn WebSocket i webbläsaren kan inte sätta request-headers, så det finns ingen Authorization-header att lägga en nyckel i; varje tal-API mot webbläsare autentiserar sin socket med en kortlivad token i URL:en (eller smugglad in i ett subprotokollnamn). Att den är kortlivad är poängen: en URL hamnar i loggar och historik, så det den bär måste gå ut. Anslut före expiresAt.
SDK:erna laddas ned från utvecklarytan, inte från ett paketregister; installationsavsnittet i snabbstarten visar kommandona för just den här miljön. Med TypeScript-SDK:t ser en serverroute ut så här:
// 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 };
}
Utan SDK är det ett enda fetch. Den här begär också spanska och stänger av meningssammanfogningen, så att den stämmer med exemplet på nätverket längre ned:
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 };
Varje alternativ har ett standardvärde. De du bör känna till från början är spokenLanguage (utelämnar du den detekteras språket), translate (en lista med målspråk), speakerLabels och latencyMode; resten tas upp när de behövs nedan.
Lämna streamUrl till det som fångar ljudet; från och med nu är ingen nyckel inblandad.
Anslut till streamUrl som den är, utan header. Medan sessionen startar skickar servern session.loading-heartbeats. Sedan kommer session.ready. Skicka inget ljud före det. Ljud som skickas i förtid sparas inte till senare; servern behandlar det som ett fel, skickar en error-ram och stänger socketen.
När socketen är öppen har varje fel formen av den ramen: {"type":"error","code":"…","message":"…","retryable":true|false}. code är en sluten lista (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message är till för dina loggar, och retryable anger om en ny session är värd ett försök. Hantera det från första ramen:
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;
}
});
En rå klient måste hantera två saker till på egen hand; SDK:erna gör det redan. För det första bär en avvisad uppgradering API:ets vanliga HTTP-felkuvert: 401 invalid_token, 410 session_expired eller 503 unavailable med en Retry-After-header som ska respekteras.
För det andra ser överbelastning ut som tystnad, inte som ett fel. Om inga ramar alls kommer inom 30 sekunder, behandla det som capacity_exceeded och anslut igen; en ny session brukar komma igenom. Om laddningsramar fortsätter att komma men session.ready aldrig gör det, behandla det som engine_unavailable, som också går att försöka igen. I SDK:erna löser RealtimeSession.connect ut vid session.ready och misslyckas efter readyDeadlineMs med rätt kod.
När session.ready har kommit förväntar sig servern ljud i samma takt som det talas.
Ljud skickas in som binära ramar av rå PCM: 32-bitars flyttal, little-endian, 16 kHz, mono (pcm_f32le, sessionens standardformat audio). Skicka det i takt med att det fångas, i bitar om ungefär 100 till 250 ms, utan att vänta på transkriptet mellan bitarna.
Det är värt att räkna på bytes en gång. 16 000 sampel per sekund med 4 byte vardera blir 64 000 byte per sekund, så 100 ms är 6 400 byte och 250 ms är 16 000. Talmodeller tränas oftast på 16 kHz mono, vilket är anledningen till att så många realtids-API:er ber om det, medan mikrofoner och webbläsare spelar in på 44,1 eller 48 kHz och ofta i stereo. Något måste omsampla ljudet och slå ihop kanalerna till en innan byten skickas: här är det ffmpeg, och i webbläsaren är det SDK:ns mikrofonhjälpare. Att skicka 48 kHz-ljud och deklarera 16 kHz ger inget fel; det transkriberar tal som i slow motion, och dåligt.
I Node är ffmpeg den enklaste inspelningskällan:
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 blockerar aldrig; den köar. bufferedAmount är storleken på den kön, och på en frisk länk ligger den nära noll. Liveljud kan inte pausas, så det användbara svaret på en växande kö är att märka den, inte att släppa ramar. Servern tillämpar också backpressure. Den tar emot ljud några sekunder före realtid (limits.audioBufferS) och slutar sedan läsa tills den har hunnit ikapp. I andra riktningen köas slutgiltiga rader utan förlust medan utkast slås ihop, så en långsam läsare ser färre men färskare utkast. En klient som inte hinner med ens de slutgiltiga raderna får en error-ram med slow_consumer och retryable: false, och kopplas bort; om du ser det gör din meddelandehanterare för mycket arbete.
I en webbläsare ber streamMicrophone från @vocalstack/sdk/mic om mikrofonen, omsampplar till 16 kHz och skickar bitarna tillsammans med clock-ramar, som gör att servern kan markera luckor när webbläsaren stryper fliken. Sidan får sessionen från din route och ser aldrig nyckeln:
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, '');
}
}
}
Den signaturen render(committed, tail) är hela UI-mönstret; nästa avsnitt förklarar varför.
Två ramtyper bygger transkriptet, och att blanda ihop dem är det vanligaste misstaget.
Först, varför det finns två alls. En talmodell avgör delvis vad ett ord var utifrån orden som följer efter: ”I scream” och ”ice cream” låter likadant tills meningen fortsätter. Därför skickar en realtidsigenkännare först en gissning, reviderar den när mer ljud kommer, och först när den har hört tillräckligt blir den fast. Varje strömmande tal-API exponerar detta på något sätt. Somliga kallar gissningarna för ”interim” eller ”partial”-resultat och markerar de fasta med is_final; ordförrådet varierar, disciplinen gör det inte. Här är gissningen ett utkast och den fasta texten en slutgiltig rad.
transcript.draft är den levande svansen: orden som hörts sedan den senaste slutgiltiga raden, som fortfarande ändras. Varje utkast ersätter det föregående helt. Rita om svansen med dess segments; lägg aldrig till den. Utkast har medvetet ingen seq, så försök inte matcha dem mot de slutgiltiga rader de blir till.
transcript.committed är slutgiltig: ett segment med seq (0, 1, 2, utan luckor), start, end, text, speaker och words med tidsangivelser per ord. Lägg till den och släpp utkastet den täcker; nästa utkast börjar där den här raden slutade.
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
Ett gränssnitt som lägger till utkast visar dubblerad, flimrande text. Din textvy får en hackighet som talaren aldrig hade. Rita alla slutgiltiga segment, sedan det senaste utkastet efter dem, och töm utkastet när en slutgiltig rad kommer. En slutgiltig rad kan innehålla ord som aldrig fanns i något utkast.
Här är några sekunder tal på nätverket, från fetch-exemplet ovan (translate: ["es"], meningssammanfogning av):
{"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}]}
Två ramar till finns runt dessa. transcript.amended rättar en slutgiltig rad på plats (samma seq, ny text eller speaker) när ett ord i den har rättats; tillämpa den på raden du redan ritat. transcript.words strömmar tidsangivelser per ord för svansen strax före den blir slutgiltig. Den kräver events.words, som är på som standard; en slutgiltig rad bär alltid med sina words, oavsett vad händelsealternativen säger. Ignorera slutligen ramtyper du inte känner igen: nya läggs till utan förvarning.
Här är Python-SDK:ns läsloop, med terminalen som textvy. Meningssammanfogningen är avstängd även här, så översättningar kommer som transcript.translated, och error-ramen visas som ett VocalStackError som kastas ut ur loopen:
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())
Översättning per segment, som ovan, är den enkla formen. Formen läsare föredrar får ett eget avsnitt.
translate: ["es", "fr"] begär översättningar allteftersom sessionen pågår. Hur de kommer beror på meningssammanfogningen (sentences: { enabled: true }), som är på som standard.
Med meningar avstängda följs varje slutgiltig rad strax efter av en transcript.translated-ram som anger dess seq och bär translations nycklade på språk; koppla dem till det segmentet. Det är det exemplen hittills har använt. Det är enkelt och läses som en händelselogg: ett segment, en översättning, meningsgränsen där talaren andades.
Med meningar på sätter servern ihop slutgiltiga rader till hela meningar och översätter dem, vilket läses betydligt bättre. Varje transcript.sentence-ram ersätter en mening helt, nycklad på sentenceId. Dess status går från open till closed (eller dropped, som betyder att den ska kasseras), och dess translations är preliminära medan den är öppen och slutgiltiga när den stängs. Infoga eller uppdatera med sentenceId, lägg aldrig till. TypeScript-SDK:t levereras med applySentence just för detta:
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}`);
}
Översättningar följer transkriptet med en sekund eller två. Vill du att de också ska talas upp lägger du till voice: ["es"] (det kräver meningar). Den spanska översättningen för varje stängd mening kommer då som en transcript.voice-ram, med klippet som base64 Ogg/Opus i 24 kHz mono. En lång mening kommer i delar (part av parts) som spelas upp i följd. Preliminär text talas aldrig upp. Guiden om livöversättning tar upp röster i detalj, och var sekunderna tar vägen förklarar fördröjningskedjan.
Målspråken är inte låsta vid skapandet.
En textram session.update ändrar spokenLanguage, translate, speakerLabels, latencyMode, voice eller voiceByLang från den punkten och framåt; fält du utelämnar är oförändrade.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK:erna exponerar den som session.update(…). translate ersätter hela listan, och [] stänger av översättningen. Segment som redan levererats transkriberas eller översätts inte om, och om du slår på talaretiketter märks talarna bara från den punkten. Det som har sagts förblir sagt; inställningarna styr bara det som kommer härefter.
När ljudet tar slut skickar du {"type":"eof"} (session.eof() i SDK:erna). Servern transkriberar det den fortfarande har, skickar de sista slutgiltiga raderna, sedan session.done och stänger. Att i stället skicka cancel stoppar direkt och släpper resten.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason är eof, max_duration (sessionen nådde abonnemangets maximala längd och körde samma avslutning) eller cancelled. stats mäts av servern: audioS är ljudet du skickade, processingS är klocktiden sessionen varade, och realtimeFactor är deras kvot. En livesession hamnar strax under 1, eftersom klocktiden inkluderar den sista tömningen efter din eof. Ett tal märkbart lägre än så betyder att sessionen tog betydligt längre tid än ljudet varade; det första man kollar är hur snabbt ljudet kom in. För en fil visar samma tal hur många gånger snabbare än uppspelning transkriptet kom tillbaka.
En session varar högst limits.maxDurationS från GET /v1/realtime/capabilities, och dess ljud förbrukar dina krediter per sekund till abonnemangets livepris, med samma minimum på 5 minuter som vilken transkribering som helst: varje session som tar emot ljud faktureras för minst 5 minuter (priser). Samma socket tar också emot en hel fil: skapa sessionen med input: { kind: "file" }, skicka filens egna bytes (mp3, wav, flac, ogg, m4a) och eof, så kommer transkriptet tillbaka många gånger snabbare än ljudet spelas upp, med transcript.progress-ramar på vägen.
Det finns ingen återupptagning. En bruten anslutning avslutar sessionen; att ansluta igen innebär att skapa en ny, som faktureras med sitt eget minimum, och strömma igen från din live-källa, med seq tillbaka på 0. Själva återanslutningen är standardmönstret för alla socket-API:er, och den är värd att få rätt en gång:
retryable: true i en error-ram, en 503 med sin Retry-After, eller 30 sekunder utan en ram, vilket står för capacity_exceeded. invalid_token och session_expired betyder en ny streamUrl från din server, inte ett nytt försök med den gamla.Håll rutten som skapar sessioner billig att anropa och behandla en stängning som en ny start. Att sakna återupptagning är priset för att inte lagra något; måste transkriptet överleva socketen, spara det.
Vill du att samma ström ska sparas som en inspelning, rättas i efterhand, exporteras och läsas av en publik på deras egna språk, publicera den i stället som en livetranskribering. Guiden om att gå live går igenom samma PCM-socket genom en transkribering och en sändningslänk, och lyssnarguiden visar prenumerantens sida, där varje lyssnare väljer ett språk och en sen anslutare hämtar upp sig från en lastSeq-markör. Hur det ser ut för ett rum är ämnet för en länk, alla språk.
För en fil du vill spara är den lagrade vägen transkribera en fil med API:et. Den fullständiga referensen för ramar, med varje fält, finns i guiden Strömma en mikrofon i API-referensen.