O încărcare returnează un job, nu o transcriere. Pune un Idempotency-Key pe fiecare creare, ca o reluare să nu creeze și să nu factureze niciodată o a doua transcriere; apoi interoghează jobul până e gata și citește propozițiile, cuvintele sau un export din același id.
Transcrierea unui fișier este cel mai simplu lucru pe care îl face API-ul VocalStack, iar forma lui este cea a oricărui job asincron: încarci, aștepți, citești. Încărcarea răspunde imediat cu o transcriere și jobul ei, dar încă fără cuvinte. Cât timp programul tău interoghează jobul, serverul transcrie fișierul de mai multe ori mai repede decât se redă audio-ul. Când starea este done, citești transcrierea ca propoziții cu vorbitori și momente de timp sau o exporți ca fișier într-unul din cele patru formate, fie în limba vorbită, fie tradusă.
Orice are această formă se transferă la orice API pentru procesare în lot: o creare care returnează un id, un status pe care îl interoghezi, un rezultat pe care îl descarci după aceea. Doar numele câmpurilor sunt specifice VocalStack, iar acest tutorial le parcurge cu SDK-urile TypeScript și Python și, acolo unde ajută, cu apelurile HTTP.
Deschide Developers → API keys în Dashboard și creează o cheie cu permisiunile Read și Write. Cheia se afișează o singură dată; copiaz-o într-o variabilă de mediu:
export VOCALSTACK_API_KEY="<your API key>"
O cheie este pentru servere și scripturi. Tot ce se livrează unui browser livrează și cheia, așa că păstreaz-o departe de codul paginii și din URL-uri, care ajung în jurnale. Ca să transmiți dintr-un browser, serverul tău creează o sesiune realtime pentru el; aceasta este un alt tutorial.
Fiecare apel se autentifică cu cheia ca token bearer:
Authorization: Bearer vs_key_...
Cu cheia în mediu, următorul lucru este un client care să o trimită.
Există două SDK-uri. Fiecare are un singur client, VocalStack, cu metodele grupate pe resurse: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime și câteva altele. SDK-ul TypeScript rulează pe Node 20 sau mai nou și în browsere; SDK-ul Python este construit pe asyncio și are nevoie de Python 3.10 sau mai nou.
SDK-urile nu sunt pe npm sau PyPI. API-ul servește build-urile fiecărui cont. Comenzile de instalare, completate pentru mediul tău, sunt în secțiunea Install din quickstart-ul din referința API, iar ele trimit cheia ta doar către gazda API-ului. Dacă preferi să nu instalezi nimic, apelurile HTTP de mai jos au nevoie doar de curl și jq.
Oricare ai alege, primul apel este încărcarea.
POST /v1/transcriptions primește fișierul ca multipart/form-data: o parte file, plus o parte opțională options cu JSON. Răspunde cu 201 și cu transcrierea. job.recordingId este id-ul înregistrării făcute din fișier, iar fiecare apel următor este adresat prin acest id.
Două setări contează la prima rulare, amândouă în options.settings:
spokenLanguage: un cod, cum ar fi en. Dacă îl lași gol, limba este detectată. Numirea ei îmbunătățește acuratețea la înregistrările zgomotoase și la accentele puternice.speakerLabels: auto, on sau off. Cu on, fiecare propoziție are un număr de vorbitor; cu auto, etichetele apar abia după ce a fost auzit un al doilea vorbitor.Majoritatea fișierelor audio și video sunt acceptate: mp3, wav, flac, ogg, m4a, mp4 și altele. Un video păstrează doar audio-ul. Formatul contează puțin pentru acuratețe, pentru că recunoașterea vorbirii lucrează la 16 kHz mono, iar totul este reeșantionat la acest format la sosire. Un WAV stereo de 48 kHz cu o singură persoană care vorbește își petrece majoritatea octeților descriind tăcerea în înaltă fidelitate. Se transcrie exact la fel ca un fișier MP3 sau Opus mono de 64 kbps al aceleiași prezentări, dar este de aproximativ douăzeci de ori mai mare, deci durează de douăzeci de ori mai mult să se încarce. Dacă convertești înainte de încărcare, mono și un bitrate moderat sunt cele două setări care merită schimbate.
Trimite un Idempotency-Key cu fiecare încărcare și înțelege de ce. Un timeout de rețea nu îți spune nimic despre ce a făcut serverul: poate nu a văzut niciodată cererea, sau poate a stocat fișierul și a pus jobul în coadă înainte ca răspunsul să se piardă. O reluare oarbă riscă deci o a doua transcriere și o a doua taxare. Cheia rezolvă asta. Când o cerere este repetată cu aceeași cheie și același corp în 24 de ore, serverul returnează primul răspuns în loc să creeze ceva, ceea ce transformă „cel puțin o dată” în „efectiv o singură dată”. Regula este deci o cheie pentru fiecare încărcare logică, generată înainte de prima încercare și refolosită la fiecare reluare. O cheie nouă la fiecare încercare ar face din fiecare reluare o nouă încărcare, iar antetul n-ar proteja nimic.
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
Același apel cu 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-ul vine înainte ca vreun cuvânt să fie transcris; ce ții în mână este un id de job, deci următorul pas este să aștepți după el.
Un job trece prin queued → transcribing → done, sau se încheie cu failed, cu un mesaj error și, când cauza poate fi citită de un program, cu un errorCode. A patra stare, cropping, înseamnă că jobul este pus în pauză până când cineva alege ce porțiune din înregistrare să fie transcrisă, cum face pasul de decupare din Dashboard. O încărcare obișnuită nu intră niciodată în ea, deși jobs.wait returnează și atunci când un job ajunge acolo. Citește GET /v1/recordings/{id}/job până când starea este done sau failed.
Nu există webhook-uri, iar asta este un compromis deliberat. Un webhook are nevoie de un endpoint HTTPS public la tine, de o verificare a semnăturii ca să nu-l poată apela altcineva și de o politică de reluare pentru când endpoint-ul tău este căzut, ceea ce înseamnă multă infrastructură pentru un job care durează un minut. Interogarea nu are nevoie de nimic din toate astea. Scriptul tău va întreba „gata?” la fiecare două secunde, ca un copil pe bancheta din spate, și, spre deosebire de copil, va primi înapoi un procent.
O pagină web deja deschisă poate urmări în schimb socket-ul de evenimente al transcrierii (WS /v1/transcriptions/{id}/events, un cadru job.progress pe măsură ce jobul avansează). Pentru un script, interogarea este instrumentul potrivit.
SDK-urile fac interogarea: vs.jobs.wait citește jobul la fiecare 2 secunde și trimite fiecare citire către onProgress (on_progress în Python). progress al jobului conține pct (de la 0 la 100) și processedS, secundele media transcrise până acum. Programele de mai jos afișează o linie doar când se schimbă starea sau procentul.
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}")
Ține cont că wait nu aruncă o excepție la failed; returnează jobul și lasă verificarea în seama ta. Două secunde se potrivesc pentru o prezentare. Pentru o înregistrare de trei ore, sau un lot de sute, mărește intervalul (intervalMs, interval_s) sau încetinește pe măsură ce jobul rulează: primele citiri spun cel mai mult, iar cele ulterioare confirmă mai ales că serverul este încă ocupat. Fără SDK, scrii aceeași buclă de mână:
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
Când starea arată done, transcrierea este gata.
GET /v1/recordings/{id}/transcript răspunde cu întreaga transcriere într-un singur document:
sentences: ce să arăți oamenilor. Fiecare are text, speaker, și start și end în secunde.segments: liniile motorului, fiecare cu temporizări la nivel de cuvânt în words (fiecare cuvânt are text, start și end).speakers: câte o intrare pentru fiecare număr de vorbitor, cu name-ul lui.Vorbitorii sunt numerotați de la 0. Un speaker de -1 înseamnă că etichetele de vorbitor erau dezactivate pentru înregistrare. Adaugă ?lang=es ca să citești transcrierea tradusă în spaniolă; propozițiile vin în acea limbă.
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'
Cele două niveluri de timp servesc două scopuri. Propozițiile sunt unitatea de citire, deci ele sunt ce arată un subtitlu sau o pagină de transcriere. Cuvintele sunt unitatea de indicare, deci o evidențiere în stil karaoke, un rezultat de căutare sau o tăiere de clip parcurge în schimb segments[].words. Formatele de export sunt construite din propoziții.
GET /v1/recordings/{id}/export returnează un fișier real. format este unul din srt, json, xlsx sau pdf, iar fișierul include eventualele corecturi. Adaugă lang= pentru un fișier tradus: exportul se citește atunci în acea limbă, câte o linie pe propoziție, iar orice propoziție încă netradusă este tradusă mai întâi.
SRT merită recunoscut din prima privire, pentru că este formatul de subtitrare pe care îl acceptă aproape orice player și pentru că este text simplu. Un fișier este o listă de blocuri separate prin linii goale, iar fiecare bloc este un contor, un interval de timp și textul. Marcajele de timp folosesc o virgulă înaintea milisecundelor, o obișnuință franceză pe care formatul n-a abandonat-o niciodată:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
O propoziție pe bloc este granularitatea potrivită: fiecare bloc poate fi citit în timpul cât stă pe ecran, iar niciunul nu taie o idee din mijloc. Când înregistrarea avea etichete de vorbitor, textul fiecărui bloc începe cu numele vorbitorului și două puncte.
În SDK-ul TypeScript, vs.recordings.export returnează un Response; citește-i octeții cu arrayBuffer(). În Python returnează 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
Transcrierea este stocată, deci apare sub Transcriptions în Dashboard, poate fi partajată prin link și poate fi citită din nou oricând mai târziu. Acesta este ciclul de bază: creezi, aștepți, citești, exporți. Restul tutorialului acoperă variantele: media deja online, erorile pe care le vei întâlni și cât costă un fișier.
Dacă media este deja online, dai API-ului linkul, iar el își aduce singur fișierul: o adresă publică http(s) (un mp3, un video, un episod de podcast) sau un video YouTube. Apelul este același POST /v1/transcriptions, cu un corp JSON { "url": "..." } în locul încărcării multipart, iar name și settings ca înainte. În SDK-uri, vs.transcriptions.createFromUrl(url) (TypeScript) și vs.transcriptions.create(url=...) (Python) fac aceeași cerere.
Înainte de a crea ceva, GET /v1/transcriptions/probe?url= îți spune ce este un link. kind este unul din trei:
file: o înregistrare cu final; transcrie-o aici.live: un stream live YouTube, un stream radio fără sfârșit sau o listă HLS fără capăt. Un link live nu se termină niciodată, deci nu poate fi transcris ca fișier; folosește-l ca sursă a unei transcrieri live.unknown: serverul nu a putut stabili.Când linkul nu se deschide deloc ca media (o pagină web, de exemplu), ok este false, iar problem explică motivul într-o singură propoziție. Probe-ul este opțional și limitat la 60 de apeluri pe minut.
Un job pe link începe în fetching înainte să treacă în transcribing și done. Cât timp descarcă, progress.phase spune ce face serverul: fetching cât timp sosesc octeți (cu fetchedBytes și fetchTotalBytes când celălalt capăt spune câți sunt) sau extracting cât timp audio-ul este scos dintr-un video. progress.phasePct arată cât de departe este jobul în faza respectivă. Un video lung durează, deci interoghează la fiecare câteva secunde; vs.jobs.wait primește intervalMs (interval_s în Python) pentru asta.
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}%`);
},
});
Ghidul Transcrie un link din referință are programul complet în ambele limbaje și în HTTP. Descărcarea de pe link adaugă și mai multe moduri de a eșua, așa că iată erorile pe care le vei vedea cu adevărat.
Fiecare cerere eșuată răspunde cu un singur plic JSON. code este șirul după care trebuie să ramifici, iar status statusul HTTP; message este pentru oameni, iar requestId pentru suport. Sub data, retryable spune dacă retrimiterea aceleiași cereri poate ajuta, iar details conține ce are nevoie codul. SDK-urile ridică un singur tip de eroare, VocalStackError, cu aceleași câmpuri.
Trei coduri merită tratate din prima zi.
402 insufficient_credit: creditul contului s-a epuizat, iar nimic nu a fost taxat. details.balance este soldul în dolari, iar details.topUpUrl este locul unde adaugi credit. Când costul era cunoscut dinainte, ca pentru un fișier de lungime cunoscută, details.required spune cât ar fi fost.429 rate_limited: așteaptă Retry-After secunde. Când details.metric este creates_per_hour, ai atins rata de creare a planului (transcrieri noi pe oră), nu o limită de rafală; limit și resetsAt spun care este plafonul și când se eliberează.503 unavailable: serviciul este indisponibil pentru scurt timp. Trimite din nou cererea după Retry-After.SDK-urile reiau un 429 și un 503 după Retry-After, de două ori implicit (maxRetries / max_retries pe client). Un 503 însă este reluat doar la un apel care poate fi trimis de două ori în siguranță: un GET, sau o creare cu Idempotency-Key. O creare fără cheie rămâne în seama ta, pentru că SDK-ul nu poate ști dacă prima încercare a trecut. Acesta este motivul practic pentru care pui o cheie pe fiecare încărcare.
Fiecare răspuns poartă și RateLimit-Limit, RateLimit-Remaining și RateLimit-Reset, așa că o conductă ocupată poate încetini înainte de un 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…"
}
Tabelul complet al codurilor este în Erori, reluări și limite. Acel 402 este singura eroare legată de bani, așa că iată cât costă un fișier.
Fișierele costă $0.20 pe oră de audio, facturate pe secundă ($0.0033 pe minut), cu un minim de 5 minute: un fișier de 30 de minute costă $0.10. Sunt zece cenți, mai puțin decât cafeaua pe care o vei bea cât timp bara de progres se umple. Tariful este același pe fiecare plan și prin API la fel ca în Dashboard. Un cont nou pornește cu un credit de înscriere unic de $5, fără card necesar.
Trei limite ale planului ating acest tutorial.
GET /v1/realtime/capabilities îl raportează ca limits.maxUploadBytes. Se aplică cel mai mic dintre cele două, iar un fișier peste el este refuzat cu 413 înainte ca ceva să fie transcris. Pentru o înregistrare lungă, este încă un motiv să încarci un fișier mono comprimat, nu un WAV.GET /v1/billing raportează oricând planul, soldul și limitele tale; tabelul complet este pe pagina de prețuri.
De aici, următorii pași sunt streaming de audio în timp real când nu poți aștepta terminarea unui fișier, sau conectarea unui agent AI la același cont prin serverul MCP, ca să transcrie și să caute pentru tine fără niciunul din codul de mai sus.