Une transcription en temps réel arrive deux fois : d’abord sous forme de brouillon qui continue de changer, puis sous forme de ligne finale qui ne change plus. Affichez le brouillon comme une fin remplaçable et la ligne finale comme historique, et le reste d’une vue de sous-titres en direct n’est que de la plomberie.
Une session en temps réel est la plus petite opération de l’API VocalStack. Votre serveur effectue un seul appel HTTP et reçoit en retour une streamUrl signée. Votre client ouvre une WebSocket vers cette URL, envoie l’audio brut au fur et à mesure de sa capture, et lit la transcription sous forme de trames texte JSON, quelques instants seulement après l’orateur. Lorsque l’audio se termine, vous envoyez eof ; le serveur vide ses tampons, indique les mesures de la session et ferme la connexion.
Rien n’est stocké. La transcription n’existe que sur le fil, ce qui convient à une vue de sous-titres, à une superposition pour réunion ou à une fonction vocale de votre propre application. Si vous voulez conserver les mots, les partager et les exporter ensuite, il vous faut plutôt une transcription en direct et une diffusion ; la dernière section y renvoie.
Cette forme est celle que partagent la plupart des API de parole en flux : un échange d’identifiants sur HTTP, un socket longue durée où l’audio entre et où le JSON sort, et un moyen de distinguer le texte provisoire du texte définitif. Les noms changent d’une API à l’autre ; les mécanismes, eux, changent rarement. Voici les étapes, dans l’ordre :
POST /v1/realtime/sessions avec votre clé API, sur votre serveur. La réponse est { sessionId, streamUrl, expiresAt }.streamUrl.session.loading jusqu’à session.ready.transcript.draft (remplacez la fin en cours) et transcript.committed (ajoutez une ligne finale).{"type":"eof"} ; lisez session.done.Seule l’étape 1 requiert votre clé ; elle se déroule donc sur votre serveur.
Créez une clé dans Developers, API keys avec la permission Write. Une clé est destinée aux serveurs et aux scripts : l’API refuse une clé envoyée depuis une page web ou dans une URL. Vous créez donc la session derrière une route de votre propre application, et la page ne voit jamais que la streamUrl.
L’URL porte son propre jeton, en raison d’une limite des navigateurs qu’il est utile de connaître de manière générale. Le constructeur WebSocket du navigateur ne permet pas de définir des en-têtes de requête : il n’existe donc pas d’en-tête Authorization où placer une clé. Toutes les API vocales destinées aux navigateurs authentifient leur socket avec un jeton à courte durée de vie placé dans l’URL (ou glissé dans le nom d’un sous-protocole). La courte durée est essentielle : une URL finit dans les journaux et l’historique, donc ce qu’elle contient doit expirer. Connectez-vous avant expiresAt.
Les SDK se téléchargent depuis l’espace Developers, et non depuis un registre de paquets ; la section Install du guide de démarrage rapide affiche les commandes propres à cet environnement. Avec le SDK TypeScript, une route serveur ressemble à ceci :
// 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 };
}
Sans SDK, il suffit d’un seul fetch. Celui-ci demande aussi l’espagnol et désactive l’assemblage des phrases, comme l’exemple de fil plus bas :
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 };
Chaque option a une valeur par défaut. Celles qu’il est utile de connaître dès le départ sont spokenLanguage (si vous l’omettez, la langue est détectée), translate (une liste de langues cibles), speakerLabels et latencyMode ; les autres apparaissent au fil des besoins exposés ci-dessous.
Transmettez la streamUrl à ce qui capture l’audio ; à partir de là, aucune clé n’intervient.
Connectez-vous à streamUrl telle quelle, sans en-tête. Pendant le démarrage de la session, le serveur envoie des signaux session.loading. Vient ensuite session.ready. N’envoyez aucun audio avant ce signal. L’audio envoyé trop tôt n’est pas conservé pour plus tard : le serveur le traite comme une erreur, envoie une trame error et ferme le socket.
Une fois le socket ouvert, toute erreur prend la forme de cette trame : {"type":"error","code":"…","message":"…","retryable":true|false}. Le code appartient à une liste fermée (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message est destiné à vos journaux, et retryable indique s’il vaut la peine de tenter une nouvelle session. Traitez-le dès la première trame :
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 brut doit gérer lui-même deux points supplémentaires ; les SDK le font déjà. Premièrement, un passage refusé porte l’enveloppe d’erreur HTTP habituelle de l’API : 401 invalid_token, 410 session_expired, ou 503 unavailable accompagné d’un en-tête Retry-After à respecter.
Deuxièmement, la saturation ressemble à du silence, et non à une erreur. Si aucune trame n’arrive dans les 30 secondes, traitez-la comme un capacity_exceeded et reconnectez-vous ; une nouvelle session passe en général. Si les trames de chargement continuent d’arriver sans que session.ready n’arrive jamais, traitez-la comme un engine_unavailable, qui est également réessayable. Dans les SDK, RealtimeSession.connect se résout à session.ready et échoue après readyDeadlineMs avec le bon code.
Une fois session.ready reçu, le serveur attend un audio au rythme de la parole.
L’audio est envoyé sous forme de trames binaires de PCM brut : virgule flottante 32 bits, little-endian, 16 kHz, mono (pcm_f32le, le format audio par défaut de la session). Envoyez-le au fur et à mesure de sa capture, par morceaux d’environ 100 à 250 ms, sans attendre la transcription entre deux morceaux.
Le calcul des octets mérite d’être fait une fois. 16 000 échantillons par seconde à 4 octets chacun représentent 64 000 octets par seconde ; 100 ms font donc 6 400 octets, et 250 ms en font 16 000. Les modèles de parole sont généralement entraînés sur du 16 kHz mono, ce qui explique pourquoi tant d’API temps réel le demandent, alors que les microphones et les navigateurs capturent à 44,1 ou 48 kHz, souvent en stéréo. Il faut rééchantillonner l’audio et fusionner les canaux en un seul avant l’envoi des octets : ici, c’est ffmpeg ; dans le navigateur, c’est l’assistant microphone du SDK. Envoyer de l’audio à 48 kHz en déclarant 16 kHz ne provoque pas d’erreur ; la parole est simplement transcrite comme au ralenti, et mal.
Dans Node, ffmpeg est le dispositif de capture le plus simple :
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 ne bloque jamais ; il met en file d’attente. bufferedAmount indique la taille de cette file, et sur une liaison saine elle reste proche de zéro. L’audio en direct ne peut pas être mis en pause ; la bonne réaction à une file qui grossit est donc de la remarquer, et non de supprimer des trames. Le serveur applique lui aussi une contre-pression. Il accepte l’audio quelques secondes en avance sur le temps réel (limits.audioBufferS), puis cesse de lire jusqu’à rattraper son retard. Dans l’autre sens, les validations sont mises en file sans perte, tandis que les brouillons sont fusionnés : un lecteur lent voit donc moins de brouillons, mais plus récents. Un client qui ne parvient pas à suivre, même avec les validations, reçoit une trame error avec slow_consumer et retryable: false, puis est déconnecté ; si vous le voyez, votre gestionnaire de messages en fait trop.
Dans un navigateur, streamMicrophone, importé depuis @vocalstack/sdk/mic, demande l’accès au microphone, rééchantillonne à 16 kHz et envoie les morceaux accompagnés de trames clock, qui permettent au serveur de marquer les trous lorsque le navigateur ralentit l’onglet. La page reçoit la session depuis votre route et ne voit jamais la clé :
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, '');
}
}
}
Cette signature render(committed, tail) constitue à elle seule tout le modèle d’interface ; la section suivante explique pourquoi.
Deux types de trames construisent la transcription, et les confondre est l’erreur la plus courante.
Pourquoi deux types, d’abord. Un modèle de parole détermine en partie ce qu’est un mot à partir des mots qui suivent : « I scream » et « ice cream » se prononcent de la même façon tant que la phrase n’a pas continué. Un moteur de reconnaissance en temps réel émet donc d’abord une hypothèse, puis la révise à mesure que de l’audio supplémentaire arrive, et ne la fixe qu’une fois qu’il en a assez entendu. Toutes les API de parole en flux expriment cela d’une manière ou d’une autre. Certaines appellent ces hypothèses des résultats « intermédiaires » ou « partiels » et marquent les résultats fixés is_final ; le vocabulaire varie, la discipline, non. Ici, l’hypothèse est un brouillon, et le texte fixé est une validation.
transcript.draft est la fin en direct : les mots entendus depuis la dernière validation, qui changent encore. Chaque brouillon remplace intégralement le précédent. Redessinez la fin avec ses segments ; ne l’ajoutez jamais. Les brouillons ne portent pas de seq, par conception ; n’essayez donc pas de les associer aux validations qu’ils deviendront.
transcript.committed est définitif : un segment unique avec seq (0, 1, 2, sans trou), start, end, text, speaker et words avec le temps de chaque mot. Ajoutez-le et supprimez le brouillon qu’il couvre ; le brouillon suivant commence là où cette validation s’achève.
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
Une interface qui ajoute les brouillons affiche un texte dupliqué et scintillant. Votre vue de sous-titres présente alors un bégaiement que l’orateur n’a jamais eu. Affichez tous les segments validés, puis le dernier brouillon à leur suite, et effacez le brouillon à l’arrivée d’une validation. Une validation peut contenir des mots qui n’apparaissaient dans aucun brouillon.
Voici quelques secondes de parole sur le fil, tirées de l’exemple fetch ci-dessus (translate: ["es"], assemblage des phrases désactivé) :
{"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}]}
Deux autres trames entourent celles-ci. transcript.amended corrige sur place un segment validé (même seq, nouveaux text ou speaker) lorsqu’un mot de ce segment a été corrigé ; appliquez-la à la ligne que vous avez déjà dessinée. transcript.words diffuse les temps des mots de la fin juste avant sa validation. Elle nécessite events.words, activé par défaut ; une validation intègre toujours ses words, quelles que soient les options d’événements. Enfin, ignorez les types de trames que vous ne connaissez pas : de nouveaux types sont ajoutés sans préavis.
Voici la boucle de lecture du SDK Python, avec le terminal comme vue de sous-titres. L’assemblage des phrases est également désactivé ici ; les traductions arrivent donc sous forme de transcript.translated, et la trame error remonte sous forme d’une VocalStackError levée hors de la boucle :
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 traduction segment par segment, comme ci-dessus, est la forme simple. La forme que les lecteurs préfèrent a droit à une section à part.
translate: ["es", "fr"] demande des traductions au fil de la session. Leur mode de réception dépend de l’assemblage des phrases (sentences: { enabled: true }), activé par défaut.
Lorsque l’assemblage des phrases est désactivé, chaque validation est suivie peu après d’une trame transcript.translated qui indique son seq et porte des translations indexées par langue ; rattachez-les à ce segment. C’est ce qu’utilisent les exemples précédents. C’est simple, et cela se lit comme un journal de validations : un segment, une traduction, et la frontière de phrase là où l’orateur a repris son souffle.
Lorsque l’assemblage est activé, le serveur regroupe les validations en phrases complètes et traduit celles-ci, ce qui se lit bien mieux. Chaque trame transcript.sentence remplace intégralement une phrase, identifiée par sentenceId. Son status passe de open à closed (ou à dropped, qui signifie de l’écarter), et ses translations sont provisoires tant qu’elle est ouverte, puis définitives une fois fermée. Effectuez une mise à jour ou une insertion par sentenceId (upsert), sans jamais ajouter. Le SDK TypeScript fournit applySentence précisément pour cela :
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}`);
}
Les traductions suivent la transcription à une ou deux secondes près. Si vous voulez qu’elles soient aussi lues à voix haute, ajoutez voice: ["es"] (cela requiert les phrases). L’espagnol de chaque phrase fermée arrive alors sous forme de trame transcript.voice, qui transporte l’extrait en Ogg/Opus encodé en base64, à 24 kHz mono. Une phrase longue arrive en morceaux (part parmi parts) à lire les uns après les autres. Le texte provisoire n’est jamais lu à voix haute. Le guide de traduction en direct détaille les voix, et où passent les secondes explique la chaîne de latence.
Les langues cibles ne sont pas figées à la création.
Une trame texte session.update modifie spokenLanguage, translate, speakerLabels, latencyMode, voice ou voiceByLang à partir de ce moment ; les champs que vous omettez ne changent pas.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Les SDK l’exposent sous la forme session.update(…). translate remplace la liste entière, et [] désactive la traduction. Les segments déjà livrés ne sont ni retranscrits ni retraduits, et si vous activez les étiquettes de locuteurs, celles-ci ne s’appliquent qu’à partir de ce moment. Ce qui a été dit reste dit ; les réglages ne régissent que la suite.
Lorsque l’audio se termine, envoyez {"type":"eof"} (session.eof() dans les SDK). Le serveur transcrit ce qu’il retient encore, envoie les dernières validations, puis session.done, et ferme la connexion. Envoyer cancel à la place arrête immédiatement et abandonne le reste.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason vaut eof, max_duration (la session a atteint la durée maximale du forfait et a effectué le même vidage) ou cancelled. stats est mesuré par le serveur : audioS correspond à l’audio que vous avez envoyé, processingS au temps réel écoulé pendant la session, et realtimeFactor au rapport entre les deux. Une session en direct se situe juste en dessous de 1, car le temps écoulé inclut le vidage final après votre eof. Un nombre nettement inférieur signifie que la session a pris bien plus de temps que la durée de l’audio ; le premier point à vérifier est la vitesse d’arrivée de l’audio. Pour un fichier, ce même nombre indique combien de fois la transcription est revenue plus vite que la lecture.
Une session dure au maximum limits.maxDurationS selon GET /v1/realtime/capabilities, et son audio consomme votre crédit à la seconde, au tarif en direct de votre forfait, avec la même durée minimale de 5 minutes que toute transcription : chaque session qui reçoit de l’audio est facturée au minimum 5 minutes (tarifs). Le même socket accepte aussi un fichier complet : créez la session avec input: { kind: "file" }, envoyez les octets du fichier lui-même (mp3, wav, flac, ogg, m4a) puis eof, et la transcription revient plusieurs fois plus vite que la lecture de l’audio, avec des trames transcript.progress en cours de route.
Il n’existe pas de reprise. Une connexion interrompue met fin à la session ; se reconnecter signifie en créer une nouvelle, facturée sa propre durée minimale, puis reprendre le flux depuis votre source en direct, avec seq revenu à 0. La reconnexion elle-même est le schéma standard de toute API à socket, et mérite d’être bien faite une fois pour toutes :
retryable: true dans une trame error, un 503 accompagné de son Retry-After, ou 30 secondes sans trame, ce qui équivaut à capacity_exceeded. invalid_token et session_expired signifient qu’il faut une nouvelle streamUrl depuis votre serveur, et non une nouvelle tentative avec l’ancienne.Gardez la route de création de session peu coûteuse à appeler, et considérez toute fermeture comme un nouveau départ. L’absence de reprise est le prix de ne rien stocker ; si la transcription doit survivre au socket, conservez-la.
Si vous voulez que le même flux soit enregistré, corrigé après coup, exporté et lu par un public dans sa propre langue, publiez-le plutôt comme transcription en direct. Le guide du direct présente ce même socket PCM à travers une transcription et un lien de diffusion, et le guide d’écoute décrit le côté abonné, où chaque auditeur choisit une langue et où un retardataire rattrape son retard à partir d’un curseur lastSeq. Ce que cela donne pour une salle est traité dans un lien, toutes les langues.
Pour un fichier que vous voulez conserver, la voie stockée est transcrire un fichier avec l’API. La référence complète des trames, tous champs compris, se trouve dans le guide Stream a microphone de la référence API.