Una trascrizione in tempo reale arriva due volte: prima come bozza che continua a cambiare, poi come riga definitiva che non cambia più. Disegna la bozza come una coda sostituibile e la riga definitiva come storia, e il resto di una vista dei sottotitoli in diretta è infrastruttura di collegamento.
Una sessione in tempo reale è la cosa più semplice che l'API di VocalStack fa. Il tuo server effettua una chiamata HTTP e riceve in cambio uno streamUrl firmato. Il tuo client apre un WebSocket verso quell'URL, invia l'audio grezzo man mano che viene catturato e legge la trascrizione come frame di testo JSON, pochi istanti dopo chi parla. Quando l'audio finisce invii eof; il server completa l'elaborazione, riporta quello che la sessione ha misurato e chiude la connessione.
Non viene salvato nulla. La trascrizione esiste solo sulla rete, ed è ciò che serve a una vista con i sottotitoli, a un overlay per riunioni o a una funzione vocale della tua app. Se vuoi conservare le parole, condividerle ed esportarle in seguito, ti servono invece una trascrizione in diretta e una trasmissione; l'ultima sezione ti indirizza lì.
La struttura è quella che condividono la maggior parte delle API di riconoscimento vocale in streaming: uno scambio di credenziali via HTTP, un socket di lunga durata con l'audio in entrata e il JSON in uscita, e un modo per distinguere il testo provvisorio da quello definitivo. I nomi cambiano da un'API all'altra; le parti in movimento raramente. Eccole, in ordine:
POST /v1/realtime/sessions con la tua chiave API, sul tuo server. La risposta è { sessionId, streamUrl, expiresAt }.streamUrl.session.loading fino a session.ready.transcript.draft (sostituisci la coda) e transcript.committed (aggiungi una riga definitiva).{"type":"eof"}; leggi session.done.Solo il passo 1 richiede la tua chiave, quindi avviene sul tuo server.
Crea una chiave in Developers, API keys con il permesso Write. Una chiave serve per server e script: l'API rifiuta una chiave inviata da una pagina web o in un URL, quindi crei la sessione dietro una tua route, e la pagina vede solo lo streamUrl.
L'URL porta con sé il proprio token per via di un limite del browser che vale la pena conoscere in generale. Il costruttore WebSocket del browser non può impostare header di richiesta, quindi non c'è un header Authorization in cui mettere una chiave; ogni API vocale pensata per il browser autentica il socket con un token a breve durata nell'URL (o infilato in un nome di sottoprotocollo). A breve durata è il punto: un URL finisce nei log e nella cronologia, quindi ciò che contiene deve scadere. Connettiti prima di expiresAt.
Gli SDK si scaricano dall'area Developers, non da un registro di pacchetti; la sezione Install della guida rapida mostra i comandi specifici di questo ambiente. Con l'SDK TypeScript, una route del server ha questo aspetto:
// 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 };
}
Senza SDK basta un fetch. Questo chiede anche lo spagnolo e disattiva l'assemblaggio delle frasi, in linea con l'esempio di rete più avanti:
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 };
Ogni opzione ha un valore predefinito. Quelle utili da conoscere subito sono spokenLanguage (se la ometti, la lingua viene rilevata), translate (un elenco di lingue di destinazione), speakerLabels e latencyMode; le altre emergono man mano che servono più avanti.
Passa lo streamUrl a ciò che cattura l'audio; da qui in poi non serve più nessuna chiave.
Connettiti a streamUrl così com'è, senza header. Mentre la sessione si avvia, il server invia battiti session.loading. Poi arriva session.ready. Non inviare audio prima: l'audio inviato in anticipo non viene conservato per dopo; il server lo considera un errore, invia un frame error e chiude il socket.
Una volta aperto il socket, ogni errore ha la forma di quel frame: {"type":"error","code":"…","message":"…","retryable":true|false}. Il code è un elenco chiuso (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message è per i tuoi log, e retryable indica se vale la pena provare una nuova sessione. Gestiscilo fin dal primo frame:
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;
}
});
Un client grezzo deve gestire altre due cose da solo; gli SDK lo fanno già. Primo, un upgrade rifiutato porta la normale busta di errore HTTP dell'API: 401 invalid_token, 410 session_expired, oppure 503 unavailable con un header Retry-After da rispettare.
Secondo, la saturazione sembra silenzio, non un errore. Se entro 30 secondi non arriva alcun frame, trattalo come capacity_exceeded e connettiti di nuovo; una nuova sessione di solito passa. Se continuano ad arrivare frame di caricamento ma session.ready non arriva mai, trattalo come engine_unavailable, anch'esso riprovabile. Negli SDK, RealtimeSession.connect si risolve a session.ready e fallisce dopo readyDeadlineMs con il codice giusto.
Una volta arrivato session.ready, il server si aspetta audio al ritmo con cui viene parlato.
L'audio entra come frame binari di PCM grezzo: float a 32 bit, little-endian, 16 kHz, mono (pcm_f32le, il formato audio predefinito della sessione). Invialo man mano che viene catturato, in blocchi di circa 100-250 ms, senza attendere la trascrizione tra un blocco e l'altro.
Vale la pena fare il conto una volta. 16.000 campioni al secondo di 4 byte ciascuno fanno 64.000 byte al secondo, quindi 100 ms sono 6.400 byte e 250 ms sono 16.000. I modelli di riconoscimento vocale sono spesso addestrati su audio mono a 16 kHz, ed è per questo che tante API in tempo reale lo chiedono, mentre i microfoni e i browser catturano a 44,1 o 48 kHz e spesso in stereo. Qualcosa deve ricampionare l'audio e unire i canali in uno solo prima che i byte partano: qui è ffmpeg, e nel browser è l'helper del microfono dell'SDK. Inviare audio a 48 kHz dichiarandolo a 16 kHz non dà errore; trascrive un parlato al rallentatore, e male.
In Node, ffmpeg è il dispositivo di cattura più semplice:
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 non si blocca; accoda. bufferedAmount è la dimensione di quella coda, e su una connessione sana resta vicino a zero. L'audio dal vivo non si può mettere in pausa, quindi la risposta utile a una coda che cresce è accorgersene, non scartare frame. Anche il server applica la contropressione. Accetta l'audio qualche secondo in anticipo rispetto al tempo reale (limits.audioBufferS), poi smette di leggere finché non si è rimesso in pari. Nella direzione opposta, le conferme vengono accodate senza perdite mentre le bozze vengono fuse, quindi un lettore lento vede meno bozze, ma più fresche. Un client che non riesce a stare al passo nemmeno con le conferme riceve un frame error con slow_consumer e retryable: false, e viene disconnesso; se lo vedi, il tuo gestore dei messaggi sta facendo troppo lavoro.
In un browser, streamMicrophone da @vocalstack/sdk/mic chiede il microfono, ricampiona a 16 kHz e invia i blocchi insieme a frame clock, che permettono al server di segnalare le interruzioni quando il browser rallenta la scheda. La pagina ottiene la sessione dalla tua route e non vede mai la chiave:
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, '');
}
}
}
Quella firma render(committed, tail) è l'intero schema dell'interfaccia; la prossima sezione spiega perché.
Due tipi di frame costruiscono la trascrizione, e confonderli è l'errore più comune.
Prima, perché ce ne sono due. Un modello vocale decide che cosa è stata una parola in parte in base alle parole che seguono: «a Roma» e «aroma» suonano quasi uguali finché la frase non prosegue. Quindi un riconoscitore in tempo reale emette una prima ipotesi e poi la rivede man mano che arriva altro audio, e si stabilizza solo quando ha sentito abbastanza. Ogni API di riconoscimento vocale in streaming espone questo fenomeno in qualche modo. Alcune chiamano queste ipotesi risultati «interim» o «parziali» e marcano quelli stabilizzati con is_final; il vocabolario varia, la disciplina no. Qui l'ipotesi è una bozza e il testo stabilizzato è una conferma.
transcript.draft è la coda in diretta: le parole sentite dall'ultima conferma, ancora in cambiamento. Ogni bozza sostituisce integralmente la precedente. Ridisegna la coda con i suoi segments; non aggiungerla mai. Le bozze non hanno un seq per scelta progettuale, quindi non cercare di abbinarle alle conferme in cui diventano.
transcript.committed è definitivo: un segment con seq (0, 1, 2, senza buchi), start, end, text, speaker e words con i tempi di ogni parola. Aggiungilo e togli la bozza che copre; la bozza successiva inizia dove termina questa conferma.
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
Un'interfaccia che aggiunge le bozze mostra testo duplicato e tremolante. La tua vista con i sottotitoli sviluppa un'esitazione che il relatore non aveva. Mostra tutti i segmenti definitivi, poi l'ultima bozza dopo di essi, e cancella la bozza quando arriva una conferma. Una conferma può contenere parole che non sono mai comparse in nessuna bozza.
Ecco qualche secondo di parlato sul filo, dall'esempio fetch di sopra (translate: ["es"], assemblaggio delle frasi disattivato):
{"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}]}
Altri due frame stanno attorno a questi. transcript.amended corregge un segmento già confermato sul posto (stesso seq, nuovo text o speaker) quando una parola al suo interno è stata corretta; applicalo alla riga che hai già disegnato. transcript.words trasmette i tempi delle parole per la coda appena prima che venga confermata. Richiede events.words, attivo per impostazione predefinita; una conferma include sempre i suoi words, qualunque cosa dicano le opzioni degli eventi. Infine, ignora i tipi di frame che non conosci: ne vengono aggiunti di nuovi senza preavviso.
Ecco il ciclo di lettura dell'SDK Python, con il terminale come vista dei sottotitoli. Anche qui l'assemblaggio delle frasi è disattivato, quindi le traduzioni arrivano come transcript.translated, e il frame error viene sollevato dal ciclo come 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())
La traduzione per segmento, come sopra, è la forma semplice. La forma che i lettori preferiscono ha una sezione tutta sua.
translate: ["es", "fr"] chiede traduzioni mentre la sessione procede. Come arrivano dipende dall'assemblaggio delle frasi (sentences: { enabled: true }), attivo per impostazione predefinita.
Con le frasi disattivate, a ogni conferma segue poco dopo un frame transcript.translated che indica il suo seq e contiene translations indicizzate per lingua; collegale a quel segmento. È ciò che hanno usato finora gli esempi. È semplice e somiglia a un registro di conferme: un segmento, una traduzione, il confine di frase dove il relatore ha preso fiato.
Con le frasi attive, il server assembla le conferme in frasi intere e le traduce, cosa che si legge molto meglio. Ogni frame transcript.sentence sostituisce integralmente una frase, identificata da sentenceId. Il suo status passa da open a closed (oppure dropped, che significa scartarla), e i suoi translations sono provvisori finché è aperta e definitivi una volta chiusa. Aggiorna per sentenceId, mai aggiungendo. L'SDK TypeScript include applySentence proprio per questo:
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}`);
}
Le traduzioni seguono la trascrizione di uno o due secondi. Se vuoi anche che vengano lette ad alta voce, aggiungi voice: ["es"] (richiede le frasi). Lo spagnolo di ogni frase chiusa arriva allora come frame transcript.voice, con la clip in Ogg/Opus base64 a 24 kHz mono. Una frase lunga arriva a pezzi (part di parts) da riprodurre uno dopo l'altro. Il testo provvisorio non viene mai letto. La guida alla traduzione dal vivo tratta le voci in dettaglio, e dove vanno i secondi spiega la catena dei ritardi.
Le lingue di destinazione non sono fissate alla creazione.
Un frame di testo session.update cambia spokenLanguage, translate, speakerLabels, latencyMode, voice o voiceByLang da quel punto in poi; i campi che ometti restano invariati.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Gli SDK lo espongono come session.update(…). translate sostituisce l'intero elenco, e [] disattiva la traduzione. I segmenti già consegnati non vengono ritrascritti né ritradotti, e se attivi le etichette dei parlanti, questi vengono etichettati solo da quel punto. Ciò che è stato detto resta detto; le impostazioni governano solo ciò che viene dopo.
Quando l'audio finisce, invia {"type":"eof"} (session.eof() negli SDK). Il server trascrive ciò che ancora trattiene, invia le ultime conferme, poi session.done e chiude. Inviare invece cancel si ferma subito e scarta il resto.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason è eof, max_duration (la sessione ha raggiunto la durata massima del piano ed esegue lo stesso svuotamento) oppure cancelled. stats è misurato dal server: audioS è l'audio che hai inviato, processingS è il tempo reale in cui la sessione è stata in esecuzione, e realtimeFactor è il loro rapporto. Una sessione dal vivo si attesta appena sotto 1, perché il tempo reale include lo svuotamento finale dopo il tuo eof. Un valore nettamente più basso significa che la sessione ha richiesto molto più tempo della durata dell'audio; il primo posto dove guardare è la velocità con cui è arrivato l'audio. Per un file, lo stesso numero dice quante volte più veloce della riproduzione è tornata la trascrizione.
Una sessione dura al massimo limits.maxDurationS da GET /v1/realtime/capabilities, e il suo audio consuma il tuo credito al secondo alla tariffa dal vivo del tuo piano, con lo stesso minimo di 5 minuti di ogni trascrizione: ogni sessione che riceve audio viene fatturata per almeno 5 minuti (prezzi). Lo stesso socket accetta anche un intero file: crea la sessione con input: { kind: "file" }, invia i byte del file stesso (mp3, wav, flac, ogg, m4a) e eof, e la trascrizione torna molte volte più velocemente della riproduzione, con frame transcript.progress lungo il percorso.
Non c'è ripresa. Una connessione caduta termina la sessione; riconnettersi significa crearne una nuova, fatturata con il suo minimo, e riprendere lo stream dalla sorgente dal vivo, con seq di nuovo a 0. La riconnessione è il modello standard per qualsiasi API a socket, e vale la pena farla bene una volta:
retryable: true su un frame error, un 503 con il suo Retry-After, o 30 secondi senza alcun frame, che equivale a capacity_exceeded. invalid_token e session_expired significano un nuovo streamUrl dal tuo server, non un altro tentativo con quello vecchio.Mantieni la route che crea la sessione economica da chiamare e tratta una chiusura come un nuovo inizio. Non avere una ripresa è il prezzo di non salvare nulla; se la trascrizione deve sopravvivere al socket, salvala tu.
Quando vuoi che lo stesso stream venga salvato come registrazione, corretto in seguito, esportato e letto da un pubblico nelle proprie lingue, pubblicalo invece come trascrizione in diretta. La guida per andare in diretta percorre lo stesso socket PCM attraverso una trascrizione e un link di trasmissione, e la guida all'ascolto mostra il lato dell'iscritto, dove ogni ascoltatore sceglie una lingua e chi arriva in ritardo recupera da un cursore lastSeq. Come appare tutto questo per una sala è il tema di un link, ogni lingua.
Per un file che vuoi conservare, il percorso salvato è trascrivere un file con l'API. Il riferimento completo di tutti i frame, ogni campo incluso, è nella guida Inviare un microfono in streaming del riferimento API.