Um envio devolve um job, não uma transcrição. Coloque um Idempotency-Key em cada criação para que uma nova tentativa nunca crie nem cobre uma segunda transcrição; depois consulte o job até ele terminar e leia frases, palavras ou uma exportação a partir do mesmo id.
Transcrever um arquivo é a coisa mais simples que a API do VocalStack faz, e ela tem a forma de qualquer job assíncrono: enviar, aguardar, ler. O envio responde de imediato com uma transcrição e o seu job, mas ainda sem palavras. Enquanto o seu programa consulta o job, o servidor transcreve o arquivo muitas vezes mais rápido do que o áudio toca. Quando o estado chega a done, você lê a transcrição como frases com locutores e tempos, ou a exporta como arquivo em um de quatro formatos, no idioma falado ou traduzido.
Tudo nesse formato se aplica a qualquer API em lote: uma criação que devolve um id, um status que você consulta, um resultado que você busca depois. Só os nomes dos campos são específicos do VocalStack, e este tutorial percorre-os com os SDKs de TypeScript e Python, e com as chamadas HTTP onde elas ajudam.
Abra Developers → API keys no Painel e crie uma chave com as permissões Read e Write. A chave é mostrada uma única vez; copie-a para uma variável de ambiente:
export VOCALSTACK_API_KEY="<your API key>"
Uma chave serve para servidores e scripts. Tudo o que vai para o navegador leva a chave junto, então mantenha-a fora do código da página e fora de URLs, que acabam em logs. Para transmitir a partir do navegador, o seu servidor cria uma sessão em tempo real para ele; isso é outro tutorial.
Toda chamada se autentica com a chave como bearer token:
Authorization: Bearer vs_key_...
Com a chave no ambiente, o próximo passo é um cliente para enviá-la.
Há dois SDKs. Cada um tem um cliente, VocalStack, com os métodos agrupados por recurso: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime e mais alguns. O SDK de TypeScript roda no Node 20 ou mais recente e nos navegadores; o SDK de Python é construído sobre asyncio e precisa de Python 3.10 ou mais recente.
Os SDKs não estão no npm nem no PyPI. A API serve os builds para todas as contas. Os comandos de instalação, preenchidos para o seu ambiente, estão na seção Install do quickstart na referência da API, e eles enviam a sua chave somente ao host da API. Se preferir não instalar nada, as chamadas HTTP abaixo precisam apenas de curl e jq.
Seja qual for a sua escolha, a primeira chamada é o envio.
POST /v1/transcriptions recebe o arquivo como multipart/form-data: uma parte file, mais uma parte options opcional com JSON. Ele responde 201 com a transcrição. O job.recordingId é o id da gravação feita a partir do arquivo, e todas as chamadas seguintes são endereçadas por esse id.
Dois ajustes importam numa primeira execução, ambos dentro de options.settings:
spokenLanguage: um código como en. Se você omitir, o idioma é detectado. Informá-lo melhora a precisão em gravações com ruído e sotaques fortes.speakerLabels: auto, on ou off. Com on, cada frase traz um número de locutor; com auto, os rótulos aparecem só depois que um segundo locutor tiver sido ouvido.A maioria dos arquivos de áudio e vídeo é aceita: mp3, wav, flac, ogg, m4a, mp4 e outros. Um vídeo mantém apenas o áudio. O formato quase não afeta a precisão, porque o reconhecimento de fala trabalha em 16 kHz mono, e tudo é reamostrado para isso ao chegar. Um WAV estéreo de 48 kHz com uma só pessoa falando gasta a maior parte dos bytes descrevendo silêncio em alta fidelidade. Ele é transcrito exatamente tão bem quanto um MP3 ou Opus mono de 64 kbps da mesma fala, mas é cerca de vinte vezes maior, então leva vinte vezes mais para subir. Se você converter antes do envio, mono e uma taxa de bits moderada são as duas configurações que valem a pena mudar.
Envie um Idempotency-Key em cada envio, e entenda por quê. Um timeout de rede não diz nada sobre o que o servidor fez: ele pode nunca ter recebido a requisição, ou pode ter armazenado o arquivo e enfileirado o job antes que a resposta se perdesse. Uma nova tentativa às cegas, portanto, arrisca uma segunda transcrição e uma segunda cobrança. A chave resolve isso. Quando uma requisição é repetida com a mesma chave e o mesmo corpo em até 24 horas, o servidor devolve a primeira resposta em vez de criar qualquer coisa, o que transforma “pelo menos uma vez” em “efetivamente uma vez”. Por isso a regra é uma chave por envio lógico, gerada antes da primeira tentativa e reutilizada em cada nova tentativa. Uma chave nova a cada tentativa faria de cada nova tentativa um novo envio, e o cabeçalho não protegeria nada.
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
A mesma chamada com 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)
O 201 chega antes de uma única palavra ser transcrita; o que você tem em mãos é um id de job, então o próximo passo é aguardá-lo.
Um job passa de queued → transcribing → done, ou termina em failed, com uma mensagem error e, quando a causa é legível por máquina, um errorCode. Um quarto estado, cropping, significa que o job fica parado até alguém escolher qual trecho da gravação transcrever, como faz a etapa de corte do Painel. Um envio comum nunca entra nele, embora jobs.wait também retorne quando um job chega a ele. Consulte GET /v1/recordings/{id}/job até o estado ser done ou failed.
Não há webhooks, e essa é uma troca deliberada. Um webhook exige um endpoint HTTPS público do seu lado, uma verificação de assinatura para que ninguém mais possa chamá-lo e uma política de novas tentativas para quando o seu endpoint estiver fora do ar, o que é muita infraestrutura para um job que leva um minuto. A consulta dispensa tudo isso. O seu script vai perguntar “já terminou?” a cada dois segundos, como uma criança no banco de trás, e, diferente da criança, vai receber de volta uma porcentagem.
Uma página web já aberta pode acompanhar o socket de eventos da transcrição (WS /v1/transcriptions/{id}/events, um frame job.progress a cada avanço do job). Para um script, a consulta é a ferramenta certa.
Os SDKs fazem a consulta: vs.jobs.wait lê o job a cada 2 segundos e entrega cada leitura a onProgress (on_progress em Python). O progress do job traz pct (0 a 100) e processedS, os segundos de mídia já transcritos. Os programas abaixo imprimem uma linha só quando o estado ou a porcentagem muda.
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}")
Observe que wait não lança exceção em failed; ele devolve o job e deixa a verificação com você. Dois segundos servem para uma palestra. Para uma gravação de três horas, ou um lote de centenas, aumente o intervalo (intervalMs, interval_s) ou recue conforme o job avança: as primeiras leituras dizem o mais, e as seguintes, em geral, apenas confirmam que o servidor continua ocupado. Sem um SDK, você escreve o mesmo laço à mão:
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 o estado indicar done, a transcrição está pronta.
GET /v1/recordings/{id}/transcript responde com a transcrição inteira em um único documento:
sentences: o que mostrar às pessoas. Cada uma tem text, speaker, e start e end em segundos.segments: as linhas do motor, cada uma com tempos por palavra em words (cada palavra tem text, start e end).speakers: uma entrada por número de locutor, com o seu name.Os locutores são numerados a partir de 0. Um speaker de -1 significa que os rótulos de locutor estavam desligados na gravação. Acrescente ?lang=es para ler a transcrição traduzida para o espanhol; as frases voltam nesse 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'
Os dois níveis de tempo servem a dois usos. As frases são a unidade de leitura, por isso são o que uma legenda ou uma página de transcrição mostra. As palavras são a unidade de apontamento, então um destaque estilo karaokê, um resultado de busca ou um corte de clipe percorre segments[].words. Os formatos de exportação são construídos a partir das frases.
GET /v1/recordings/{id}/export devolve um arquivo de verdade. format é um de srt, json, xlsx ou pdf, e o arquivo inclui as correções feitas. Acrescente lang= para um arquivo traduzido: a exportação então sai nesse idioma, uma linha por frase, e qualquer frase ainda não traduzida é traduzida antes.
Vale reconhecer o SRT de cara, porque é o formato de legenda que quase todo player aceita e é texto puro. Um arquivo é uma lista de blocos separados por linhas em branco, e cada bloco é um contador, um intervalo de tempo e o texto. Os carimbos de tempo usam vírgula antes dos milissegundos, um hábito francês que o formato nunca abandonou:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Uma frase por bloco é a granularidade certa: cada bloco pode ser lido no tempo em que fica na tela, e nenhum corta uma ideia pela metade. Quando a gravação tinha rótulos de locutor, o texto de cada bloco começa com o nome do locutor e dois-pontos.
No SDK de TypeScript, vs.recordings.export devolve um Response; leia os bytes com arrayBuffer(). Em Python, ele devolve 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
A transcrição fica armazenada, então aparece em Transcriptions no Painel, pode ser compartilhada por link e pode ser lida novamente a qualquer momento depois. Esse é o ciclo principal: criar, aguardar, ler, exportar. O restante deste tutorial cobre as variações: mídia que já está online, os erros que você vai encontrar e quanto um arquivo custa.
Se a mídia já estiver online, entregue à API o link e ela mesma busca o arquivo: um endereço http(s) público (um mp3, um vídeo, um episódio de podcast) ou um vídeo do YouTube. A chamada é a mesma POST /v1/transcriptions, com um corpo JSON { "url": "..." } no lugar do envio multipart, e name e settings como antes. Nos SDKs, vs.transcriptions.createFromUrl(url) (TypeScript) e vs.transcriptions.create(url=...) (Python) fazem a mesma requisição.
Antes de criar qualquer coisa, GET /v1/transcriptions/probe?url= informa o que é um link. O kind dele é um de três:
file: uma gravação com fim; transcreva aqui.live: uma transmissão ao vivo do YouTube, um stream de rádio sem fim ou uma playlist HLS sem fim. Um link ao vivo nunca termina, então não pode ser transcrito como arquivo; use-o como fonte de uma transcrição ao vivo.unknown: o servidor não conseguiu dizer.Quando o link não abre como mídia (uma página web, por exemplo), ok é false e problem explica o motivo em uma frase. A sondagem é opcional e limitada a 60 chamadas por minuto.
Um job de link começa em fetching antes de passar para transcribing e done. Enquanto busca, progress.phase diz o que o servidor está fazendo: fetching enquanto os bytes chegam (com fetchedBytes e fetchTotalBytes quando a outra ponta informa quantos são), ou extracting enquanto o áudio é extraído de um vídeo. progress.phasePct diz quanto da fase já passou. Um vídeo longo leva um tempo, então consulte a cada poucos segundos; vs.jobs.wait aceita intervalMs (interval_s em Python) para isso.
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}%`);
},
});
O guia Transcrever um link da referência traz o programa completo nas duas linguagens e em HTTP. Buscar a partir de links acrescenta mais formas de falhar, então aqui estão os erros que você de fato vai ver.
Toda chamada com falha responde com um único envelope JSON. code é a string a ser testada e status o status HTTP; message é para pessoas e requestId serve ao suporte. Dentro de data, retryable diz se reenviar a mesma requisição pode ajudar, e details traz o que o código precisar. Os SDKs lançam um só tipo de erro, VocalStackError, com os mesmos campos.
Três códigos merecem tratamento desde o primeiro dia.
402 insufficient_credit: o crédito da conta acabou, e nada foi cobrado. details.balance é o saldo em dólares e details.topUpUrl é onde adicionar crédito. Quando o custo era conhecido de antemão, como acontece com um arquivo de duração conhecida, details.required informa quanto teria sido.429 rate_limited: aguarde os segundos de Retry-After. Quando details.metric é creates_per_hour, você atingiu o limite de criação do plano (novas transcrições por hora), não um limite de rajada; limit e resetsAt dizem qual é o teto e quando ele libera.503 unavailable: o serviço está indisponível por um momento. Envie a requisição de novo depois de Retry-After.Os SDKs repetem um 429 e um 503 após Retry-After, duas vezes por padrão (maxRetries / max_retries no cliente). Um 503, porém, só é repetido em uma chamada segura para enviar duas vezes: um GET, ou uma criação com Idempotency-Key. Uma criação sem ela fica com você, porque o SDK não tem como saber se a primeira tentativa chegou ao destino. Essa é a razão prática para pôr uma chave em todo envio.
Toda resposta também traz RateLimit-Limit, RateLimit-Remaining e RateLimit-Reset, para que um pipeline ocupado possa desacelerar antes do 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…"
}
A tabela completa de códigos está em Erros, novas tentativas e limites. Esse 402 é o único erro ligado a dinheiro, então veja quanto custa um arquivo.
Arquivos custam $0.20 por hora de áudio, cobrados por segundo ($0.0033 por minuto), com um mínimo de 5 minutos: um arquivo de 30 minutos custa $0.10. São dez centavos, menos do que o café que você vai tomar enquanto a barra de progresso avança. A tarifa é a mesma em todos os planos, e a mesma pela API e pelo Painel. Uma conta nova começa com um crédito único de cadastro de $5, e nenhum cartão é exigido.
Três limites do plano afetam este tutorial.
GET /v1/realtime/capabilities informa como limits.maxUploadBytes. Vale o menor dos dois, e um arquivo acima dele é recusado com 413 antes que qualquer coisa seja transcrita. Para uma gravação longa, isso é mais um motivo para enviar um arquivo mono compactado em vez de um WAV.GET /v1/billing informa o seu plano, saldo e limites a qualquer momento; a tabela completa está na página de preços.
A partir daqui, os próximos passos são transmitir áudio em tempo real quando você não puder esperar um arquivo terminar, ou conectar um agente de IA à mesma conta pelo servidor MCP, para que ele transcreva e busque por você sem nenhum do código acima.