Una pujada retorna una tasca, no una transcripció. Posa una Idempotency-Key a cada creació perquè un reintent no pugui crear ni facturar una segona transcripció; després sondeja la tasca fins que acabi i llegeix les frases, les paraules o una exportació des del mateix id.
Una transcripció de fitxer és el més senzill que fa l'API de VocalStack, i té la forma de qualsevol tasca asíncrona: pujar, esperar, llegir. La pujada respon de seguida amb una transcripció i la seva tasca, però encara sense paraules. Mentre el programa sondeja la tasca, el servidor transcriu el fitxer moltes vegades més ràpid del que sona l'àudio. Quan l'estat és done, llegeixes la transcripció com a frases amb parlants i temps, o l'exportes com a fitxer en un dels quatre formats, en l'idioma parlat o traduïda.
Tot el que té aquesta forma es transfereix a qualsevol API per lots: una creació que retorna un id, un estat que consultes i un resultat que demanes després. Només els noms dels camps són específics de VocalStack, i aquest tutorial els repassa amb els SDK de TypeScript i Python, i amb les crides HTTP quan ajuden.
Obre Developers → API keys al tauler i crea una clau amb els permisos Read i Write. La clau es mostra una sola vegada; copia-la en una variable d'entorn:
export VOCALSTACK_API_KEY="<your API key>"
Una clau és per a servidors i scripts. Qualsevol cosa que s'envia a un navegador porta la clau amb ella, així que mantén-la fora del codi de la pàgina i fora de les URL, que acaben als registres. Per transmetre des d'un navegador, el teu servidor crea una sessió en temps real per a ell; això és un tutorial diferent.
Cada crida s'autentica amb la clau com a token portador:
Authorization: Bearer vs_key_...
Amb la clau a l'entorn, el següent és un client que l'enviï.
Hi ha dos SDK. Cada un té un client, VocalStack, amb els seus mètodes agrupats per recurs: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime i uns quants més. El SDK de TypeScript s'executa a Node 20 o posterior i als navegadors; el SDK de Python està construït sobre asyncio i necessita Python 3.10 o posterior.
Els SDK no són a npm ni a PyPI. L'API serveix les versions a tots els comptes. Les ordres d'instal·lació, adaptades al teu entorn, són a la secció Install de la guia d'inici ràpid a la referència de l'API, i envien la teva clau només al servidor de l'API. Si prefereixes no instal·lar res, les crides HTTP de més avall només necessiten curl i jq.
Tant si uses un com l'altre, la primera crida és la pujada.
POST /v1/transcriptions rep el fitxer com a multipart/form-data: una part file, més una part options opcional amb JSON. Respon 201 amb la transcripció. El seu job.recordingId és l'id de la gravació feta a partir del fitxer, i totes les crides que segueixen s'adrecen per aquest id.
Dues opcions importen en una primera execució, les dues dins de options.settings:
spokenLanguage: un codi com en. Si la deixes fora, l'idioma es detecta. Indicar-la millora la precisió en gravacions amb soroll i accents marcats.speakerLabels: auto, on o off. Amb on, cada frase porta un número de parlant; amb auto, les etiquetes només apareixen quan s'ha sentit un segon parlant.Accepta la majoria de fitxers d'àudio i vídeo: mp3, wav, flac, ogg, m4a, mp4 i d'altres. Un vídeo només conserva el seu àudio. El format gairebé no afecta la precisió, perquè el reconeixement de la parla treballa a 16 kHz mono i tot es remostreja a aquesta freqüència en arribar. Un WAV estèreo de 48 kHz d'una persona que parla dedica la major part dels seus bytes a descriure silenci en alta fidelitat. Es transcriu exactament igual que un fitxer MP3 mono de 64 kbps o Opus de la mateixa xerrada, però és unes vint vegades més gran, així que triga vint vegades més a pujar-se. Si el converteixes abans de pujar-lo, mono i una taxa de bits moderada són les dues opcions que val la pena canviar.
Envia una Idempotency-Key amb cada pujada, i entén per què. Un timeout de xarxa no et diu res sobre el que ha fet el servidor: pot ser que mai no hagi rebut la sol·licitud, o que hagi desat el fitxer i posat la tasca en cua abans que la resposta es perdés. Un reintent a cegues, per tant, arrisca una segona transcripció i un segon cobrament. La clau ho resol. Quan una sol·licitud es repeteix amb la mateixa clau i el mateix cos dins de 24 hores, el servidor retorna la primera resposta sense crear res, cosa que converteix «com a mínim una vegada» en «efectivament una vegada». Així que la regla és una clau per cada pujada lògica, generada abans del primer intent i reutilitzada en cada reintent. Una clau nova per intent faria que cada reintent fos una pujada nova, i la capçalera no protegiria res.
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
La mateixa crida amb 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)
El 201 arriba abans que es transcrigui cap paraula; el que tens a les mans és un id de tasca, així que el següent pas és esperar-la.
Una tasca passa de queued → transcribing → done, o acaba en failed amb un missatge error i, quan la causa és llegible per màquina, un errorCode. Un quart estat, cropping, vol dir que la tasca queda aturada fins que algú tria quin tram de la gravació transcriure, com fa el pas de retallada del tauler. Una pujada normal mai no hi entra, tot i que jobs.wait també retorna quan una tasca hi arriba. Llegeix GET /v1/recordings/{id}/job fins que l'estat sigui done o failed.
No hi ha webhooks, i és un canvi deliberat. Un webhook necessita un endpoint HTTPS públic al teu costat, una verificació de signatura perquè ningú més no el pugui cridar, i una política de reintents per quan el teu endpoint no estigui disponible. És molta feina per a una tasca que triga un minut. El sondeig no en necessita res. El teu script preguntarà «ja n'hi ha?» cada dos segons, com un nen al seient de darrere del cotxe, i a diferència del nen rep un percentatge.
Una pàgina web que ja és oberta pot seguir el socket d'esdeveniments de la transcripció (WS /v1/transcriptions/{id}/events, un missatge job.progress a mesura que avança la tasca). Per a un script, el sondeig és l'eina adequada.
Els SDK fan el sondeig: vs.jobs.wait llegeix la tasca cada 2 segons i passa cada lectura a onProgress (on_progress en Python). El progress de la tasca porta pct (de 0 a 100) i processedS, els segons de media transcrits fins ara. Els programes de més avall imprimeixen una línia només quan canvien l'estat o el percentatge.
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}")
Fixa't que wait no llança un error amb failed; retorna la tasca i et deixa la comprovació a tu. Dos segons va bé per a una xerrada. Per a una gravació de tres hores, o un lot de centenars, amplia l'interval (intervalMs, interval_s) o espaia les lectures a mesura que la tasca avança: les primeres lectures et diuen el més, i les següents sobretot confirmen que el servidor encara està ocupat. Sense SDK, escriu el mateix bucle a mà:
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
Quan l'estat marca done, la transcripció és a punt.
GET /v1/recordings/{id}/transcript respon amb tota la transcripció en un sol document:
sentences: el que cal mostrar a la gent. Cada una té text, speaker, i start i end en segons.segments: les línies del motor, cadascuna amb els temps per paraula a words (cada paraula té text, start i end).speakers: una entrada per número de parlant, amb el seu name.Els parlants es numeren des de 0. Un speaker de -1 vol dir que les etiquetes de parlant estaven desactivades per a la gravació. Afegeix ?lang=es per llegir la transcripció traduïda a l'espanyol; les frases arriben en aquest idioma.
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'
Els dos nivells de temps serveixen per dues tasques. Les frases són la unitat de lectura, per això són el que mostra un subtítol o una pàgina de transcripció. Les paraules són la unitat d'assenyalament, així que un ressaltat estil karaoke, un resultat de cerca o un tall de clip recorre segments[].words en lloc d'això. Els formats d'exportació es construeixen a partir de les frases.
GET /v1/recordings/{id}/export retorna un fitxer real. format és un de srt, json, xlsx o pdf, i el fitxer inclou les correccions. Afegeix lang= per a un fitxer traduït: aleshores l'exportació es llegeix en aquest idioma, una línia per frase, i tota frase encara no traduïda es tradueix primer.
Val la pena conèixer el SRT a simple vista, perquè és el format de subtítols que gairebé tots els reproductors accepten i és text pla. Un fitxer és una llista de cues separades per línies en blanc, i cada cue és un comptador, un rang de temps i el text. Les marques de temps usen una coma abans dels mil·lisegons, un hàbit francès que el format mai no va abandonar:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Una frase per cue és la granularitat adequada: cada cue es pot llegir en el temps que és a la pantalla, i cap no es talla a mitja idea. Quan la gravació tenia etiquetes de parlant, el text de cada cue comença amb el nom del parlant i dos punts.
Al SDK de TypeScript, vs.recordings.export retorna un Response; llegeix-ne els bytes amb arrayBuffer(). A Python retorna 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
La transcripció es desa, així que apareix a Transcriptions al tauler, es pot compartir per enllaç i es pot tornar a llegir en qualsevol moment. Aquest és el bucle central: crear, esperar, llegir, exportar. La resta del tutorial cobreix les variants: contingut que ja és en línia, els errors que trobaràs i el que costa un fitxer.
Si el contingut ja és en línia, passa a l'API l'enllaç i ella mateixa en descarrega el fitxer: una adreça pública http(s) (un mp3, un vídeo, un episodi de podcast) o un vídeo de YouTube. La crida és la mateixa POST /v1/transcriptions, amb un cos JSON { "url": "..." } en lloc de la pujada multipart, i name i settings com abans. Als SDK, vs.transcriptions.createFromUrl(url) (TypeScript) i vs.transcriptions.create(url=...) (Python) fan la mateixa sol·licitud.
Abans de crear res, GET /v1/transcriptions/probe?url= et diu què és un enllaç. El seu kind és un de tres:
file: una gravació amb final; transcriu-la aquí.live: una emissió en directe de YouTube, un flux de ràdio sense fi o una llista HLS sense final. Un enllaç en directe no acaba mai, així que no es pot transcriure com a fitxer; usa'l com a font d'una transcripció en directe.unknown: el servidor no ho ha pogut determinar.Quan l'enllaç no s'obre com a contingut multimèdia (una pàgina web, per exemple), ok és false i problem n'explica el motiu en una frase. El sondeig és opcional i està limitat a 60 crides per minut.
Una tasca d'enllaç comença en fetching abans de passar a transcribing i done. Mentre descarrega, progress.phase diu què fa el servidor: fetching mentre arriben bytes (amb fetchedBytes i fetchTotalBytes quan l'altre extrem indica quants n'hi ha), o extracting mentre s'extreu l'àudio d'un vídeo. progress.phasePct indica per on va la tasca dins d'aquesta fase. Un vídeo llarg triga una estona, així que sondeja cada pocs segons; vs.jobs.wait accepta intervalMs (interval_s en Python) per a això.
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}%`);
},
});
La guia Transcriu un enllaç de la referència té el programa complet en els dos llenguatges i en HTTP. Descarregar des d'enllaços afegeix més maneres de fallar, així que aquí tens els errors que de fet veuràs.
Cada crida fallida respon amb un únic sobre JSON. code és la cadena per la qual has de bifurcar i status és l'estat HTTP; message és per a les persones i requestId per a l'assistència. Sota data, retryable indica si tornar a enviar la mateixa sol·licitud pot ajudar, i details conté el que calgui per al codi. Els SDK llancen un sol tipus d'error, VocalStackError, amb els mateixos camps.
Tres codis val la pena gestionar des del primer dia.
402 insufficient_credit: el crèdit del compte s'ha esgotat, i no s'ha cobrat res. details.balance és el saldo en dòlars i details.topUpUrl és on afegir crèdit. Quan el cost es coneixia per endavant, com passa amb un fitxer de durada coneguda, details.required indica quant hauria estat.429 rate_limited: espera Retry-After segons. Quan details.metric és creates_per_hour, has arribat al límit de creació del pla (noves transcripcions per hora), no a un límit de ràfega; limit i resetsAt indiquen quin és el topall i quan queda alliberat.503 unavailable: el servei no està disponible un moment. Torna a enviar la sol·licitud després de Retry-After.Els SDK reintenten un 429 i un 503 després de Retry-After, per defecte dos cops (maxRetries / max_retries al client). Un 503, però, només es reintenta en una crida que es pot enviar dues vegades sense risc: un GET, o una creació amb Idempotency-Key. Una creació sense clau queda en mans teves, perquè el SDK no pot saber si el primer intent va arribar. Aquesta és la raó pràctica per posar una clau a cada pujada.
Cada resposta també porta RateLimit-Limit, RateLimit-Remaining i RateLimit-Reset, perquè una pipeline ocupada pugui alentir-se abans del 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…"
}
La taula completa de codis és a Errors, reintents i límits. Aquest 402 és l'únic error vinculat als diners, així que aquí tens què costa un fitxer.
Els fitxers costen $0.20 per hora d'àudio, facturats per segon ($0.0033 per minut) amb un mínim de 5 minuts: un fitxer de 30 minuts costa $0.10. Són deu cèntims, menys que el cafè que et beuràs mentre s'omple la barra de progrés. La tarifa és la mateixa en tots els plans, i la mateixa a través de l'API que al tauler. Un compte nou comença amb un crèdit de registre únic de $5, i no cal targeta.
Tres límits del pla afecten aquest tutorial.
GET /v1/realtime/capabilities informa com a limits.maxUploadBytes. S'aplica el més petit dels dos, i un fitxer que el superi es rebutja amb un 413 abans que es transcrigui res. Per a una gravació llarga, això és una raó més per pujar un fitxer mono comprimit en lloc d'un WAV.GET /v1/billing informa en qualsevol moment del teu pla, saldo i límits; la taula completa és a la pàgina de preus.
A partir d'aquí, els següents passos són transmetre àudio en temps real quan no pots esperar que acabi un fitxer, o connectar un agent d'IA al mateix compte mitjançant el servidor MCP, perquè transcrigui i busqui per tu sense cap del codi anterior.