Una subida devuelve un trabajo, no una transcripción. Incluye una Idempotency-Key en cada creación para que un reintento nunca cree ni facture una segunda transcripción; luego consulta el trabajo hasta que esté terminado y lee las frases, las palabras o una exportación con el mismo id.
Transcribir un archivo es lo más sencillo que hace la API de VocalStack, y tiene la forma de cualquier trabajo asíncrono: subir, esperar, leer. La subida responde de inmediato con una transcripción y su trabajo, pero todavía sin palabras. Mientras tu programa consulta el trabajo, el servidor transcribe el archivo muchas veces más rápido de lo que suena el audio. Cuando el estado es done, lees el texto transcrito como frases con hablantes y tiempos, o lo exportas como archivo en uno de cuatro formatos, en el idioma original o traducido.
Todo lo que tiene esta forma se aplica a cualquier API por lotes: una creación que devuelve un id, un estado que consultas y un resultado que pides después. Solo los nombres de los campos son específicos de VocalStack, y este tutorial los recorre con los SDK de TypeScript y de Python, y con llamadas HTTP cuando ayudan.
Abre Developers → API keys en el panel y crea una clave con los permisos Read y Write. La clave se muestra una sola vez; cópiala en una variable de entorno:
export VOCALSTACK_API_KEY="<your API key>"
Una clave es para servidores y scripts. Todo lo que se envía a un navegador lleva la clave consigo, así que mantenla fuera del código de la página y de las URL, que acaban en los registros. Para transmitir desde un navegador, tu servidor crea una sesión en tiempo real para él; eso es otro tutorial.
Cada llamada se autentica con la clave como token de portador:
Authorization: Bearer vs_key_...
Con la clave en tu entorno, lo siguiente es un cliente que la envíe.
Hay dos SDK. Cada uno tiene un cliente, VocalStack, con sus métodos agrupados por recurso: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime y algunos más. El SDK de TypeScript funciona en Node 20 o posterior y en navegadores; el SDK de Python está construido sobre asyncio y necesita Python 3.10 o posterior.
Los SDK no están en npm ni en PyPI. La API sirve las compilaciones a todas las cuentas. Los comandos de instalación, completados para tu entorno, están en la sección Install de la guía de inicio rápido de la referencia de la API, y envían tu clave solo al host de la API. Si prefieres no instalar nada, las llamadas HTTP de abajo solo necesitan curl y jq.
Sea cual sea la opción que elijas, la primera llamada es la subida.
POST /v1/transcriptions recibe el archivo como multipart/form-data: una parte file y, opcionalmente, una parte options con JSON. Responde 201 con la transcripción. Su job.recordingId es el id de la grabación creada a partir del archivo, y todas las llamadas siguientes se dirigen por ese id.
Dos ajustes importan en una primera prueba, ambos dentro de options.settings:
spokenLanguage: un código como en. Si lo omites, el idioma se detecta. Indicarlo mejora la precisión en grabaciones con ruido y acentos marcados.speakerLabels: auto, on u off. Con on, cada frase lleva un número de hablante; con auto, las etiquetas aparecen solo cuando se ha oído a un segundo hablante.Se aceptan la mayoría de archivos de audio y vídeo: mp3, wav, flac, ogg, m4a, mp4 y otros. Un vídeo conserva solo su audio. El formato apenas influye en la precisión, porque el reconocimiento del habla trabaja a 16 kHz mono y todo se remuestrea a ese formato al llegar. Un WAV estéreo de 48 kHz con una sola persona que habla dedica la mayor parte de sus bytes a describir silencio con alta fidelidad. Se transcribe exactamente igual que un MP3 mono de 64 kbps o un Opus del mismo discurso, pero es unas veinte veces más grande, así que tarda veinte veces más en subirse. Si conviertes antes de subir, los dos ajustes que merece la pena cambiar son el mono y una tasa de bits moderada.
Envía un Idempotency-Key con cada subida, y entiende por qué. Un timeout de red no te dice nada sobre lo que hizo el servidor: puede que nunca haya recibido la petición, o que haya guardado el archivo y encolado el trabajo antes de que se perdiera la respuesta. Un reintento a ciegas arriesga una segunda transcripción y un segundo cobro. La clave lo evita. Cuando una petición se repite con la misma clave y el mismo cuerpo en un plazo de 24 horas, el servidor devuelve la primera respuesta en lugar de crear nada, lo que convierte «al menos una vez» en «efectivamente una vez». Así que la regla es una clave por subida lógica, generada antes del primer intento y reutilizada en cada reintento. Una clave nueva por intento haría que cada reintento fuera una subida nueva, y la cabecera no protegería 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
Lo mismo 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)
El 201 llega antes de transcribir una sola palabra; lo que tienes es un id de trabajo, así que el siguiente paso es esperarlo.
Un trabajo pasa de queued a transcribing y luego a done, o termina en failed con un mensaje error y, cuando la causa es legible por máquina, un errorCode. Un cuarto estado, cropping, significa que el trabajo queda en pausa hasta que alguien elige qué tramo de la grabación transcribir, como hace el paso de recorte del panel. Una subida normal nunca entra en él, aunque jobs.wait también devuelve el control cuando un trabajo llega a él. Lee GET /v1/recordings/{id}/job hasta que el estado sea done o failed.
No hay webhooks, y es una decisión deliberada. Un webhook necesita un endpoint HTTPS público de tu lado, una comprobación de firma para que nadie más pueda llamarlo y una política de reintentos para cuando tu endpoint esté caído, lo cual es bastante infraestructura para un trabajo que dura un minuto. El sondeo no necesita nada de eso. Tu script preguntará «¿ya?» cada dos segundos, como un niño en el asiento trasero, y a diferencia del niño recibirá un porcentaje.
Una página web que ya esté abierta puede seguir en su lugar el socket de eventos de la transcripción (WS /v1/transcriptions/{id}/events, con un mensaje job.progress según avanza el trabajo). Para un script, el sondeo es la herramienta adecuada.
Los SDK hacen el sondeo: vs.jobs.wait lee el trabajo cada 2 segundos y entrega cada lectura a onProgress (on_progress en Python). El progress del trabajo incluye pct (de 0 a 100) y processedS, los segundos de audio o vídeo transcritos hasta ahora. Los programas de abajo imprimen una línea solo cuando cambian el estado o el porcentaje.
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}")
Ten en cuenta que wait no lanza error en failed; devuelve el trabajo y te deja la comprobación a ti. Dos segundos bastan para una charla. Para una grabación de tres horas, o un lote de cientos, amplía el intervalo (intervalMs, interval_s) o espacia las consultas a medida que el trabajo avanza: las primeras lecturas te dicen lo más, y las posteriores sobre todo confirman que el servidor sigue ocupado. Sin SDK, escribes el mismo bucle 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
Cuando el estado indica done, la transcripción está lista.
GET /v1/recordings/{id}/transcript responde con toda la transcripción en un solo documento:
sentences: lo que se muestra a las personas. Cada una tiene text, speaker, y start y end en segundos.segments: las líneas del motor, cada una con tiempos por palabra en words (cada palabra tiene text, start y end).speakers: una entrada por número de hablante, con su name.Los hablantes se numeran desde 0. Un speaker de -1 significa que las etiquetas de hablante estaban desactivadas para la grabación. Añade ?lang=es para leer la transcripción traducida al español; las frases llegan en ese 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'
Los dos niveles de tiempo sirven para dos tareas. Las frases son la unidad de lectura, así que son lo que muestra un subtítulo o una página de transcripción. Las palabras son la unidad para señalar: un resaltado estilo karaoke, un resultado de búsqueda o un recorte de clip recorren segments[].words en su lugar. Los formatos de exportación se construyen a partir de frases.
GET /v1/recordings/{id}/export devuelve un archivo real. format es uno de srt, json, xlsx o pdf, y el archivo incluye las correcciones que hayas hecho. Añade lang= para un archivo traducido: la exportación se lee en ese idioma, una línea por frase, y cualquier frase aún sin traducir se traduce primero.
SRT conviene reconocerlo a simple vista, porque es el formato de subtítulos que casi todos los reproductores aceptan y es texto plano. Un archivo es una lista de bloques separados por líneas en blanco, y cada bloque es un número, un intervalo de tiempo y el texto. Las marcas de tiempo usan una coma antes de los milisegundos, una costumbre francesa que el formato nunca abandonó:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Una frase por bloque es la granularidad correcta: cada bloque se puede leer en el tiempo que está en pantalla, y ninguno corta a mitad de un pensamiento. Cuando la grabación tenía etiquetas de hablante, el texto de cada bloque empieza con el nombre del hablante y dos puntos.
En el SDK de TypeScript, vs.recordings.export devuelve un Response; lee sus bytes con arrayBuffer(). En Python devuelve 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ón queda guardada, así que aparece en Transcriptions en el panel, se puede compartir por enlace y puede leerse de nuevo en cualquier momento posterior. Ese es el bucle básico: crear, esperar, leer, exportar. El resto de este tutorial cubre las variantes: contenido ya disponible en línea, los errores con los que te encontrarás y lo que cuesta un archivo.
Si el contenido ya está en línea, entrega a la API el enlace y ella descarga el archivo por su cuenta: una dirección pública http(s) (un mp3, un vídeo, un episodio de podcast) o un vídeo de YouTube. La llamada es la misma POST /v1/transcriptions, con un cuerpo JSON { "url": "..." } en lugar de la subida multipart, y name y settings como antes. En los SDK, vs.transcriptions.createFromUrl(url) (TypeScript) y vs.transcriptions.create(url=...) (Python) hacen la misma petición.
Antes de crear nada, GET /v1/transcriptions/probe?url= te dice qué es un enlace. Su kind es uno de tres:
file: una grabación con final; transcríbela aquí.live: una transmisión en directo de YouTube, una radio en flujo sin fin o una lista HLS sin final. Un enlace en directo nunca termina, así que no se puede transcribir como archivo; úsalo como origen de una transcripción en directo.unknown: el servidor no pudo determinarlo.Cuando el enlace no se abre como contenido multimedia (una página web, por ejemplo), ok es false y problem explica el motivo en una frase. La comprobación es opcional y está limitada a 60 llamadas por minuto.
Un trabajo de enlace empieza en fetching antes de pasar a transcribing y done. Mientras descarga, progress.phase indica lo que hace el servidor: fetching mientras llegan bytes (con fetchedBytes y fetchTotalBytes cuando el otro extremo indica cuántos son), o extracting mientras se extrae el audio de un vídeo. progress.phasePct indica por dónde va esa fase. Un vídeo largo tarda un rato, así que consulta cada pocos segundos; vs.jobs.wait acepta intervalMs (interval_s en Python) para eso.
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 guía para transcribir un enlace de la referencia tiene el programa completo en ambos lenguajes y en HTTP. Descargar desde enlaces añade más formas de fallar, así que aquí están los errores que realmente verás.
Cada llamada fallida responde con un único sobre JSON. code es la cadena por la que decides, y status el estado HTTP; message es para personas y requestId para soporte. Dentro de data, retryable indica si volver a enviar la misma petición puede ayudar, y details contiene lo que necesita el código. Los SDK lanzan un solo tipo de error, VocalStackError, con los mismos campos.
Tres códigos merecen tratarse desde el primer día.
402 insufficient_credit: el crédito de la cuenta se ha agotado, y no se cobró nada. details.balance es el saldo en dólares y details.topUpUrl es donde añadir crédito. Cuando el coste se conocía de antemano, como ocurre con un archivo de duración conocida, details.required indica cuánto habría costado.429 rate_limited: espera Retry-After segundos. Cuando details.metric es creates_per_hour, has alcanzado el límite de creación del plan (nuevas transcripciones por hora), no un límite de ráfaga; limit y resetsAt indican cuál es el tope y cuándo se libera.503 unavailable: el servicio no está disponible un momento. Vuelve a enviar la petición después de Retry-After.Los SDK reintentan un 429 y un 503 después de Retry-After, dos veces por defecto (maxRetries / max_retries en el cliente). Un 503, sin embargo, solo se reintenta en una llamada que se pueda enviar dos veces sin riesgo: un GET, o una creación con Idempotency-Key. Una creación sin ella queda a tu cargo, porque el SDK no puede saber si el primer intento llegó. Esa es la razón práctica para poner una clave en cada subida.
Cada respuesta también incluye RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset, para que una canalización ocupada pueda frenar antes 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 tabla completa de códigos está en Errores, reintentos y límites. Ese 402 es el único error ligado al dinero, así que veamos cuánto cuesta un archivo.
Los archivos cuestan $0.20 por hora de audio, facturados por segundo ($0.0033 por minuto) con un mínimo de 5 minutos: un archivo de 30 minutos cuesta $0.10. Son diez centavos, menos que el café que tomarás mientras se llena la barra de progreso. La tarifa es la misma en todos los planes, y es igual por la API que en el panel. Una cuenta nueva empieza con un crédito de bienvenida único de $5, y no se requiere tarjeta.
Tres límites del plan afectan a este tutorial.
GET /v1/realtime/capabilities informa como limits.maxUploadBytes. Se aplica el menor de los dos, y un archivo que lo supere se rechaza con un 413 antes de transcribir nada. Para una grabación larga, es una razón más para subir un archivo mono comprimido en lugar de un WAV.GET /v1/billing informa en cualquier momento de tu plan, saldo y límites; la tabla completa está en la página de precios.
A partir de aquí, los siguientes pasos son transmitir audio en tiempo real cuando no puedas esperar a que termine un archivo, o conectar un agente de IA a la misma cuenta a través del servidor MCP, para que transcriba y busque por ti sin nada del código de arriba.