Загрузка возвращает задачу, а не расшифровку. Добавляйте Idempotency-Key к каждому созданию, чтобы повтор никогда не создал и не оплатил вторую транскрибацию; затем опрашивайте задачу до завершения и читайте предложения, слова или экспорт по тому же идентификатору.
Расшифровка файла — самая простая операция в VocalStack API, и устроена она как любая асинхронная задача: загрузка, ожидание, чтение. Ответ на загрузку приходит сразу: в нём есть транскрибация и её задача, но слов пока нет. Пока ваша программа опрашивает задачу, сервер обрабатывает файл во много раз быстрее, чем звучит аудио. Когда состояние становится done, вы читаете расшифровку как предложения с говорящими и таймингами или экспортируете её в файл одного из четырёх форматов — на языке оригинала или в переводе.
Всё в такой схеме переносится на любой пакетный API: создание, которое возвращает идентификатор, статус, который вы опрашиваете, и результат, который забираете потом. Специфичны только имена полей VocalStack, и в этом руководстве они разобраны на примерах с SDK для TypeScript и Python, а там, где это полезно, — с HTTP-запросами.
Откройте в панели управления раздел Developers → API keys и создайте ключ с правами Read и Write. Ключ показывается один раз — скопируйте его в переменную окружения:
export VOCALSTACK_API_KEY="<your API key>"
Ключ предназначен для серверов и скриптов. Всё, что уходит в браузер, отправляет ключ вместе с собой, поэтому не помещайте его в код страницы и в URL, которые попадают в логи. Чтобы вести потоковую передачу из браузера, ваш сервер создаёт для него сессию реального времени; об этом — отдельное руководство.
Каждый вызов аутентифицируется ключом как bearer-токеном:
Authorization: Bearer vs_key_...
Когда ключ лежит в окружении, остаётся создать клиент, который будет его отправлять.
Есть два SDK. В каждом один клиент, VocalStack, а методы сгруппированы по ресурсам: vs.transcriptions, vs.jobs, vs.recordings, vs.realtime и некоторые другие. SDK для TypeScript работает на Node 20 и новее, а также в браузерах; SDK для Python построен на asyncio и требует Python 3.10 или новее.
SDK не опубликованы в npm или PyPI. API отдаёт сборки каждому аккаунту. Команды установки с учётом вашего окружения находятся в разделе Install быстрого старта в справочнике API; они отправляют ваш ключ только на хост API. Если вы не хотите ничего устанавливать, HTTP-запросам ниже нужны только curl и jq.
Что бы вы ни выбрали, первый вызов — загрузка.
POST /v1/transcriptions принимает файл как multipart/form-data: часть file и необязательную часть options с JSON. Он отвечает 201 с транскрибацией. Её job.recordingId — идентификатор записи, созданной из файла, и все последующие вызовы адресуются по этому идентификатору.
Для первого запуска важны два параметра, оба внутри options.settings:
spokenLanguage: код языка, например en. Если его не указать, язык определяется автоматически. Явное указание повышает точность на шумных записях и при сильном акценте.speakerLabels: auto, on или off. При on каждое предложение получает номер говорящего; при auto метки появляются только после того, как услышан второй говорящий.Принимаются большинство аудио- и видеофайлов: mp3, wav, flac, ogg, m4a, mp4 и другие. От видео остаётся только звук. Формат почти не влияет на точность, потому что распознавание речи работает с моно 16 кГц, и всё приводится к этому при поступлении. Стерео-WAV на 48 кГц с записью одного человека тратит большую часть байтов на высококачественное описание тишины. Расшифровка получается ровно такой же, как у моно-MP3 или Opus на 64 kbps с той же речью, но файл примерно в двадцать раз больше, поэтому и загружаться он будет в двадцать раз дольше. Если вы конвертируете файл до загрузки, стоит изменить только два параметра: сделать звук моно и выбрать умеренный битрейт.
Передавайте Idempotency-Key с каждой загрузкой и поймите зачем. Сетевой таймаут ничего не говорит о том, что сделал сервер: он мог вообще не получить запрос или мог сохранить файл и поставить задачу в очередь до того, как ответ потерялся. Слепой повтор поэтому рискует вторым распознаванием и вторым списанием. Ключ это исправляет. Если запрос повторён с тем же ключом и тем же телом в течение 24 часов, сервер возвращает первый ответ, ничего не создавая, и это превращает «как минимум один раз» в «фактически один раз». Поэтому правило такое: один ключ на одну логическую загрузку, создаётся до первой попытки и используется при каждом повторе. Новый ключ на каждую попытку превратил бы каждый повтор в новую загрузку, и заголовок ничего бы не защищал.
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
То же самое через 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 приходит до того, как распознано хоть одно слово; у вас на руках идентификатор задачи, поэтому следующий шаг — ожидание.
Задача переходит из queued в transcribing и затем в done, либо завершается состоянием failed с сообщением error и, если причина поддаётся машинному чтению, errorCode. Четвёртое состояние, cropping, означает, что задача приостановлена, пока кто-то не выберет, какой отрезок записи расшифровывать, как это делает шаг обрезки в панели управления. Обычная загрузка в него не попадает, хотя jobs.wait тоже возвращается, если задача до него дошла. Опрашивайте GET /v1/recordings/{id}/job, пока состояние не станет done или failed.
Вебхуков нет, и это осознанное решение. Вебхук требует публичной HTTPS-конечной точки на вашей стороне, проверки подписи, чтобы никто посторонний не мог её вызвать, и политики повторов на случай, когда ваша конечная точка недоступна. Для задачи, которая длится минуту, это слишком много инфраструктуры. Опрос такой инфраструктуры не требует. Ваш скрипт будет каждые две секунды спрашивать «уже готово?», как ребёнок на заднем сиденье, но в отличие от ребёнка он получает в ответ процент.
Уже открытая веб-страница может вместо этого следить за сокетом событий транскрибации (WS /v1/transcriptions/{id}/events, кадр job.progress по мере продвижения задачи). Для скрипта правильный инструмент — опрос.
SDK сами выполняют опрос: vs.jobs.wait читает задачу каждые 2 секунды и передаёт каждое чтение в onProgress (on_progress в Python). В progress задачи есть pct (от 0 до 100) и processedS — секунды медиа, уже расшифрованные. Программы ниже выводят строку только тогда, когда меняются состояние или процент.
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}")
Обратите внимание: wait не выбрасывает исключение при failed; он возвращает задачу и оставляет проверку вам. Двух секунд достаточно для выступления. Для трёхчасовой записи или пакета из сотен файлов увеличьте интервал (intervalMs, interval_s) или снижайте частоту по мере работы задачи: первые чтения говорят больше всего, а последующие в основном подтверждают, что сервер ещё занят. Без SDK тот же цикл пишется вручную:
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
Когда состояние становится done, расшифровка готова.
GET /v1/recordings/{id}/transcript возвращает всю расшифровку одним документом:
sentences: то, что нужно показывать людям. У каждого есть text, speaker, а также start и end в секундах.segments: строки движка, каждая с пословными таймингами в words (у каждого слова есть text, start и end).speakers: по одной записи на номер говорящего, с его name.Номера говорящих начинаются с 0. Значение speaker, равное -1, означает, что метки говорящих для записи были выключены. Добавьте ?lang=es, чтобы получить расшифровку, переведённую на испанский; предложения вернутся на этом языке.
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'
Два уровня таймингов служат двум задачам. Предложения — единица чтения, поэтому их показывают субтитры или страница расшифровки. Слова — единица указания: подсветка в стиле караоке, результат поиска или нарезка клипа проходят по segments[].words. Форматы экспорта строятся из предложений.
GET /v1/recordings/{id}/export возвращает настоящий файл. format — один из srt, json, xlsx или pdf, и файл включает все исправления. Добавьте lang= для переведённого файла: экспорт тогда читается на этом языке, по одной строке на предложение, а любое предложение, которое ещё не переведено, сначала переводится.
Стоит узнавать формат SRT в лицо: это формат субтитров, который поддерживает почти любой плеер, и он представляет собой простой текст. Файл — список блоков, разделённых пустыми строками, и каждый блок состоит из номера, временного диапазона и текста. Временные метки используют запятую перед миллисекундами — французская привычка, от которой формат так и не избавился:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
Одно предложение на блок — правильная зернистость: каждый блок можно прочитать за время, пока он на экране, и ни один не обрывается на полуслове. Если в записи были метки говорящих, текст каждого блока начинается с имени говорящего и двоеточия.
В SDK для TypeScript vs.recordings.export возвращает Response; его байты читаются через arrayBuffer(). В Python метод возвращает 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
Транскрибация сохраняется, поэтому она появляется в разделе Transcriptions панели управления, ей можно поделиться по ссылке и читать её повторно в любое время. Это основной цикл: создать, дождаться, прочитать, экспортировать. Остальная часть руководства описывает варианты: медиа, уже доступное онлайн, ошибки, с которыми вы столкнётесь, и стоимость файла.
Если медиа уже доступно онлайн, передайте API ссылку, и он сам загрузит файл: публичный адрес http(s) (mp3, видео, выпуск подкаста) или видео на YouTube. Вызов тот же — POST /v1/transcriptions, но тело — JSON { "url": "..." } вместо загрузки multipart, а name и settings остаются как прежде. В SDK vs.transcriptions.createFromUrl(url) (TypeScript) и vs.transcriptions.create(url=...) (Python) делают тот же запрос.
Прежде чем что-либо создавать, GET /v1/transcriptions/probe?url= сообщит, что представляет собой ссылка. Её kind — один из трёх значений:
file: запись с концом; расшифруйте её здесь.live: прямая трансляция на YouTube, бесконечный радиопоток или плейлист HLS без конца. Живая ссылка никогда не заканчивается, поэтому её нельзя расшифровать как файл; используйте её как источник живой транскрибации.unknown: сервер не смог определить.Если ссылка вообще не открывается как медиа (например, это веб-страница), ok равен false, а problem в одном предложении объясняет почему. Проба необязательна и ограничена 60 вызовами в минуту.
Задача по ссылке начинается в состоянии fetching, прежде чем перейти в transcribing и done. Пока идёт загрузка, progress.phase показывает, что делает сервер: fetching, пока поступают байты (с fetchedBytes и fetchTotalBytes, если другая сторона сообщает их количество), или extracting, пока звук извлекается из видео. progress.phasePct показывает, насколько далеко продвинулась задача в текущей фазе. Долгое видео обрабатывается какое-то время, поэтому опрашивайте каждые несколько секунд; для этого vs.jobs.wait принимает intervalMs (interval_s в Python).
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}%`);
},
});
В руководстве по расшифровке по ссылке справочника есть полная программа на обоих языках и в HTTP. Загрузка по ссылкам добавляет ещё способы отказа, поэтому вот ошибки, которые вы действительно увидите.
Каждый неудачный вызов возвращает один JSON-конверт. code — строка, по которой нужно ветвиться, status — HTTP-статус; message предназначено для людей, requestId — для поддержки. В data поле retryable говорит, поможет ли повторная отправка того же запроса, а details содержит всё, что нужно для данного кода. SDK выбрасывают один тип ошибки, VocalStackError, с теми же полями.
Три кода стоит обработать с первого дня.
402 insufficient_credit: кредит аккаунта исчерпан, и ничего не списано. details.balance — баланс в долларах, details.topUpUrl — адрес, по которому можно пополнить кредит. Если стоимость была известна заранее, как для файла известной длины, details.required показывает, сколько она составила бы.429 rate_limited: подождите Retry-After секунд. Когда details.metric равен creates_per_hour, вы превысили лимит создания тарифа (новых транскрибаций в час), а не кратковременный лимит; limit и resetsAt показывают ограничение и момент, когда оно освободится.503 unavailable: сервис временно недоступен. Отправьте запрос повторно после Retry-After.SDK повторяют 429 и 503 после Retry-After, по умолчанию дважды (maxRetries / max_retries в клиенте). А 503 повторяется только для вызова, который безопасно отправить дважды: GET или создания с Idempotency-Key. Создание без него остаётся на вашей ответственности, потому что SDK не может знать, прошла ли первая попытка. Это и есть практическая причина ставить ключ на каждую загрузку.
Каждый ответ также содержит RateLimit-Limit, RateLimit-Remaining и RateLimit-Reset, поэтому загруженный конвейер может замедлиться до получения 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…"
}
Полная таблица кодов — в разделе «Ошибки, повторы и лимиты». Этот 402 — единственная ошибка, связанная с деньгами, поэтому вот что стоит файл.
Файлы стоят $0.20 за час аудио, тарификация посекундная ($0.0033 в минуту) с минимумом 5 минут: файл на 30 минут стоит $0.10. Это десять центов, меньше, чем кофе, который вы выпьете, пока заполняется индикатор прогресса. Ставка одинакова на всех тарифах и через API, и в панели управления. Новый аккаунт начинает с разового кредита при регистрации $5, и карта не нужна.
Три ограничения тарифа касаются этого руководства.
GET /v1/realtime/capabilities сообщает как limits.maxUploadBytes. Действует меньший из двух, и файл сверх него отклоняется с 413 до того, как что-либо будет расшифровано. Для длинной записи это ещё одна причина загружать сжатый моно-файл, а не WAV.GET /v1/billing в любой момент сообщает ваш тариф, баланс и лимиты; полная таблица — на странице цен.
Дальше можно перейти к потоковой передаче аудио в реальном времени, если не хочется ждать окончания файла, или к подключению ИИ-агента к тому же аккаунту через MCP-сервер, чтобы он расшифровывал и искал за вас, без всего кода выше.