Przesłanie zwraca zadanie, a nie transkrypcję. Dołącz Idempotency-Key do każdego tworzenia, aby ponowienie nigdy nie utworzyło ani nie obciążyło drugiej transkrypcji; potem odpytuj zadanie, aż się skończy, i czytaj zdania, słowa lub eksport z tego samego identyfikatora.
Transkrypcja pliku to najprostsza rzecz, jaką robi API VocalStack, i ma kształt każdego asynchronicznego zadania: przesłanie, oczekiwanie, odczyt. Przesłanie od razu zwraca transkrypcję wraz z jej zadaniem, ale jeszcze bez słów. Podczas gdy program odpytuje zadanie, serwer przetwarza plik wielokrotnie szybciej, niż trwa jego odtwarzanie. Gdy stan to done, czytasz transkrypt jako zdania z mówcami i czasami albo eksportujesz go jako plik w jednym z czterech formatów, w języku mówionym lub przetłumaczonym.
Wszystko w tym schemacie przenosi się na dowolne API wsadowe: tworzenie zwracające identyfikator, status, który odpytujesz, i wynik pobierany później. Tylko nazwy pól są specyficzne dla VocalStack, a ten poradnik pokazuje je w SDK dla TypeScript i Pythona oraz, tam gdzie pomagają, w wywołaniach HTTP.
Otwórz Developers → API keys w panelu i utwórz klucz z uprawnieniami Read i Write. Klucz pokazywany jest tylko raz; skopiuj go do zmiennej środowiskowej:
export VOCALSTACK_API_KEY="<your API key>"
Klucz służy serwerom i skryptom. Wszystko, co trafia do przeglądarki, dostaje klucz razem z kodem, więc trzymaj go z dala od kodu strony i z dala od adresów URL, które trafiają do logów. Aby strumieniować z przeglądarki, twój serwer tworzy dla niej sesję czasu rzeczywistego; to inny poradnik.
Każde wywołanie uwierzytelnia się kluczem jako token bearer:
Authorization: Bearer vs_key_...
Mając klucz w środowisku, kolejnym krokiem jest klient, który go wyśle.
Są dwa SDK. Każde ma jednego klienta, VocalStack, z metodami pogrupowanymi według zasobów: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime i kilka innych. SDK dla TypeScript działa w Node 20 lub nowszym oraz w przeglądarkach; SDK dla Pythona opiera się na asyncio i wymaga Pythona 3.10 lub nowszego.
SDK nie ma w npm ani w PyPI. API udostępnia wersje buildów każdemu kontu. Polecenia instalacji, uzupełnione o twoje środowisko, są w sekcji Install szybkiego startu w dokumentacji API; wysyłają one twój klucz wyłącznie do hosta API. Jeśli wolisz nic nie instalować, wywołania HTTP poniżej potrzebują tylko curl i jq.
Cokolwiek wybierzesz, pierwszym wywołaniem jest przesłanie.
POST /v1/transcriptions przyjmuje plik jako multipart/form-data: część file oraz opcjonalną część options z danymi w JSON. Odpowiada kodem 201 z transkrypcją. Jej job.recordingId to identyfikator nagrania utworzonego z pliku, a każde kolejne wywołanie adresuje się tym identyfikatorem.
Dwa ustawienia mają znaczenie przy pierwszym uruchomieniu, oba w options.settings:
spokenLanguage: kod, na przykład en. Jeśli go pominiesz, język zostanie wykryty. Podanie go poprawia dokładność przy hałaśliwych nagraniach i silnych akcentach.speakerLabels: auto, on albo off. Przy on każde zdanie ma numer mówcy; przy auto etykiety pojawiają się dopiero, gdy usłyszano drugiego mówcę.Przyjmowane są większość plików audio i wideo: mp3, wav, flac, ogg, m4a, mp4 i inne. Z wideo zostaje tylko dźwięk. Format ma niewielkie znaczenie dla dokładności, bo rozpoznawanie mowy działa na 16 kHz mono, a wszystko po przybyciu zostaje przekształcone do tej częstotliwości. Plik WAV 48 kHz stereo z jedną osobą mówiącą spędza większość bajtów na opisywaniu ciszy w wysokiej jakości. Transkrybuje się on dokładnie tak samo jak plik MP3 mono 64 kbps lub Opus z tą samą rozmową, ale jest około dwudziestu razy większy, więc jego przesłanie trwa dwadzieścia razy dłużej. Jeśli konwertujesz przed przesłaniem, warto zmienić dwa ustawienia: mono i umiarkowaną przepływność.
Wysyłaj Idempotency-Key z każdym przesłaniem i zrozum dlaczego. Przekroczenie czasu sieci nie mówi nic o tym, co zrobił serwer: mógł nigdy nie zobaczyć żądania albo mógł zapisać plik i umieścić zadanie w kolejce, zanim odpowiedź zaginęła. Ślepe ponowienie grozi więc drugą transkrypcją i drugą opłatą. Klucz to rozwiązuje. Gdy żądanie zostaje powtórzone z tym samym kluczem i treścią w ciągu 24 godzin, serwer zwraca pierwszą odpowiedź zamiast cokolwiek tworzyć, co zamienia „co najmniej raz” w „praktycznie dokładnie raz”. Zasada brzmi więc: jeden klucz na jedno logiczne przesłanie, wygenerowany przed pierwszą próbą i używany przy każdym ponowieniu. Nowy klucz przy każdej próbie sprawiłby, że każde ponowienie byłoby nowym przesłaniem i nagłówek nie chroniłby niczego.
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
To samo wywołanie w 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)
Kod 201 wraca, zanim zostanie przepisane choć jedno słowo; masz w ręku identyfikator zadania, więc następnym krokiem jest czekanie na nie.
Zadanie przechodzi przez stany queued → transcribing → done albo kończy się w stanie failed z komunikatem error i, gdy przyczyna jest czytelna dla maszyny, z errorCode. Czwarty stan, cropping, oznacza, że zadanie czeka, aż ktoś wybierze fragment nagrania do transkrypcji, tak jak robi to krok przycinania w panelu. Zwykłe przesłanie nigdy go nie osiąga, choć jobs.wait także zwraca się, gdy zadanie tam dotrze. Odczytuj GET /v1/recordings/{id}/job, aż stan będzie done lub failed.
Nie ma webhooków i to świadomy wybór. Webhook wymaga publicznego endpointu HTTPS po twojej stronie, sprawdzania podpisu, by nikt inny nie mógł go wywołać, oraz polityki ponowień na wypadek, gdy endpoint jest niedostępny. To sporo instalacji dla zadania, które trwa minutę. Odpytywanie nie wymaga niczego z tego. Twój skrypt będzie pytał „czy już?” co dwie sekundy, jak dziecko na tylnym siedzeniu, a w przeciwieństwie do dziecka dostanie w odpowiedzi procent.
Strona internetowa, która jest już otwarta, może zamiast tego śledzić gniazdo zdarzeń transkrypcji (WS /v1/transcriptions/{id}/events, ramka job.progress przy postępie zadania). Dla skryptu odpytywanie to właściwe narzędzie.
SDK same odpytują: vs.jobs.wait czyta zadanie co 2 sekundy i przekazuje każdy odczyt do onProgress (on_progress w Pythonie). progress zadania zawiera pct (0 do 100) i processedS, czyli sekundy nagrania przepisane do tej pory. Poniższe programy wypisują linię tylko wtedy, gdy zmienia się stan lub procent.
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}")
Uwaga: wait nie zgłasza wyjątku przy failed; zwraca zadanie i zostawia sprawdzenie tobie. Dwie sekundy wystarczą dla wykładu. Dla trzygodzinnego nagrania albo partii setek plików zwiększ interwał (intervalMs, interval_s) lub stopniowo wydłużaj przerwy w miarę pracy zadania: pierwsze odczyty mówią najwięcej, a późniejsze w większości tylko potwierdzają, że serwer nadal jest zajęty. Bez SDK napiszesz tę samą pętlę ręcznie:
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
Gdy stan pokaże done, transkrypt jest gotowy.
GET /v1/recordings/{id}/transcript zwraca cały transkrypt w jednym dokumencie:
sentences: to, co pokazać ludziom. Każde ma text, speaker oraz start i end w sekundach.segments: linie silnika, każda z czasami słów w words (każde słowo ma text, start i end).speakers: jeden wpis na numer mówcy, z jego name.Mówcy są numerowani od 0. speaker równe -1 oznacza, że etykiety mówców były wyłączone dla nagrania. Dodaj ?lang=es, aby odczytać transkrypt przetłumaczony na hiszpański; zdania wrócą w tym języku.
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'
Dwa poziomy czasu służą dwóm zadaniom. Zdania to jednostka czytania, więc to je pokazują napisy albo strona transkryptu. Słowa to jednostka wskazywania, więc podświetlenie w stylu karaoke, wynik wyszukiwania albo wycięcie klipu przechodzi przez segments[].words. Formaty eksportu powstają ze zdań.
GET /v1/recordings/{id}/export zwraca prawdziwy plik. format to jedno z: srt, json, xlsx albo pdf, a plik zawiera wszystkie poprawki. Dodaj lang=, by otrzymać plik przetłumaczony: eksport czyta się wtedy w tym języku, po jednej linii na zdanie, a każde zdanie, które nie zostało jeszcze przetłumaczone, jest najpierw tłumaczone.
Warto znać format SRT z wyglądu, bo to format napisów, który obsługuje niemal każdy odtwarzacz, a jest zwykłym tekstem. Plik to lista bloków oddzielonych pustymi liniami; każdy blok to numer, zakres czasu i tekst. Znaczniki czasu używają przecinka przed milisekundami, francuskiego zwyczaju, którego ten format nigdy się nie pozbył.
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Jedno zdanie na blok to właściwa gradacja: każdy blok da się przeczytać w czasie, w którym jest na ekranie, a żaden nie urywa się w połowie myśli. Gdy nagranie miało etykiety mówców, tekst każdego bloku zaczyna się od nazwy mówcy i dwukropka.
W SDK dla TypeScript vs.recordings.export zwraca Response; odczytaj jego bajty przez arrayBuffer(). W Pythonie zwraca 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
Transkrypcja jest zapisana, więc pojawia się w sekcji Transcriptions w panelu, można ją udostępnić linkiem i odczytać ponownie w dowolnym momencie. To podstawowa pętla: utwórz, czekaj, odczytaj, wyeksportuj. Reszta poradnika omawia odmiany: media już dostępne online, błędy, na które się natkniesz, i koszt pliku.
Jeśli media są już online, przekaż API link, a ono samo pobierze plik: publiczny adres http(s) (mp3, wideo, odcinek podcastu) albo film z YouTube. Wywołanie jest to samo, POST /v1/transcriptions, z treścią JSON { "url": "..." } zamiast przesłania multipart, a name i settings jak poprzednio. W SDK vs.transcriptions.createFromUrl(url) (TypeScript) i vs.transcriptions.create(url=...) (Python) wysyłają to samo żądanie.
Zanim cokolwiek utworzysz, GET /v1/transcriptions/probe?url= powie ci, czym jest link. Jego kind to jedno z trzech:
file: nagranie z końcem; transkrybuj je tutaj.live: transmisja na żywo z YouTube, niekończący się strumień radiowy albo playlista HLS bez końca. Link na żywo nigdy się nie kończy, więc nie da się go transkrybować jako pliku; użyj go jako źródła transkrypcji na żywo.unknown: serwer nie mógł tego rozpoznać.Gdy link nie otwiera się jako media w ogóle (na przykład strona internetowa), ok ma wartość false, a problem wyjaśnia to jednym zdaniem. Sonda jest opcjonalna i ograniczona do 60 wywołań na minutę.
Zadanie z linku zaczyna się w fetching, zanim przejdzie do transcribing i done. Podczas pobierania progress.phase mówi, co robi serwer: fetching, gdy napływają bajty (z fetchedBytes i fetchTotalBytes, gdy druga strona podaje rozmiar), albo extracting, gdy z wideo wyodrębniany jest dźwięk. progress.phasePct mówi, jak daleko w tej fazie jest zadanie. Długie wideo potrzebuje czasu, więc odpytuj co kilka sekund; vs.jobs.wait przyjmuje w tym celu intervalMs (interval_s w Pythonie).
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}%`);
},
});
Pełny program w obu językach i w HTTP znajduje się w przewodniku referencyjnym Transkrypcja linku. Pobieranie z linków dodaje kolejne sposoby na porażkę, więc oto błędy, które naprawdę zobaczysz.
Każde nieudane wywołanie odpowiada jedną kopertą JSON. code to ciąg, według którego się rozgałęziasz, a status to status HTTP; message jest dla ludzi, a requestId dla pomocy technicznej. Pod data, retryable mówi, czy ponowne wysłanie tego samego żądania może pomóc, a details zawiera to, czego dany kod potrzebuje. SDK zgłaszają jeden typ błędu, VocalStackError, z tymi samymi polami.
Trzy kody warto obsłużyć od pierwszego dnia.
402 insufficient_credit: środki konta się wyczerpały i nic nie zostało pobrane. details.balance to saldo w dolarach, a details.topUpUrl to miejsce, gdzie dodasz środki. Gdy koszt był znany z góry, jak przy pliku o znanej długości, details.required mówi, ile by wyniósł.429 rate_limited: odczekaj Retry-After sekund. Gdy details.metric ma wartość creates_per_hour, osiągnięto limit tworzenia planu (nowych transkrypcji na godzinę), a nie limit krótkotrwały; limit i resetsAt mówią, jaki jest limit i kiedy się zwolni.503 unavailable: usługa jest chwilowo niedostępna. Wyślij żądanie ponownie po Retry-After.SDK ponawiają 429 i 503 po Retry-After, domyślnie dwa razy (maxRetries / max_retries na kliencie). 503 jest jednak ponawiane tylko przy wywołaniu bezpiecznym do wysłania dwukrotnie: GET albo tworzenie z Idempotency-Key. Tworzenie bez klucza zostaje w gestii ciebie, bo SDK nie może wiedzieć, czy pierwsza próba dotarła. To praktyczny powód, by dawać klucz przy każdym przesłaniu.
Każda odpowiedź niesie też RateLimit-Limit, RateLimit-Remaining i RateLimit-Reset, więc zajęty potok może zwolnić, zanim trafi w 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…"
}
Pełna tabela kodów znajduje się w Błędy, ponowienia i limity. Ten 402 to jedyny błąd związany z pieniędzmi, więc sprawdźmy, ile kosztuje plik.
Pliki kosztują $0.20 za godzinę audio, rozliczane co sekundę ($0.0033 za minutę) z minimum 5 minut: plik 30-minutowy kosztuje $0.10. To dziesięć centów, mniej niż kawa, którą wypijesz, gdy pasek postępu się zapełnia. Stawka jest taka sama w każdym planie i tak samo przez API, jak w panelu. Nowe konto zaczyna z jednorazowym kredytem powitalnym w wysokości $5, bez wymaganej karty.
Trzy limity planu dotyczą tego poradnika.
GET /v1/realtime/capabilities podaje jako limits.maxUploadBytes. Obowiązuje mniejszy z dwóch, a plik większy od niego zostaje odrzucony kodem 413, zanim cokolwiek zostanie przetranskrybowane. Przy długim nagraniu to kolejny powód, by przesyłać skompresowany plik mono zamiast WAV.GET /v1/billing podaje w każdej chwili twój plan, saldo i limity; pełna tabela jest na stronie cennika.
Dalej, kolejnymi krokami są strumieniowanie dźwięku w czasie rzeczywistym, gdy nie możesz czekać, aż plik się skończy, albo podłączenie agenta AI do tego samego konta przez serwer MCP, tak by transkrybował i przeszukiwał za ciebie, bez żadnego z powyższego kodu.