Ein Upload liefert einen Job, kein Transkript. Setzen Sie bei jedem Anlegen einen Idempotency-Key, damit ein Wiederholungsversuch nie eine zweite Transkription anlegen oder abrechnen kann; fragen Sie dann den Job ab, bis er fertig ist, und lesen Sie Sätze, Wörter oder einen Export unter derselben ID.
Eine Dateitranskription ist die einfachste Aufgabe der VocalStack API, und sie folgt dem Muster jedes asynchronen Jobs: hochladen, warten, lesen. Die Antwort auf den Upload kommt sofort mit einer Transkription und ihrem Job, aber noch ohne Wörter. Während Ihr Programm den Job abfragt, transkribiert der Server die Datei um ein Vielfaches schneller, als das Audio abgespielt wird. Ist der Status done erreicht, lesen Sie das Transkript als Sätze mit Sprechern und Zeitangaben oder exportieren es als Datei in einem von vier Formaten, entweder in der gesprochenen Sprache oder übersetzt.
Alles in diesem Ablauf gilt für jede Batch-API: ein Anlegen, das eine ID zurückgibt, ein Status, den Sie abfragen, und ein Ergebnis, das Sie anschließend abrufen. Nur die Feldnamen sind spezifisch für VocalStack. Dieses Tutorial geht sie mit dem TypeScript- und dem Python-SDK durch, dazu an den Stellen, wo es hilft, die HTTP-Aufrufe.
Öffnen Sie Developers → API keys im Dashboard und legen Sie einen Schlüssel mit den Berechtigungen Read und Write an. Der Schlüssel wird nur einmal angezeigt; kopieren Sie ihn in eine Umgebungsvariable:
export VOCALSTACK_API_KEY="<your API key>"
Ein Schlüssel ist für Server und Skripte gedacht. Alles, was in einen Browser ausgeliefert wird, bringt den Schlüssel mit. Halten Sie ihn deshalb aus dem Seitencode und aus URLs heraus, denn URLs landen in Logs. Um aus einem Browser zu streamen, legt Ihr Server dafür eine Echtzeit-Sitzung an; das ist ein anderes Tutorial.
Jeder Aufruf authentifiziert sich mit dem Schlüssel als Bearer-Token:
Authorization: Bearer vs_key_...
Mit dem Schlüssel in Ihrer Umgebung brauchen Sie als Nächstes einen Client, der ihn sendet.
Es gibt zwei SDKs. Jedes hat einen Client, VocalStack, dessen Methoden nach Ressourcen gruppiert sind: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime und einige weitere. Das TypeScript-SDK läuft unter Node 20 oder neuer und im Browser; das Python-SDK basiert auf asyncio und braucht Python 3.10 oder neuer.
Die SDKs sind weder auf npm noch auf PyPI veröffentlicht. Die API liefert die Builds an jedes Konto aus. Die Installationsbefehle, für Ihre Umgebung ausgefüllt, stehen im Abschnitt Install der Schnellanleitung in der API-Referenz; sie senden Ihren Schlüssel nur an den API-Host. Wenn Sie lieber gar nichts installieren möchten, kommen die HTTP-Aufrufe unten mit curl und jq aus.
Egal, wofür Sie sich entscheiden, der erste Aufruf ist der Upload.
POST /v1/transcriptions nimmt die Datei als multipart/form-data entgegen: einen file-Teil und optional einen options-Teil mit JSON. Die Antwort ist 201 mit der Transkription. Ihr job.recordingId ist die ID der Aufnahme, die aus der Datei entsteht, und jeder folgende Aufruf wird über diese ID adressiert.
Zwei Einstellungen sind bei einem ersten Lauf wichtig, beide innerhalb von options.settings:
spokenLanguage: ein Code wie en. Lassen Sie ihn weg, wird die Sprache erkannt. Wenn Sie sie angeben, verbessert sich die Genauigkeit bei verrauschten Aufnahmen und starkem Akzent.speakerLabels: auto, on oder off. Bei on trägt jeder Satz eine Sprechernummer; bei auto erscheinen die Labels erst, sobald ein zweiter Sprecher zu hören war.Die meisten Audio- und Videodateien werden akzeptiert: mp3, wav, flac, ogg, m4a, mp4 und weitere. Bei einem Video bleibt nur die Tonspur erhalten. Das Format beeinflusst die Genauigkeit kaum, denn die Spracherkennung arbeitet mit 16 kHz Mono, und alles wird bei der Ankunft darauf umgerechnet. Eine 48-kHz-Stereo-WAV, auf der eine Person spricht, verbraucht den größten Teil ihrer Bytes für die Beschreibung von Stille in hoher Qualität. Sie wird genauso gut transkribiert wie eine Mono-MP3 mit 64 kbit/s oder eine Opus-Datei desselben Vortrags, ist aber etwa zwanzigmal größer und braucht deshalb beim Hochladen zwanzigmal so lang. Wenn Sie vor dem Hochladen konvertieren, sind Mono und eine moderate Bitrate die beiden Einstellungen, die sich lohnen.
Senden Sie mit jedem Upload einen Idempotency-Key und verstehen Sie, warum. Ein Netzwerk-Timeout sagt Ihnen nichts darüber, was der Server getan hat: Er hat die Anfrage vielleicht nie gesehen, oder er hat die Datei gespeichert und den Job eingereiht, bevor die Antwort verloren ging. Ein blinder Wiederholungsversuch riskiert daher eine zweite Transkription und eine zweite Abbuchung. Der Schlüssel verhindert das. Wird eine Anfrage mit demselben Schlüssel und Body innerhalb von 24 Stunden wiederholt, liefert der Server die erste Antwort zurück, statt etwas anzulegen. So wird aus „mindestens einmal“ ein „effektiv genau einmal“. Die Regel lautet daher: ein Schlüssel pro logischem Upload, erzeugt vor dem ersten Versuch und bei jedem Wiederholungsversuch wiederverwendet. Ein neuer Schlüssel pro Versuch würde jeden Wiederholungsversuch zu einem neuen Upload machen, und der Header würde nichts schützen.
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
Derselbe Aufruf mit 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)
Der 201 kommt zurück, bevor auch nur ein Wort transkribiert ist; was Sie in Händen halten, ist eine Job-ID. Der nächste Schritt ist also das Warten auf diesen Job.
Ein Job wandert von queued → transcribing → done, oder er endet in failed mit einer error-Meldung und, wenn die Ursache maschinenlesbar ist, einem errorCode. Ein vierter Zustand, cropping, bedeutet, dass der Job geparkt ist, bis jemand den Abschnitt der Aufnahme auswählt, der transkribiert werden soll, so wie es der Zuschneide-Schritt im Dashboard tut. Ein gewöhnlicher Upload erreicht ihn nie, auch wenn jobs.wait zurückkehrt, sobald ein Job ihn erreicht. Lesen Sie GET /v1/recordings/{id}/job, bis der Zustand done oder failed ist.
Es gibt keine Webhooks, und das ist ein bewusster Kompromiss. Ein Webhook braucht auf Ihrer Seite einen öffentlichen HTTPS-Endpunkt, eine Signaturprüfung, damit niemand sonst ihn aufrufen kann, und eine Wiederholungsstrategie für den Fall, dass Ihr Endpunkt ausfällt. Das ist viel Aufwand für einen Job, der eine Minute dauert. Polling kommt ohne all das aus. Ihr Skript fragt alle zwei Sekunden „schon fertig?“, wie ein Kind auf dem Rücksitz, nur dass es anders als das Kind eine Prozentzahl zurückbekommt.
Eine bereits geöffnete Webseite kann stattdessen den Ereignis-Socket der Transkription verfolgen (WS /v1/transcriptions/{id}/events, ein job.progress-Frame, sobald der Job voranschreitet). Für ein Skript ist Polling das richtige Werkzeug.
Die SDKs übernehmen das Polling: vs.jobs.wait liest den Job alle 2 Sekunden und übergibt jeden Lesevorgang an onProgress (on_progress in Python). Der progress des Jobs enthält pct (0 bis 100) und processedS, die bisher transkribierten Medien-Sekunden. Die Programme unten geben nur dann eine Zeile aus, wenn sich Zustand oder Prozentzahl ändern.
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}")
Beachten Sie, dass wait bei failed nicht auslöst; es gibt den Job zurück und überlässt Ihnen die Prüfung. Zwei Sekunden passen für einen Vortrag. Bei einer dreistündigen Aufnahme oder einem Stapel von Hunderten vergrößern Sie das Intervall (intervalMs, interval_s) oder warten während der Verarbeitung immer länger zwischen den Abfragen: Die ersten Lesevorgänge sagen am meisten, und die späteren bestätigen meist nur, dass der Server noch beschäftigt ist. Ohne SDK schreiben Sie dieselbe Schleife von 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
Steht der Zustand auf done, ist das Transkript bereit.
GET /v1/recordings/{id}/transcript liefert das gesamte Transkript in einem Dokument:
sentences: das, was man Menschen zeigt. Jeder Eintrag hat text, speaker sowie start und end in Sekunden.segments: die Zeilen der Engine, jede mit Zeitangaben auf Wortebene in words (jedes Wort hat text, start und end).speakers: ein Eintrag pro Sprechernummer, mit seinem name.Die Sprecher sind ab 0 nummeriert. Ein speaker mit dem Wert -1 bedeutet, dass Sprecher-Labels für die Aufnahme ausgeschaltet waren. Hängen Sie ?lang=es an, um das Transkript ins Spanische übersetzt zu lesen; die Sätze kommen dann in dieser Sprache zurück.
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'
Die beiden Zeitebenen dienen zwei Aufgaben. Sätze sind die Einheit des Lesens, deshalb zeigt man sie in Untertiteln oder auf einer Transkriptseite. Wörter sind die Einheit, auf die man zeigt, deshalb durchläuft eine Hervorhebung im Karaoke-Stil, ein Suchtreffer oder ein Zuschnitt eines Clips stattdessen segments[].words. Die Exportformate werden aus Sätzen gebildet.
GET /v1/recordings/{id}/export liefert eine echte Datei. format ist eines von srt, json, xlsx oder pdf, und die Datei enthält alle Korrekturen. Mit lang= erhalten Sie eine übersetzte Datei: Der Export liest sich dann in dieser Sprache, eine Zeile pro Satz, und jeder noch nicht übersetzte Satz wird vorher übersetzt.
SRT sollte man vom Ansehen kennen, denn es ist das Untertitelformat, das fast jeder Player akzeptiert, und es ist reiner Text. Eine Datei ist eine Liste von Cues, getrennt durch Leerzeilen, und jeder Cue besteht aus einer Zählnummer, einem Zeitbereich und dem Text. Die Zeitstempel verwenden ein Komma vor den Millisekunden, eine französische Gewohnheit, die das Format nie abgelegt hat:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Ein Satz pro Cue ist die richtige Körnung: Jeder Cue lässt sich in der Zeit lesen, in der er auf dem Bildschirm steht, und keiner bricht mitten im Gedanken ab. Hat die Aufnahme Sprecher-Labels, beginnt der Text jedes Cues mit dem Namen des Sprechers und einem Doppelpunkt.
In der TypeScript-SDK gibt vs.recordings.export ein Response zurück; lesen Sie seine Bytes mit arrayBuffer(). In Python gibt es bytes zurück.
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
Die Transkription ist gespeichert. Sie erscheint daher unter Transcriptions im Dashboard, lässt sich per Link teilen und jederzeit später erneut lesen. Das ist die Kernschleife: anlegen, warten, lesen, exportieren. Der Rest dieses Tutorials behandelt die Varianten: Medien, die bereits online sind, die Fehler, auf die Sie stoßen werden, und was eine Datei kostet.
Wenn die Medien bereits online sind, geben Sie der API den Link, und sie holt die Datei selbst: eine öffentliche http(s)-Adresse (eine mp3, ein Video, eine Podcast-Folge) oder ein YouTube-Video. Der Aufruf ist derselbe POST /v1/transcriptions, mit einem JSON-Body { "url": "..." } anstelle des Multipart-Uploads, und name und settings wie zuvor. In den SDKs stellen vs.transcriptions.createFromUrl(url) (TypeScript) und vs.transcriptions.create(url=...) (Python) dieselbe Anfrage.
Bevor Sie etwas anlegen, verrät GET /v1/transcriptions/probe?url=, was ein Link ist. Sein kind ist einer von drei Werten:
file: eine Aufnahme mit einem Ende; hier transkribieren.live: ein YouTube-Livestream, ein endloser Radiostream oder eine HLS-Playlist ohne Ende. Ein Live-Link endet nie, deshalb lässt er sich nicht als Datei transkribieren; verwenden Sie ihn stattdessen als Quelle einer Live-Transkription.unknown: der Server konnte es nicht feststellen.Wenn sich der Link gar nicht als Medium öffnen lässt (eine Webseite zum Beispiel), ist ok gleich false, und problem erklärt den Grund in einem Satz. Die Prüfung ist optional und auf 60 Aufrufe pro Minute begrenzt.
Ein Link-Job beginnt in fetching, bevor er zu transcribing und done übergeht. Während er abruft, sagt progress.phase, was der Server gerade tut: fetching, solange Bytes eintreffen (mit fetchedBytes und fetchTotalBytes, wenn die Gegenseite mitteilt, wie viele es sind), oder extracting, während die Tonspur aus einem Video herausgelöst wird. progress.phasePct zeigt, wie weit der Job in dieser Phase ist. Ein langes Video braucht seine Zeit; fragen Sie daher alle paar Sekunden ab. vs.jobs.wait nimmt dafür 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}%`);
},
});
Der Leitfaden Einen Link transkribieren in der Referenz enthält das vollständige Programm in beiden Sprachen sowie in HTTP. Das Abrufen von Links bringt weitere Fehlerquellen mit sich; deshalb folgen hier die Fehler, die Sie tatsächlich sehen werden.
Jeder fehlgeschlagene Aufruf antwortet mit einem einzigen JSON-Envelope. code ist der String, nach dem Sie verzweigen, und status der HTTP-Status; message ist für Menschen gedacht und requestId für den Support. Unter data sagt retryable, ob das erneute Senden derselben Anfrage helfen kann, und details enthält, was der Code benötigt. Die SDKs werfen einen einzigen Fehlertyp, VocalStackError, mit denselben Feldern.
Drei Codes sollten Sie am ersten Tag behandeln.
402 insufficient_credit: Das Guthaben des Kontos ist aufgebraucht, und es wurde nichts abgebucht. details.balance ist das Guthaben in Dollar, details.topUpUrl ist die Stelle, an der Sie Guthaben aufladen. Wenn die Kosten vorab bekannt waren, wie bei einer Datei mit bekannter Länge, sagt details.required, wie hoch sie gewesen wären.429 rate_limited: Warten Sie Retry-After Sekunden. Hat details.metric den Wert creates_per_hour, haben Sie die Anlegerate des Tarifs erreicht (neue Transkriptionen pro Stunde), kein Burst-Limit; limit und resetsAt sagen, wie hoch die Grenze ist und wann sie wieder frei wird.503 unavailable: Der Dienst ist kurzzeitig nicht verfügbar. Senden Sie die Anfrage nach Retry-After erneut.Die SDKs wiederholen ein 429 und ein 503 nach Retry-After, standardmäßig zweimal (maxRetries bzw. max_retries am Client). Ein 503 wird jedoch nur bei einem Aufruf wiederholt, der sicher zweimal gesendet werden kann: ein GET oder ein Anlegen mit Idempotency-Key. Ein Anlegen ohne Schlüssel bleibt Ihnen überlassen, weil das SDK nicht wissen kann, ob der erste Versuch durchging. Das ist der praktische Grund, warum Sie jeden Upload mit einem Schlüssel versehen sollten.
Jede Antwort enthält außerdem RateLimit-Limit, RateLimit-Remaining und RateLimit-Reset, damit eine ausgelastete Pipeline vor dem 429 langsamer werden kann.
{
"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…"
}
Die vollständige Tabelle der Codes steht unter Fehler, Wiederholungen und Limits. Dieses 402 ist der einzige Fehler, der mit Geld zu tun hat, deshalb folgt nun, was eine Datei kostet.
Dateien kosten $0.20 pro Stunde Audio, abgerechnet pro Sekunde ($0.0033 pro Minute) mit einem Mindestbetrag von 5 Minuten: Eine 30-minütige Datei kostet $0.10. Das sind zehn Cent, weniger, als der Kaffee kostet, den Sie trinken, während sich der Fortschrittsbalken füllt. Der Preis ist in jedem Tarif gleich, über die API ebenso wie im Dashboard. Ein neues Konto startet mit einem einmaligen Anmeldeguthaben von $5, und es ist keine Karte nötig.
Drei Tarifgrenzen betreffen dieses Tutorial.
GET /v1/realtime/capabilities als limits.maxUploadBytes ausweist. Es gilt der kleinere der beiden Werte; eine Datei darüber wird mit einem 413 abgelehnt, bevor etwas transkribiert wird. Bei einer langen Aufnahme ist das ein weiterer Grund, eine komprimierte Mono-Datei statt einer WAV hochzuladen.GET /v1/billing zeigt Ihren Tarif, Ihr Guthaben und Ihre Limits jederzeit an; die vollständige Tabelle steht auf der Preisseite.
Als Nächstes bieten sich an: Audio in Echtzeit streamen, wenn Sie nicht warten können, bis eine Datei fertig ist, oder einen KI-Agenten anbinden an dasselbe Konto über den MCP-Server, sodass er für Sie transkribiert und durchsucht, ganz ohne den Code von oben.