En uppladdning returnerar ett jobb, inte ett transkript. Sätt en Idempotency-Key på varje create så att ett återförsök aldrig kan skapa eller fakturera en andra transkribering. Poll sedan jobbet tills det är klart och läs meningar, ord eller en export från samma id.
Att transkribera en fil är det enklaste VocalStack API:et gör, och det har formen av ett vanligt asynkront jobb: ladda upp, vänta, läs. Uppladdningen svarar direkt med en transkribering och dess jobb, men ännu inga ord. Medan ditt program pollar jobbet transkriberar servern filen många gånger snabbare än ljudet spelas upp. När done är status läser du transkriptet som meningar med talare och tidsangivelser, eller exporterar det som en fil i ett av fyra format, antingen på det talade språket eller översatt.
Allt i den här formen gäller för alla batch-API:er: ett create som returnerar ett id, en status du pollar, ett resultat du hämtar efteråt. Bara fältnamnen är specifika för VocalStack. Den här handledningen går igenom dem med TypeScript- och Python-SDK:erna, och med HTTP-anropen där de hjälper.
Öppna Developers → API keys i Dashboarden och skapa en nyckel med behörigheterna Read och Write. Nyckeln visas bara en gång. Kopiera den till en miljövariabel:
export VOCALSTACK_API_KEY="<your API key>"
En nyckel är till för servrar och skript. Allt som skickas till en webbläsare skickar nyckeln med sig, så håll den borta från sidkod och från URL:er, som hamnar i loggar. Vill du strömma från en webbläsare skapar din server en realtidssession åt den; det är en annan handledning.
Varje anrop autentiserar med nyckeln som bearer-token:
Authorization: Bearer vs_key_...
Med nyckeln i din miljö är nästa steg en klient som skickar den.
Det finns två SDK:er. Var och en har en klient, VocalStack, med sina metoder grupperade per resurs: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime och några till. TypeScript-SDK:n körs på Node 20 eller nyare och i webbläsare. Python-SDK:n bygger på asyncio och kräver Python 3.10 eller nyare.
SDK:erna finns inte på npm eller PyPI. API:et serverar byggena till varje konto. Installationskommandona, ifyllda för din miljö, finns i avsnittet Install i snabbstarten i API-referensen, och de skickar din nyckel bara till API-värden. Vill du inte installera något alls räcker HTTP-anropen nedan med curl och jq.
Oavsett vad du väljer är det första anropet uppladdningen.
POST /v1/transcriptions tar filen som multipart/form-data: en file-del, plus en valfri options-del med JSON. Det svarar med 201 och transkriberingen. Dess job.recordingId är id:t för inspelningen som skapats av filen, och alla anrop som följer adresseras med det id:t.
Två inställningar spelar roll för en första körning, båda inuti options.settings:
spokenLanguage: en kod som en. Utelämnar du den detekteras språket. Anger du den förbättras noggrannheten på brusiga inspelningar och kraftiga accenter.speakerLabels: auto, on eller off. Med on får varje mening ett talarnummer. Med auto visas etiketter först när en andra talare har hörts.De flesta ljud- och videofiler accepteras: mp3, wav, flac, ogg, m4a, mp4 och andra. En video behåller bara sitt ljud. Formatet spelar knappt någon roll för noggrannheten, eftersom taligenkänningen arbetar med 16 kHz mono och allt omsamplas till det när det kommer fram. En 48 kHz stereo-WAV med en person som talar spenderar större delen av sina bytes på att beskriva tystnad i hög upplösning. Den transkriberas exakt lika bra som en mono-MP3 eller Opus-fil på 64 kbps av samma föredrag, men den är ungefär tjugo gånger större, så uppladdningen tar tjugo gånger så lång tid. Konverterar du före uppladdningen är mono och en måttlig bitrate de två inställningar som är värda att ändra.
Skicka en Idempotency-Key med varje uppladdning, och förstå varför. En nätverkstimeout säger dig ingenting om vad servern gjorde: den kanske aldrig såg begäran, eller så kanske den lagrade filen och köade jobbet innan svaret försvann. Ett blint återförsök riskerar därför en andra transkribering och en andra debitering. Nyckeln löser det. När en begäran upprepas med samma nyckel och samma innehåll inom 24 timmar returnerar servern det första svaret i stället för att skapa något, vilket gör ”minst en gång” till ”effektivt en gång”. Regeln är alltså en nyckel per logisk uppladdning, genererad före första försöket och återanvänd vid varje återförsök. En ny nyckel per försök skulle göra varje återförsök till en ny uppladdning, och headern skulle inte skydda något.
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
Samma anrop med 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)
201-svaret kommer innan ett enda ord har transkriberats. Det du har i handen är ett jobb-id, så nästa steg är att vänta på det.
Ett jobb går från queued → transcribing → done, eller slutar i failed med ett error-meddelande och, när orsaken är maskinläsbar, en errorCode. Ett fjärde tillstånd, cropping, betyder att jobbet står parkerat tills någon väljer vilken del av inspelningen som ska transkriberas, precis som Dashboardens klippsteg gör. En vanlig uppladdning hamnar aldrig där, även om jobs.wait också returnerar när ett jobb når det. Läs GET /v1/recordings/{id}/job tills statusen är done eller failed.
Det finns inga webhooks, och det är ett medvetet val. En webhook kräver en publik HTTPS-endpoint hos dig, en signaturkontroll så att ingen annan kan anropa den, och en återförsökspolicy för när din endpoint är nere. Det är mycket krångel för ett jobb som tar en minut. Polling behöver inget av det. Ditt skript frågar ”klart än?” varannan sekund, som ett barn på baksätet, men till skillnad från barnet får det en procentsats tillbaka.
En webbsida som redan är öppen kan i stället följa transkriberingens händelsesocket (WS /v1/transcriptions/{id}/events, en job.progress-ram när jobbet går framåt). För ett skript är polling rätt verktyg.
SDK:erna sköter pollingen: vs.jobs.wait läser jobbet var 2:e sekund och skickar varje läsning till onProgress (on_progress i Python). Jobbets progress innehåller pct (0 till 100) och processedS, antalet mediesekunder som hittills transkriberats. Programmen nedan skriver bara ut en rad när statusen eller procenten ändras.
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}")
Observera att wait inte kastar fel vid failed. Den returnerar jobbet och överlåter kontrollen till dig. Två sekunder passar ett föredrag. För en inspelning på tre timmar, eller en batch med hundratals, gör intervallet längre (intervalMs, interval_s) eller backa när jobbet körs. De första läsningarna berättar mest, och de senare bekräftar mest att servern fortfarande är upptagen. Utan SDK skriver du samma loop för 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
När statusen visar done är transkriptet klart.
GET /v1/recordings/{id}/transcript svarar med hela transkriptet i ett dokument:
sentences: det som ska visas för människor. Varje har text, speaker samt start och end i sekunder.segments: motorns rader, var och en med ordnivå-tider i words (varje ord har text, start och end).speakers: en post per talarnummer, med dess name.Talarna numreras från 0. En speaker med värdet -1 betyder att talaretiketter var avstängda för inspelningen. Lägg till ?lang=es för att läsa transkriptet översatt till spanska. Meningarna kommer tillbaka på det språket.
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 två tidsnivåerna tjänar två uppgifter. Meningar är enheten för läsning, så det är vad en undertext eller en transkriptsida visar. Ord är enheten för pekning, så en karaokeliknande markering, en sökträff eller ett klipp går i stället igenom segments[].words. Exportformaten byggs av meningar.
GET /v1/recordings/{id}/export returnerar en riktig fil. format är en av srt, json, xlsx eller pdf, och filen innehåller alla rättelser. Lägg till lang= för en översatt fil: exporten läses då på det språket, en rad per mening, och eventuella meningar som ännu inte är översatta översätts först.
SRT är bra att känna igen, eftersom det är undertextformatet som nästan varje spelare accepterar och eftersom det är ren text. En fil är en lista med cues åtskilda av tomma rader, och varje cue är ett räknare, ett tidsintervall och texten. Tidsstämplarna använder komma före millisekunderna, en fransk vana som formatet aldrig har lagt av sig:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
En mening per cue är rätt kornighet: varje cue kan läsas på den tid den visas på skärmen, och ingen avbryts mitt i en tanke. Har inspelningen talaretiketter börjar varje cues text med talarens namn och ett kolon.
I TypeScript-SDK:n returnerar vs.recordings.export ett Response. Läs dess byte med arrayBuffer(). I Python returnerar den 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
Transkriberingen är lagrad, så den visas under Transcriptions i Dashboarden, kan delas med en länk och kan läsas igen när som helst senare. Det är kärnloopen: skapa, vänta, läs, exportera. Resten av handledningen tar upp varianterna: media som redan finns online, felen du kommer att möta och vad en fil kostar.
Om mediet redan finns online ger du API:et länken, så hämtar det filen själv. Det kan vara en publik http(s)-adress (en mp3, en video, ett podcastavsnitt) eller en YouTube-video. Anropet är detsamma, POST /v1/transcriptions, med en JSON-kropp { "url": "..." } i stället för den multipart-uppladdningen, och name och settings som förut. I SDK:erna gör vs.transcriptions.createFromUrl(url) (TypeScript) och vs.transcriptions.create(url=...) (Python) samma förfrågan.
Innan du skapar något berättar GET /v1/transcriptions/probe?url= vad en länk är. Dess kind är en av tre:
file: en inspelning med ett slut. Transkribera den här.live: en YouTube-livesändning, en oändlig radioström eller en HLS-spellista utan slut. En livelänk tar aldrig slut, så den kan inte transkriberas som en fil. Använd den i stället som källa för en livetranskribering.unknown: servern kunde inte avgöra.När länken inte öppnas som media alls (en webbsida, till exempel) är ok false, och problem förklarar varför i en mening. Proben är valfri och begränsad till 60 anrop per minut.
Ett länkjobb startar i fetching innan det går över till transcribing och done. Medan det hämtar säger progress.phase vad servern gör: fetching medan bytes kommer in (med fetchedBytes och fetchTotalBytes när den andra änden säger hur många), eller extracting medan ljudet tas ut ur en video. progress.phasePct säger hur långt jobbet har kommit i den fasen. En lång video tar en stund, så polla några sekunder åt gången. vs.jobs.wait tar intervalMs (interval_s i Python) för det.
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}%`);
},
});
Guiden Transkribera en länk i referensen har hela programmet på båda språken och i HTTP. Hämtning från länkar ger fler sätt att misslyckas, så här är felen du faktiskt kommer att se.
Varje misslyckat anrop svarar med ett JSON-kuvert. code är strängen att förgrena på och status är HTTP-statusen. message är till för människor och requestId för support. Under data säger retryable om det hjälper att skicka samma förfrågan igen, och details innehåller det koden behöver. SDK:erna kastar en feltyp, VocalStackError, med samma fält.
Tre koder är värda att hantera redan första dagen.
402 insufficient_credit: kontots kredit är slut, och ingenting debiterades. details.balance är saldot i dollar och details.topUpUrl är var du lägger till kredit. När kostnaden var känd i förväg, som för en fil med känd längd, säger details.required vad den hade blivit.429 rate_limited: vänta Retry-After sekunder. När details.metric är creates_per_hour har du nått planens skapandegräns (nya transkriberingar per timme), inte en burst-gräns. limit och resetsAt säger vad taket är och när det frigörs.503 unavailable: tjänsten är tillfälligt otillgänglig. Skicka förfrågan igen efter Retry-After.SDK:erna försöker igen vid 429 och 503 efter Retry-After, två gånger som standard (maxRetries / max_retries på klienten). Ett 503 försöks dock bara igen på ett anrop som är säkert att skicka två gånger: en GET, eller ett create med en Idempotency-Key. Ett create utan nyckel lämnas åt dig, eftersom SDK:n inte kan veta om första försöket gick igenom. Det är den praktiska anledningen till att lägga en nyckel på varje uppladdning.
Varje svar bär också RateLimit-Limit, RateLimit-Remaining och RateLimit-Reset, så att en pipeline med mycket trafik kan sakta ner innan 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…"
}
Hela tabellen med koder finns i Fel, återförsök och gränser. Det där 402-felet är det enda som är kopplat till pengar, så här är vad en fil kostar.
Filer kostar $0.20 per timme ljud, debiterat per sekund ($0.0033 per minut) med en minimiperiod på 5 minuter: en fil på 30 minuter kostar $0.10. Det är tio cent, mindre än kaffet du dricker medan förloppsindikatorn fylls. Priset är detsamma i varje abonnemang och detsamma via API:et som i Dashboarden. Ett nytt konto börjar med en engångskredit på $5 vid registreringen, och inget kort krävs.
Tre abonnemangsgränser påverkar den här handledningen.
GET /v1/realtime/capabilities rapporterar som limits.maxUploadBytes. Det mindre av de två gäller, och en fil som är större avvisas med en 413 innan något transkriberas. För en lång inspelning är detta ytterligare ett skäl att ladda upp en komprimerad mono-fil i stället för en WAV.GET /v1/billing rapporterar ditt abonnemang, saldo och gränser när som helst. Hela tabellen finns på prissidan.
Härifrån är nästa steg streaming av ljud i realtid när du inte kan vänta på att en fil är klar, eller att koppla en AI-agent till samma konto via MCP-servern. Då transkriberar och söker agenten åt dig utan någon av koden ovan.