Un import renvoie une tâche, pas une transcription. Mettez un Idempotency-Key sur chaque création pour qu’une nouvelle tentative ne puisse jamais créer ni facturer une seconde transcription ; interrogez ensuite la tâche jusqu’à ce qu’elle soit terminée, puis lisez les phrases, les mots ou un export à partir du même identifiant.
La transcription d’un fichier est la chose la plus simple que fait l’API VocalStack, et elle a la forme de toute tâche asynchrone : importer, attendre, lire. L’import répond immédiatement avec une transcription et sa tâche, mais sans aucun mot pour l’instant. Pendant que votre programme interroge la tâche, le serveur transcrit le fichier plusieurs fois plus vite que la lecture de l’audio. Lorsque l’état est done, vous lisez la transcription sous forme de phrases avec les locuteurs et les horodatages, ou vous l’exportez sous forme de fichier dans l’un de quatre formats, dans la langue parlée ou traduite.
Tout ce qui suit se transpose à n’importe quelle API de traitement par lots : une création qui renvoie un identifiant, un statut que vous interrogez, un résultat que vous récupérez ensuite. Seuls les noms de champs sont propres à VocalStack, et ce tutoriel les parcourt avec les SDK TypeScript et Python, ainsi qu’avec les appels HTTP lorsqu’ils aident.
Ouvrez Developers → API keys dans le tableau de bord et créez une clé avec les permissions Read et Write. La clé n’est affichée qu’une seule fois ; copiez-la dans une variable d’environnement :
export VOCALSTACK_API_KEY="<your API key>"
Une clé est destinée aux serveurs et aux scripts. Tout ce qui est livré à un navigateur embarque la clé avec lui : gardez-la hors du code des pages et hors des URL, qui finissent dans les journaux. Pour diffuser depuis un navigateur, votre serveur crée une session temps réel pour lui ; c’est un autre tutoriel.
Chaque appel s’authentifie avec la clé comme jeton porteur :
Authorization: Bearer vs_key_...
Une fois la clé dans votre environnement, il reste à créer un client pour l’envoyer.
Il existe deux SDK. Chacun possède un seul client, VocalStack, dont les méthodes sont regroupées par ressource : vs.transcriptions, vs.jobs, vs.recordings, vs.realtime et quelques autres. Le SDK TypeScript fonctionne sous Node 20 ou plus récent, ainsi que dans les navigateurs ; le SDK Python repose sur asyncio et nécessite Python 3.10 ou plus récent.
Les SDK ne sont disponibles ni sur npm ni sur PyPI. L’API fournit les versions à tous les comptes. Les commandes d’installation, adaptées à votre environnement, figurent dans la section « Install » du démarrage rapide de la référence de l’API ; elles n’envoient votre clé qu’à l’hôte de l’API. Si vous préférez n’installer aucun outil, les appels HTTP ci-dessous ne nécessitent que curl et jq.
Quel que soit votre choix, le premier appel est l’import.
POST /v1/transcriptions prend le fichier en multipart/form-data : une partie file, et une partie options facultative contenant du JSON. Il répond 201 avec la transcription. Son job.recordingId est l’identifiant de l’enregistrement créé à partir du fichier, et chaque appel suivant est adressé par cet identifiant.
Deux réglages comptent pour un premier essai, tous deux dans options.settings :
spokenLanguage : un code tel que en. Si vous l’omettez, la langue est détectée. La préciser améliore la précision sur les enregistrements bruyants et les forts accents.speakerLabels : auto, on ou off. Avec on, chaque phrase porte un numéro de locuteur ; avec auto, les étiquettes n’apparaissent qu’une fois qu’un second locuteur a été entendu.La plupart des fichiers audio et vidéo sont acceptés : mp3, wav, flac, ogg, m4a, mp4 et d’autres. Une vidéo ne conserve que son audio. Le format compte peu pour la précision, car la reconnaissance vocale travaille en mono à 16 kHz et tout est rééchantillonné à cette valeur à l’arrivée. Un WAV stéréo 48 kHz d’une seule personne qui parle consacre l’essentiel de ses octets à décrire du silence en haute fidélité. Il se transcrit exactement aussi bien qu’un MP3 mono à 64 kbit/s ou qu’un fichier Opus de la même intervention, mais il est environ vingt fois plus lourd, donc vingt fois plus long à importer. Si vous convertissez avant l’import, le mono et un débit modéré sont les deux seuls réglages qui valent la peine d’être changés.
Envoyez un Idempotency-Key avec chaque import, et comprenez pourquoi. Un délai d’expiration réseau ne vous dit rien de ce que le serveur a fait : il se peut qu’il n’ait jamais reçu la requête, ou qu’il ait stocké le fichier et mis la tâche en file avant que la réponse ne se perde. Une nouvelle tentative aveugle risque donc une seconde transcription et une seconde facturation. La clé règle ce problème. Lorsqu’une requête est répétée avec la même clé et le même corps dans les 24 heures, le serveur renvoie la première réponse sans rien créer, ce qui transforme « au moins une fois » en « effectivement une seule fois ». La règle est donc une clé par import logique, générée avant la première tentative et réutilisée à chaque nouvelle tentative. Une clé différente à chaque tentative ferait de chaque nouvelle tentative un nouvel import, et l’en-tête ne protégerait plus rien.
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
const uploadKey = crypto.randomUUID(); // one key for this upload, however many attempts it takes
const tx = await vs.transcriptions.create(
{ file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } },
{ idempotencyKey: uploadKey },
);
const recordingId = tx.job!.recordingId;
import asyncio
import os
import uuid
from vocalstack import VocalStack
async def main() -> None:
upload_key = str(uuid.uuid4()) # one key for this upload, however many attempts it takes
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
tx = await vs.transcriptions.create(
file="./talk.mp3",
name="Talk",
settings={"spokenLanguage": "en", "speakerLabels": "on"},
idempotency_key=upload_key,
)
assert tx.job is not None
recording_id = tx.job.recording_id
Le même appel avec curl :
API={{api}}
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
UPLOAD_KEY=$(uuidgen)
REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $UPLOAD_KEY" \
-F file=@talk.mp3 \
-F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \
| jq -r .job.recordingId)
Le 201 arrive avant qu’un seul mot ne soit transcrit ; ce que vous détenez est un identifiant de tâche, donc l’étape suivante consiste à attendre cette tâche.
Une tâche passe de queued à transcribing, puis à done, ou se termine en failed avec un message error et, lorsque la cause est lisible par une machine, un errorCode. Un quatrième état, cropping, signifie que la tâche est mise en attente jusqu’à ce que quelqu’un choisisse la portion de l’enregistrement à transcrire, comme le fait l’étape de découpe du tableau de bord. Un import ordinaire n’entre jamais dans cet état, même si jobs.wait revient aussi lorsqu’une tâche l’atteint. Lisez GET /v1/recordings/{id}/job jusqu’à ce que l’état soit done ou failed.
Il n’y a pas de webhooks, et c’est un choix délibéré. Un webhook exige un point de terminaison HTTPS public de votre côté, une vérification de signature pour que personne d’autre ne puisse l’appeler, et une politique de nouvelles tentatives pour le cas où votre point de terminaison est indisponible : beaucoup de plomberie pour une tâche qui dure une minute. L’interrogation n’en demande aucune. Votre script demandera « c’est fini ? » toutes les deux secondes, comme un enfant sur la banquette arrière, et contrairement à l’enfant il obtiendra un pourcentage en retour.
Une page web déjà ouverte peut plutôt suivre le socket d’événements de la transcription (WS /v1/transcriptions/{id}/events, une trame job.progress à chaque avancée de la tâche). Pour un script, l’interrogation est le bon outil.
Les SDK se chargent de l’interrogation : vs.jobs.wait lit la tâche toutes les 2 secondes et transmet chaque lecture à onProgress (on_progress en Python). Le progress de la tâche porte pct (de 0 à 100) et processedS, les secondes de média déjà transcrites. Les programmes ci-dessous n’affichent une ligne que lorsque l’état ou le pourcentage change.
let last = '';
const job = await vs.jobs.wait(recordingId, {
onProgress: (j) => {
const line = `${j.state} ${j.progress.pct}%`;
if (line !== last) console.log((last = line));
},
});
if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`);
last = ""
def on_progress(j) -> None:
nonlocal last
line = f"{j.state} {j.progress.pct}%"
if line != last:
last = line
print(line)
job = await vs.jobs.wait(recording_id, on_progress=on_progress)
if job.state != "done":
raise RuntimeError(f"transcription {job.state}: {job.error}")
Notez que wait ne lève pas d’exception sur failed ; il renvoie la tâche et laisse la vérification à votre charge. Deux secondes conviennent à une intervention. Pour un enregistrement de trois heures, ou un lot de centaines de fichiers, allongez l’intervalle (intervalMs, interval_s) ou espacez de plus en plus les lectures pendant que la tâche avance : les premières lectures vous en apprennent le plus, et les suivantes confirment surtout que le serveur est toujours occupé. Sans SDK, vous écrivez la même boucle à la main :
while :; do
JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH")
STATE=$(jq -r .state <<<"$JOB")
echo "$STATE $(jq -r .progress.pct <<<"$JOB")%"
case $STATE in
done) break ;;
failed) jq -r .error <<<"$JOB" >&2; exit 1 ;;
esac
sleep 2
done
Lorsque l’état indique done, la transcription est prête.
GET /v1/recordings/{id}/transcript répond avec la transcription entière dans un seul document :
sentences : ce qu’il faut montrer aux personnes. Chacune possède text, speaker, ainsi que start et end en secondes.segments : les lignes du moteur, chacune avec les horodatages par mot dans words (chaque mot possède text, start et end).speakers : une entrée par numéro de locuteur, avec son name.Les locuteurs sont numérotés à partir de 0. Un speaker valant -1 signifie que les étiquettes de locuteurs étaient désactivées pour l’enregistrement. Ajoutez ?lang=es pour lire la transcription traduite en espagnol ; les phrases reviennent alors dans cette langue.
const { sentences } = await vs.recordings.transcript(recordingId);
for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text);
snapshot = await vs.recordings.transcript(recording_id)
for s in snapshot.sentences:
print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text)
curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \
| jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end'
Les deux niveaux d’horodatage servent deux usages. Les phrases sont l’unité de lecture : ce sont donc elles qu’affiche un sous-titre ou une page de transcription. Les mots sont l’unité de pointage : un surlignage façon karaoké, un résultat de recherche ou une découpe de clip parcourt donc segments[].words. Les formats d’export sont construits à partir des phrases.
GET /v1/recordings/{id}/export renvoie un vrai fichier. format vaut srt, json, xlsx ou pdf, et le fichier inclut les corrections éventuelles. Ajoutez lang= pour un fichier traduit : l’export se lit alors dans cette langue, une ligne par phrase, et toute phrase pas encore traduite est d’abord traduite.
Le SRT vaut la peine d’être reconnu à vue, car c’est le format de sous-titres que presque tous les lecteurs acceptent, et c’est du texte brut. Un fichier est une liste de repères séparés par des lignes vides, et chaque repère comprend un numéro, un intervalle de temps et le texte. Les horodatages utilisent une virgule avant les millisecondes, une habitude française que le format n’a jamais abandonnée :
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Une phrase par repère est la bonne granularité : chaque repère se lit dans le temps où il reste à l’écran, et aucun ne coupe une pensée en plein milieu. Lorsque l’enregistrement comportait des étiquettes de locuteurs, le texte de chaque repère commence par le nom du locuteur suivi de deux-points.
Dans le SDK TypeScript, vs.recordings.export renvoie une Response ; lisez ses octets avec arrayBuffer(). En Python, il renvoie des bytes.
import { writeFile } from 'node:fs/promises';
const srt = await vs.recordings.export(recordingId, 'srt');
await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer()));
const pdf = await vs.recordings.export(recordingId, 'pdf', { lang: 'es' });
await writeFile('talk.es.pdf', Buffer.from(await pdf.arrayBuffer()));
srt = await vs.recordings.export(recording_id, "srt")
with open("talk.srt", "wb") as f:
f.write(srt)
pdf = await vs.recordings.export(recording_id, "pdf", lang="es")
with open("talk.es.pdf", "wb") as f:
f.write(pdf)
asyncio.run(main())
curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt
curl -sSf "$API/v1/recordings/$REC/export?format=pdf&lang=es" -H "$AUTH" -o talk.es.pdf
La transcription est stockée ; elle apparaît donc sous Transcriptions dans le tableau de bord, peut être partagée par lien et peut être relue à tout moment. C’est la boucle centrale : créer, attendre, lire, exporter. Le reste de ce tutoriel couvre les variantes : les médias déjà en ligne, les erreurs que vous rencontrerez et le coût d’un fichier.
Si le média est déjà en ligne, donnez le lien à l’API et elle récupère elle-même le fichier : une adresse http(s) publique (un mp3, une vidéo, un épisode de podcast) ou une vidéo YouTube. L’appel est le même, POST /v1/transcriptions, avec un corps JSON { "url": "..." } à la place de l’import multipart, et name et settings comme précédemment. Dans les SDK, vs.transcriptions.createFromUrl(url) (TypeScript) et vs.transcriptions.create(url=...) (Python) envoient la même requête.
Avant de rien créer, GET /v1/transcriptions/probe?url= vous indique ce qu’est un lien. Son kind prend l’une de trois valeurs :
file : un enregistrement avec une fin ; transcrivez-le ici.live : un flux en direct YouTube, un flux radio sans fin ou une playlist HLS sans terme. Un lien en direct ne se termine jamais, il ne peut donc pas être transcrit comme un fichier ; utilisez-le plutôt comme source d’une transcription en direct.unknown : le serveur n’a pas pu le déterminer.Lorsque le lien ne s’ouvre pas du tout comme un média (une page web, par exemple), ok vaut false et problem en explique la raison en une phrase. La sonde est facultative et limitée à 60 appels par minute.
Une tâche de lien commence en fetching avant de passer à transcribing, puis à done. Pendant la récupération, progress.phase indique ce que fait le serveur : fetching pendant la réception des octets (avec fetchedBytes et fetchTotalBytes lorsque l’autre extrémité indique leur nombre), ou extracting pendant l’extraction de l’audio d’une vidéo. progress.phasePct indique où en est la tâche dans cette phase. Une longue vidéo prend du temps ; interrogez donc toutes les quelques secondes. vs.jobs.wait accepte intervalMs (interval_s en Python) pour cela.
const probe = await vs.transcriptions.probe(url);
if (!probe.ok) throw new Error(`that link did not open: ${probe.problem}`);
if (probe.kind === 'live') throw new Error('a live link: record it with a live transcription instead');
const linkKey = crypto.randomUUID();
const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: linkKey });
const job = await vs.jobs.wait(tx.job!.recordingId, {
intervalMs: 3000,
onProgress: (j) => {
const p = j.progress;
if (j.state === 'fetching') console.log(`${p.phase ?? 'fetching'} ${p.phasePct ?? 0}%`);
else console.log(`${j.state} ${p.pct}%`);
},
});
Le guide Transcrire un lien de la référence contient le programme complet dans les deux langages, ainsi qu’en HTTP. La récupération depuis des liens ajoute d’autres façons d’échouer ; voici donc les erreurs que vous rencontrerez réellement.
Chaque appel en échec répond avec une seule enveloppe JSON. code est la chaîne sur laquelle brancher, et status le statut HTTP ; message s’adresse aux personnes et requestId au support. Sous data, retryable indique si renvoyer la même requête peut aider, et details contient ce dont le code a besoin. Les SDK lèvent un seul type d’erreur, VocalStackError, avec les mêmes champs.
Trois codes méritent d’être gérés dès le premier jour.
402 insufficient_credit : le crédit du compte est épuisé, et rien n’a été facturé. details.balance est le solde en dollars et details.topUpUrl indique où ajouter du crédit. Lorsque le coût était connu à l’avance, comme pour un fichier de durée connue, details.required indique ce qu’il aurait été.429 rate_limited : attendez Retry-After secondes. Lorsque details.metric vaut creates_per_hour, vous avez atteint la cadence de création du forfait (nouvelles transcriptions par heure), et non une limite de rafale ; limit et resetsAt indiquent le plafond et le moment où il se libère.503 unavailable : le service est brièvement indisponible. Renvoyez la requête après Retry-After.Les SDK réessaient un 429 et un 503 après Retry-After, deux fois par défaut (maxRetries / max_retries sur le client). Un 503, en revanche, n’est réessayé que sur un appel qu’on peut envoyer deux fois sans risque : un GET, ou une création portant un Idempotency-Key. Une création sans clé reste à votre charge, car le SDK ne peut pas savoir si la première tentative a abouti. C’est la raison pratique de mettre une clé sur chaque import.
Chaque réponse porte aussi RateLimit-Limit, RateLimit-Remaining et RateLimit-Reset, afin qu’un pipeline chargé puisse ralentir avant le 429.
{
"code": "insufficient_credit",
"status": 402,
"message": "this account has no credit left; add credit to continue",
"data": {
"retryable": false,
"details": { "balance": -0.42, "required": 0.075, "currency": "usd", "plan": "payg", "topUpUrl": "https://…" }
},
"requestId": "01J…"
}
La table complète des codes se trouve dans Erreurs, nouvelles tentatives et limites. Ce 402 est la seule erreur liée à l’argent ; voici donc ce que coûte un fichier.
Les fichiers coûtent $0.20 par heure d’audio, facturés à la seconde ($0.0033 la minute) avec un minimum de 5 minutes : un fichier de 30 minutes coûte $0.10. Soit dix cents, moins que le café que vous boirez pendant que la barre de progression se remplit. Le tarif est le même sur chaque forfait, et identique via l’API et dans le tableau de bord. Un nouveau compte démarre avec un crédit d’inscription unique de $5, sans carte bancaire requise.
Trois limites de forfait touchent ce tutoriel.
GET /v1/realtime/capabilities expose sous limits.maxUploadBytes. Le plus petit des deux s’applique, et un fichier qui le dépasse est refusé avec un 413 avant toute transcription. Pour un long enregistrement, c’est une raison de plus d’importer un fichier mono compressé plutôt qu’un WAV.GET /v1/billing indique à tout moment votre forfait, votre solde et vos limites ; le tableau complet figure sur la page des tarifs.
Ensuite, les étapes suivantes sont diffuser de l’audio en temps réel lorsque vous ne pouvez pas attendre la fin d’un fichier, ou connecter un agent IA au même compte via le serveur MCP, afin qu’il transcrive et recherche pour vous sans aucun des codes ci-dessus.