Un caricamento restituisce un job, non una trascrizione. Metti un Idempotency-Key su ogni creazione, così che un nuovo tentativo non possa mai creare né addebitare una seconda trascrizione; poi interroga il job finché è completato, e leggi frasi, parole o un'esportazione dallo stesso id.
Trascrivere un file è la cosa più semplice che fa l'API di VocalStack, e ha la forma di qualsiasi job asincrono: carichi, attendi, leggi. Il caricamento risponde subito con una trascrizione e il suo job, ma ancora senza parole. Mentre il tuo programma interroga il job, il server trascrive il file molte volte più velocemente di quanto l'audio venga riprodotto. Quando lo stato è done, leggi la trascrizione come frasi con parlanti e tempi, oppure la esporti come file in uno dei quattro formati, nella lingua parlata o tradotta.
Tutto ciò che ha questa forma vale per qualsiasi API batch: una creazione che restituisce un id, uno stato che interroghi, un risultato che recuperi dopo. Solo i nomi dei campi sono specifici di VocalStack, e questo tutorial li illustra con gli SDK TypeScript e Python, e dove aiutano, con le chiamate HTTP.
Apri Developers → API keys nella Dashboard e crea una chiave con i permessi Read e Write. La chiave viene mostrata una sola volta; copiala in una variabile d'ambiente:
export VOCALSTACK_API_KEY="<your API key>"
Una chiave serve per server e script. Tutto ciò che viene distribuito a un browser porta con sé la chiave, quindi tienila fuori dal codice delle pagine e dagli URL, che finiscono nei log. Per trasmettere da un browser, il tuo server crea per lui una sessione in tempo reale; è un altro tutorial.
Ogni chiamata si autentica con la chiave come bearer token:
Authorization: Bearer vs_key_...
Con la chiave nell'ambiente, il passo successivo è un client che la invii.
Ci sono due SDK. Ognuno ha un unico client, VocalStack, con i metodi raggruppati per risorsa: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime e alcuni altri. L'SDK TypeScript funziona su Node 20 o successivo e nei browser; l'SDK Python è basato su asyncio e richiede Python 3.10 o successivo.
Gli SDK non sono su npm o PyPI. L'API distribuisce le build a ogni account. I comandi di installazione, compilati per il tuo ambiente, sono nella sezione Install della guida rapida nel riferimento API, e inviano la tua chiave solo all'host dell'API. Se preferisci non installare nulla, le chiamate HTTP qui sotto richiedono soltanto curl e jq.
Qualunque strada tu scelga, la prima chiamata è il caricamento.
POST /v1/transcriptions accetta il file come multipart/form-data: una parte file e, facoltativa, una parte options che contiene JSON. Risponde 201 con la trascrizione. Il suo job.recordingId è l'id della registrazione creata dal file, e ogni chiamata successiva è indirizzata con quell'id.
Due impostazioni sono importanti per una prima prova, entrambe dentro options.settings:
spokenLanguage: un codice come en. Se lo ometti, la lingua viene rilevata. Indicarla migliora la precisione su registrazioni rumorose e con accenti marcati.speakerLabels: auto, on o off. Con on, ogni frase porta un numero di parlante; con auto, le etichette compaiono solo quando è stato sentito un secondo parlante.La maggior parte dei file audio e video è accettata: mp3, wav, flac, ogg, m4a, mp4 e altri. Un video conserva solo il suo audio. Il formato conta poco per la precisione, perché il riconoscimento vocale lavora a 16 kHz mono e tutto viene ricampionato a quel valore all'arrivo. Un WAV stereo a 48 kHz di una persona che parla spende gran parte dei suoi byte a descrivere il silenzio in alta fedeltà. Viene trascritto esattamente come un MP3 o un Opus mono a 64 kbps dello stesso discorso, ma è circa venti volte più grande, quindi il caricamento richiede venti volte più tempo. Se converti prima di caricare, mono e un bitrate moderato sono le due impostazioni che vale la pena cambiare.
Invia un Idempotency-Key con ogni caricamento, e capisci perché. Un timeout di rete non ti dice nulla su ciò che il server ha fatto: potrebbe non aver mai ricevuto la richiesta, oppure potrebbe aver già salvato il file e messo in coda il job prima che la risposta andasse persa. Un nuovo tentativo alla cieca rischia quindi una seconda trascrizione e un secondo addebito. La chiave risolve il problema. Quando una richiesta viene ripetuta con la stessa chiave e lo stesso corpo entro 24 ore, il server restituisce la prima risposta senza creare nulla, il che trasforma “almeno una volta” in “effettivamente una sola volta”. La regola è quindi una chiave per ogni caricamento logico, generata prima del primo tentativo e riutilizzata a ogni nuovo tentativo. Una chiave nuova per ogni tentativo renderebbe ogni nuovo tentativo un caricamento diverso, e l'intestazione non proteggerebbe nulla.
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 stessa chiamata con 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)
Il 201 arriva prima che sia trascritta una sola parola; quello che hai in mano è un id di job, quindi il passo successivo è attendere quel job.
Un job passa da queued → transcribing → done, oppure termina in failed con un messaggio error e, quando la causa è leggibile da una macchina, un errorCode. Un quarto stato, cropping, significa che il job è in sospeso finché qualcuno non sceglie quale tratto della registrazione trascrivere, come fa il passo di ritaglio della Dashboard. Un caricamento ordinario non entra mai in questo stato, anche se jobs.wait restituisce comunque quando un job lo raggiunge. Leggi GET /v1/recordings/{id}/job finché lo stato è done o failed.
Non ci sono webhook, ed è uno scambio deliberato. Un webhook richiede un endpoint HTTPS pubblico dalla tua parte, un controllo della firma perché nessun altro possa chiamarlo e una politica di nuovi tentativi per quando il tuo endpoint è giù: parecchia infrastruttura per un job che dura un minuto. Il polling non richiede nulla di tutto questo. Il tuo script chiederà “è già finito?” ogni due secondi, come un bambino sul sedile posteriore, ma a differenza del bambino riceverà in cambio una percentuale.
Una pagina web già aperta può invece seguire il socket degli eventi della trascrizione (WS /v1/transcriptions/{id}/events, un frame job.progress a ogni avanzamento del job). Per uno script, il polling è lo strumento giusto.
Gli SDK fanno il polling: vs.jobs.wait legge il job ogni 2 secondi e passa ogni lettura a onProgress (on_progress in Python). Il progress del job contiene pct (da 0 a 100) e processedS, i secondi di media già trascritti. I programmi qui sotto stampano una riga solo quando cambiano lo stato o la percentuale.
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}")
Attenzione: wait non genera un'eccezione su failed; restituisce il job e lascia a te il controllo. Due secondi vanno bene per un discorso. Per una registrazione di tre ore, o per un lotto di centinaia di file, allarga l'intervallo (intervalMs, interval_s) o rallentalo man mano che il job procede: le prime letture ti dicono di più, e quelle successive per lo più confermano che il server è ancora occupato. Senza SDK, scrivi lo stesso ciclo a mano:
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
Quando lo stato risulta done, la trascrizione è pronta.
GET /v1/recordings/{id}/transcript risponde con l'intera trascrizione in un unico documento:
sentences: ciò che mostrare alle persone. Ognuna ha text, speaker, e start ed end in secondi.segments: le righe del motore, ciascuna con i tempi a livello di parola in words (ogni parola ha text, start ed end).speakers: una voce per ogni numero di parlante, con il suo name.I parlanti sono numerati a partire da 0. Uno speaker pari a -1 significa che per la registrazione le etichette dei parlanti erano disattivate. Aggiungi ?lang=es per leggere la trascrizione tradotta in spagnolo; le frasi tornano in quella lingua.
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'
I due livelli di tempo servono a due scopi. Le frasi sono l'unità di lettura, quindi sono ciò che mostra un sottotitolo o una pagina di trascrizione. Le parole sono l'unità di puntamento, quindi un evidenziato in stile karaoke, un risultato di ricerca o il taglio di una clip scorrono invece segments[].words. I formati di esportazione sono costruiti a partire dalle frasi.
GET /v1/recordings/{id}/export restituisce un file vero e proprio. format è uno tra srt, json, xlsx o pdf, e il file include eventuali correzioni. Aggiungi lang= per un file tradotto: l'esportazione viene allora letta in quella lingua, una riga per frase, e qualunque frase non ancora tradotta viene prima tradotta.
SRT vale la pena riconoscerlo a colpo d'occhio, perché è il formato dei sottotitoli che quasi ogni lettore accetta, ed è testo semplice. Un file è un elenco di cue separati da righe vuote, e ogni cue è un contatore, un intervallo di tempo e il testo. I timestamp usano una virgola prima dei millisecondi, un'abitudine francese che il formato non ha mai perso:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Una frase per cue è la granularità giusta: ogni cue si può leggere nel tempo in cui resta sullo schermo, e nessuno si interrompe a metà di un pensiero. Quando la registrazione aveva le etichette dei parlanti, il testo di ogni cue inizia con il nome del parlante seguito da due punti.
Nell'SDK TypeScript, vs.recordings.export restituisce un Response; ne leggi i byte con arrayBuffer(). In Python restituisce 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 trascrizione viene salvata, quindi compare sotto Transcriptions nella Dashboard, può essere condivisa tramite link e può essere riletta in qualsiasi momento. Questo è il ciclo fondamentale: crea, attendi, leggi, esporta. Il resto di questo tutorial copre le varianti: contenuti già online, gli errori che incontrerai e quanto costa un file.
Se il contenuto è già online, passa all'API il link e sarà lei a scaricare il file: un indirizzo pubblico http(s) (un mp3, un video, una puntata di podcast) oppure un video di YouTube. La chiamata è la stessa POST /v1/transcriptions, con un corpo JSON { "url": "..." } al posto del caricamento multipart, e name e settings come prima. Negli SDK, vs.transcriptions.createFromUrl(url) (TypeScript) e vs.transcriptions.create(url=...) (Python) inviano la stessa richiesta.
Prima di creare qualsiasi cosa, GET /v1/transcriptions/probe?url= ti dice che cos'è un link. Il suo kind è uno di tre:
file: una registrazione con una fine; trascrivila qui.live: un live stream di YouTube, uno stream radio senza fine o una playlist HLS senza fine. Un link live non finisce mai, quindi non può essere trascritto come file; usalo invece come sorgente di una trascrizione in diretta.unknown: il server non è riuscito a stabilirlo.Quando il link non si apre affatto come contenuto multimediale (una pagina web, per esempio), ok è false e problem spiega il motivo in una frase. Il probe è facoltativo e limitato a 60 chiamate al minuto.
Un job da link inizia in fetching prima di passare a transcribing e done. Mentre scarica, progress.phase indica cosa sta facendo il server: fetching mentre arrivano i byte (con fetchedBytes e fetchTotalBytes quando l'altro capo dichiara quanti sono), oppure extracting mentre l'audio viene estratto da un video. progress.phasePct indica a che punto è il job in quella fase. Un video lungo richiede un po' di tempo, quindi interroga ogni pochi secondi; vs.jobs.wait accetta intervalMs (interval_s in Python) per questo.
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 guida Trascrivere un link del riferimento contiene il programma completo in entrambi i linguaggi e in HTTP. Il recupero dai link aggiunge altri modi di fallire, quindi ecco gli errori che incontrerai davvero.
Ogni chiamata fallita risponde con un unico envelope JSON. code è la stringa su cui fare il controllo e status lo stato HTTP; message è per le persone e requestId serve all'assistenza. Sotto data, retryable indica se rinviare la stessa richiesta può servire, e details contiene ciò che serve al codice. Gli SDK generano un unico tipo di errore, VocalStackError, con gli stessi campi.
Tre codici meritano di essere gestiti fin dal primo giorno.
402 insufficient_credit: il credito dell'account è esaurito, e non è stato addebitato nulla. details.balance è il saldo in dollari e details.topUpUrl è dove aggiungere credito. Quando il costo era noto in anticipo, come accade per un file di durata nota, details.required indica quanto sarebbe stato.429 rate_limited: attendi Retry-After secondi. Quando details.metric è creates_per_hour, hai raggiunto il limite di creazione del piano (nuove trascrizioni all'ora), non un limite a raffica; limit e resetsAt indicano qual è il tetto e quando si libera.503 unavailable: il servizio è temporaneamente non disponibile. Invia di nuovo la richiesta dopo Retry-After.Gli SDK ritentano un 429 e un 503 dopo Retry-After, due volte per impostazione predefinita (maxRetries / max_retries sul client). Un 503, però, viene ritentato solo su una chiamata che può essere inviata due volte in sicurezza: una GET, oppure una creazione con una Idempotency-Key. Una creazione senza chiave è lasciata a te, perché l'SDK non può sapere se il primo tentativo è andato a buon fine. È questa la ragione pratica per mettere una chiave su ogni caricamento.
Ogni risposta riporta anche RateLimit-Limit, RateLimit-Remaining e RateLimit-Reset, così una pipeline occupata può rallentare prima di ricevere 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…"
}
L'elenco completo dei codici è in Errori, nuovi tentativi e limiti. Quel 402 è l'unico errore legato al denaro, quindi ecco quanto costa un file.
I file costano $0.20 all'ora di audio, addebitati al secondo ($0.0033 al minuto) con un minimo di 5 minuti: un file di 30 minuti costa $0.10. Sono dieci centesimi, meno del caffè che berrai mentre la barra di avanzamento si riempie. La tariffa è la stessa su ogni piano, e identica tramite API e nella Dashboard. Un nuovo account parte con un credito di registrazione una tantum da $5, e non serve una carta.
Tre limiti del piano toccano questo tutorial.
GET /v1/realtime/capabilities riporta come limits.maxUploadBytes. Si applica il più piccolo dei due, e un file che lo supera viene rifiutato con un 413 prima che venga trascritto qualcosa. Per una registrazione lunga, è un motivo in più per caricare un file mono compresso invece di un WAV.GET /v1/billing riporta in qualsiasi momento il tuo piano, il saldo e i limiti; la tabella completa è nella pagina dei prezzi.
Da qui, i passi successivi sono trasmettere audio in tempo reale quando non puoi aspettare che un file finisca, oppure collegare un agente AI allo stesso account tramite il server MCP, così trascrive e cerca per te senza nessuno dei codici qui sopra.