Een upload geeft een job terug, geen transcript. Zet op elke create een Idempotency-Key, zodat een retry nooit een tweede transcriptie kan aanmaken of laten factureren; poll daarna de job tot hij klaar is, en lees zinnen, woorden of een export vanuit hetzelfde id.
Een bestandstranscriptie is de eenvoudigste functie van de VocalStack API, en ze heeft de vorm van elke asynchrone job: uploaden, wachten, lezen. De upload antwoordt meteen met een transcriptie en de bijbehorende job, maar nog zonder woorden. Terwijl je programma de job bevraagt, transcribeert de server het bestand vele malen sneller dan de audio duurt. Wanneer de status done is, lees je het transcript als zinnen met sprekers en tijdstippen, of exporteer je het als bestand in een van vier formaten, in de gesproken taal of vertaald.
Alles in deze vorm geldt voor elke batch-API: een create die een id teruggeeft, een status die je bevraagt, een resultaat dat je daarna ophaalt. Alleen de veldnamen zijn specifiek voor VocalStack, en deze tutorial loopt ze door met de TypeScript- en Python-SDK's, en waar het helpt met de HTTP-aanroepen.
Open Developers → API keys in het Dashboard en maak een sleutel aan met de rechten Read en Write. De sleutel wordt één keer getoond; kopieer hem naar een omgevingsvariabele:
export VOCALSTACK_API_KEY="<your API key>"
Een sleutel is bedoeld voor servers en scripts. Alles wat naar een browser wordt verstuurd, krijgt de sleutel mee, dus houd hem uit de paginacode en uit URL's, want die belanden in logs. Wil je streamen vanuit een browser, dan maakt je server er een realtime-sessie voor aan; dat is een andere tutorial.
Elke aanroep authenticeert met de sleutel als bearer-token:
Authorization: Bearer vs_key_...
Met de sleutel in je omgeving is het volgende de client die hem verstuurt.
Er zijn twee SDK's. Elk heeft één client, VocalStack, met methoden die per resource zijn gegroepeerd: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime en een paar meer. De TypeScript-SDK draait op Node 20 of nieuwer en in browsers; de Python-SDK is gebouwd op asyncio en vereist Python 3.10 of nieuwer.
De SDK's staan niet op npm of PyPI. De API levert de builds aan elk account. De installatiecommando's, ingevuld voor jouw omgeving, staan in de sectie Install van de quickstart in de API-referentie, en ze sturen je sleutel alleen naar de API-host. Wil je niets installeren, dan hebben de HTTP-aanroepen hieronder alleen curl en jq nodig.
Wat je ook kiest, de eerste aanroep is de upload.
POST /v1/transcriptions neemt het bestand als multipart/form-data: een file-deel, plus een optioneel options-deel met JSON. Het antwoordt met 201 en de transcriptie. Het job.recordingId is het id van de opname die uit het bestand is gemaakt, en elke volgende aanroep wordt met dat id geadresseerd.
Twee instellingen zijn belangrijk bij een eerste run, beide binnen options.settings:
spokenLanguage: een code zoals en. Laat je hem weg, dan wordt de taal gedetecteerd. Het opgeven ervan verbetert de nauwkeurigheid bij ruisige opnames en zware accenten.speakerLabels: auto, on of off. Bij on krijgt elke zin een sprekernummer; bij auto verschijnen labels pas zodra een tweede spreker is gehoord.De meeste audio- en videobestanden worden geaccepteerd: mp3, wav, flac, ogg, m4a, mp4 en andere. Van een video blijft alleen de audio over. Het formaat doet er nauwelijks toe voor de nauwkeurigheid, want spraakherkenning werkt op 16 kHz mono en alles wordt bij aankomst daarnaar geresampled. Een 48 kHz stereo-WAV van één persoon die praat, besteedt het grootste deel van zijn bytes aan het in hoge kwaliteit beschrijven van stilte. Hij wordt precies even goed getranscribeerd als een mono-MP3 of Opus-bestand van 64 kbps van dezelfde voordracht, maar hij is ongeveer twintig keer zo groot, dus de upload duurt twintig keer zo lang. Als je vóór de upload converteert, zijn mono en een bescheiden bitrate de twee instellingen die het waard zijn om te wijzigen.
Stuur bij elke upload een Idempotency-Key mee, en begrijp waarom. Een netwerk-timeout zegt niets over wat de server heeft gedaan: hij heeft het verzoek misschien nooit gezien, of hij heeft het bestand al opgeslagen en de job in de wachtrij gezet voordat het antwoord verloren ging. Een blinde retry riskeert dus een tweede transcriptie en een tweede kosten. De sleutel lost dat op. Wordt een verzoek met dezelfde sleutel en dezelfde body binnen 24 uur herhaald, dan geeft de server het eerste antwoord terug in plaats van iets nieuws aan te maken, en zo wordt "minstens één keer" "effectief precies één keer". De regel is dus één sleutel per logische upload, gegenereerd vóór de eerste poging en hergebruikt bij elke retry. Een nieuwe sleutel per poging zou elke retry tot een nieuwe upload maken, en dan beschermt de header niets.
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
Hetzelfde met 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)
De 201 komt terug voordat er ook maar één woord is getranscribeerd; wat je in handen hebt is een job-id, dus de volgende stap is wachten op die job.
Een job gaat van queued → transcribing → done, of eindigt in failed met een error-bericht en, wanneer de oorzaak machineleesbaar is, een errorCode. Een vierde status, cropping, betekent dat de job geparkeerd staat totdat iemand kiest welk stuk van de opname getranscribeerd wordt, zoals de trim-stap in het Dashboard doet. Een gewone upload komt er nooit in, al keert jobs.wait ook terug wanneer een job er wel komt. Lees GET /v1/recordings/{id}/job totdat de status done of failed is.
Er zijn geen webhooks, en dat is een bewuste keuze. Een webhook heeft een openbaar HTTPS-eindpunt aan jouw kant nodig, een handtekeningcontrole zodat niemand anders hem kan aanroepen, en een retrybeleid voor het geval je eindpunt down is. Dat is veel loodgieterswerk voor een job die een minuut duurt. Pollen heeft dat allemaal niet nodig. Je script vraagt elke twee seconden "al klaar?", als een kind op de achterbank, en anders dan het kind krijgt het een percentage terug.
Een webpagina die al open staat, kan in plaats daarvan de events-socket van de transcriptie volgen (WS /v1/transcriptions/{id}/events, een job.progress-frame terwijl de job vordert). Voor een script is pollen het juiste hulpmiddel.
De SDK's doen het pollen: vs.jobs.wait leest de job elke 2 seconden en geeft elke lezing door aan onProgress (on_progress in Python). De progress van de job bevat pct (0 tot 100) en processedS, de mediaseconden die tot nu toe getranscribeerd zijn. De programma's hieronder printen alleen een regel wanneer de status of het percentage verandert.
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}")
Let op: wait geeft geen fout bij failed; het geeft de job terug en laat de controle aan jou. Twee seconden past bij een voordracht. Voor een opname van drie uur, of een batch van honderden, verhoog het interval (intervalMs, interval_s) of laat het oplopen terwijl de job loopt: de eerste lezingen vertellen het meeste, en de latere bevestigen vooral dat de server nog bezig is. Zonder SDK schrijf je dezelfde lus met de hand:
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
Zodra de status done aangeeft, is het transcript klaar.
GET /v1/recordings/{id}/transcript antwoordt met het volledige transcript in één document:
sentences: wat je mensen laat zien. Elk heeft text, speaker, en start en end in seconden.segments: de regels van de engine, elk met woordtijden op woordniveau in words (elk woord heeft text, start en end).speakers: één item per sprekernummer, met de name.Sprekers worden genummerd vanaf 0. Een speaker van -1 betekent dat sprekerlabels voor de opname uit stonden. Voeg ?lang=es toe om het transcript vertaald naar Spaans te lezen; de zinnen komen dan in die taal terug.
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'
De twee tijdsniveaus dienen twee doelen. Zinnen zijn de leeseenheid, dus die toon je in een ondertitel of op een transcriptpagina. Woorden zijn de wijseenheid, dus een karaoke-achtige highlight, een zoekresultaat of een clip-knip loopt door segments[].words heen. De exportformaten zijn opgebouwd uit zinnen.
GET /v1/recordings/{id}/export geeft een echt bestand terug. format is een van srt, json, xlsx of pdf, en het bestand bevat eventuele correcties. Voeg lang= toe voor een vertaald bestand: de export is dan in die taal, één regel per zin, en elke zin die nog niet vertaald is, wordt eerst vertaald.
SRT is goed om van zicht te kennen, want het is het ondertitelformaat dat bijna elke speler accepteert en het is platte tekst. Een bestand is een lijst van cues die door lege regels zijn gescheiden, en elke cue bestaat uit een teller, een tijdbereik en de tekst. De tijdstempels gebruiken een komma vóór de milliseconden, een Franse gewoonte die het formaat nooit heeft afgeschud:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Eén zin per cue is de juiste korrel: elke cue kan gelezen worden in de tijd dat hij op het scherm staat, en geen enkele breekt midden in een gedachte af. Als de opname sprekerlabels had, begint de tekst van elke cue met de naam van de spreker en een dubbele punt.
In de TypeScript-SDK geeft vs.recordings.export een Response terug; lees de bytes ervan met arrayBuffer(). In Python geeft het bytes terug.
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
De transcriptie wordt opgeslagen, dus ze verschijnt onder Transcriptions in het Dashboard, kan via een link worden gedeeld en kan later altijd opnieuw worden gelezen. Dat is de kernlus: aanmaken, wachten, lezen, exporteren. De rest van deze tutorial behandelt de variaties: media die al online staat, de fouten die je tegenkomt, en wat een bestand kost.
Staat de media al online, geef de API dan de link en die haalt het bestand zelf op: een openbaar http(s)-adres (een mp3, een video, een podcastaflevering) of een YouTube-video. De aanroep is dezelfde POST /v1/transcriptions, met een JSON-body { "url": "..." } in plaats van de multipart-upload, en name en settings zoals eerder. In de SDK's doen vs.transcriptions.createFromUrl(url) (TypeScript) en vs.transcriptions.create(url=...) (Python) hetzelfde verzoek.
Voordat je iets aanmaakt, vertelt GET /v1/transcriptions/probe?url= je wat een link is. De kind is een van drie:
file: een opname met een einde; transcribeer hem hier.live: een YouTube-livestream, een eindeloze radiostream of een HLS-playlist zonder einde. Een live-link eindigt nooit, dus die kan niet als bestand worden getranscribeerd; gebruik hem in plaats daarvan als bron van een livetranscriptie.unknown: de server kon het niet vaststellen.Wanneer de link helemaal niet als media opent (bijvoorbeeld een webpagina), is ok false en legt problem in één zin uit waarom. De probe is optioneel en beperkt tot 60 aanroepen per minuut.
Een link-job begint in fetching voordat hij naar transcribing en done gaat. Terwijl hij ophaalt, zegt progress.phase wat de server doet: fetching terwijl bytes binnenkomen (met fetchedBytes en fetchTotalBytes wanneer de andere kant vertelt hoeveel het er zijn), of extracting terwijl de audio uit een video wordt gehaald. progress.phasePct geeft aan hoe ver de job in die fase is. Een lange video kost tijd, dus poll elke paar seconden; vs.jobs.wait neemt daarvoor intervalMs (interval_s in Python).
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}%`);
},
});
De gids Een link transcriberen van de referentie heeft het volledige programma in beide talen en in HTTP. Het ophalen van links brengt extra manieren om te falen mee, dus hier zijn de fouten die je echt zult zien.
Elke mislukte aanroep antwoordt met één JSON-envelop. code is de string waarop je vertakt en status de HTTP-status; message is voor mensen en requestId voor support. Onder data geeft retryable aan of het opnieuw versturen van hetzelfde verzoek kan helpen, en details bevat wat de code nodig heeft. De SDK's gooien één fouttype, VocalStackError, met dezelfde velden.
Drie codes zijn op de eerste dag al het waard om af te handelen.
402 insufficient_credit: het tegoed van het account is op, en er is niets in rekening gebracht. details.balance is het saldo in dollars en details.topUpUrl is waar je tegoed kunt toevoegen. Wanneer de kosten vooraf bekend waren, zoals bij een bestand met bekende lengte, zegt details.required hoeveel het zou zijn geweest.429 rate_limited: wacht Retry-After seconden. Wanneer details.metric creates_per_hour is, heb je de aanmaakfrequentie van het abonnement bereikt (nieuwe transcripties per uur), geen burst-limiet; limit en resetsAt zeggen wat het maximum is en wanneer het weer vrijkomt.503 unavailable: de service is even niet beschikbaar. Stuur het verzoek opnieuw na Retry-After.De SDK's proberen een 429 en een 503 opnieuw na Retry-After, standaard twee keer (maxRetries / max_retries op de client). Een 503 wordt echter alleen opnieuw geprobeerd bij een aanroep die veilig twee keer verstuurd kan worden: een GET, of een create met een Idempotency-Key. Een create zonder sleutel laat de SDK aan jou over, omdat hij niet kan weten of de eerste poging is aangekomen. Dat is de praktische reden om op elke upload een sleutel te zetten.
Elke response bevat ook RateLimit-Limit, RateLimit-Remaining en RateLimit-Reset, zodat een drukke pipeline kan vertragen vóór de 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…"
}
De volledige tabel met codes staat in Fouten, retries en limieten. Die 402 is de enige fout die met geld te maken heeft, dus hier is wat een bestand kost.
Bestanden kosten $0.20 per uur audio, per seconde gefactureerd ($0.0033 per minuut) met een minimum van 5 minuten: een bestand van 30 minuten kost $0.10. Dat is tien cent, minder dan de koffie die je drinkt terwijl de voortgangsbalk zich vult. Het tarief is hetzelfde op elk abonnement, en hetzelfde via de API als in het Dashboard. Een nieuw account begint met een eenmalig tegoed van $5 bij registratie, en er is geen creditcard nodig.
Drie abonnementslimieten raken deze tutorial.
GET /v1/realtime/capabilities rapporteert als limits.maxUploadBytes. De kleinste van de twee geldt, en een bestand dat erboven zit wordt geweigerd met een 413 voordat er iets getranscribeerd wordt. Voor een lange opname is dit een reden te meer om een gecomprimeerd mono-bestand te uploaden in plaats van een WAV.GET /v1/billing geeft op elk moment je abonnement, saldo en limieten terug; de volledige tabel staat op de prijspagina.
Vanaf hier zijn de volgende stappen audio in realtime streamen wanneer je niet kunt wachten tot een bestand klaar is, of een AI-agent verbinden met hetzelfde account via de MCP-server, zodat die transcribeert en zoekt voor jou zonder een van de code hierboven.