Завантаження повертає завдання, а не транскрипт. Додавайте Idempotency-Key до кожного створення, щоб повторний запит ніколи не створив і не оплатив другу транскрипцію; далі опитуйте завдання, доки воно не буде готове, і читайте речення, слова або експорт за тим самим ідентифікатором.
Транскрибування файлу є найпростішою дією, яку виконує API VocalStack, і вона має форму будь-якого асинхронного завдання: завантаження, очікування, читання. Завантаження одразу відповідає транскрипцією та її завданням, але ще без жодного слова. Поки ваша програма опитує завдання, сервер обробляє файл у багато разів швидше, ніж відтворюється аудіо. Коли стан стає 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 кГц моно, і все при прийомі передискретизується до цього. 48 кГц стерео WAV із записом однієї людини, яка говорить, більшість байтів витрачає на опис тиші з високою точністю. Транскрибується він так само добре, як моно MP3 або Opus на 64 кбіт/с із тим самим виступом, але він приблизно в двадцять разів більший, тож і завантажується в двадцять разів довше. Якщо ви конвертуєте файл перед завантаженням, варто змінити два параметри: зробити моно та обрати помірний бітрейт.
Надсилайте 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": "..." } замість багаточастинного завантаження, а 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 наведено в посібнику довідника Transcribe a link. Завантаження за посиланням додає ще способів збою, тож ось помилки, які ви справді побачите.
Кожна невдала відповідь приходить в одному 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, щоб він транскрибував і шукав за вас без жодного коду вище.