Качването връща задача, а не транскрипт. Сложете Idempotency-Key на всяко създаване, за да не може повторен опит никога да създаде или таксува втора транскрипция; после проверявайте задачата, докато е готова, и четете изреченията, думите или експорт от същия идентификатор.
Транскрибирането на файл е най-простото нещо, което прави VocalStack API, и има формата на всяка асинхронна задача: качване, изчакване, четене. Качването отговаря веднага с транскрипция и нейната задача, но още без думи. Докато програмата ви проверява задачата, сървърът транскрибира файла много пъти по-бързо, отколкото се възпроизвежда звукът. Когато състоянието е done, четете транскрипта като изречения с говорители и времена или го експортирате като файл в един от четирите формата – или на езика на записа, или преведен.
Всичко в тази форма важи за всеки API за пакетна обработка: създаване, което връща идентификатор, статус, който проверявате, и резултат, който изтегляте после. Само имената на полетата са специфични за VocalStack. Този урок преминава през тях със SDK-та на TypeScript и Python и, където е полезно, с HTTP заявки.
Отворете Developers → API keys в Dashboard и създайте ключ с разрешенията 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 или по-нов и в браузъри; Python SDK-то е изградено върху 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 kHz моно и всичко се преобразува до това при получаване. Стерео WAV с 48 kHz на един човек, който говори, харчи по-голямата част от байтовете си за описание на тишина с висока точност. Транскрибира се точно толкова добре, колкото MP3 или Opus файл с 64 kbps моно от същата беседа, но е около двадесет пъти по-голям, така че качването отнема двадесет пъти повече. Ако преобразувате преди качване, си струва да смените две неща: моно и разумен битрейт.
Изпращайте Idempotency-Key с всяко качване и разберете защо. Мрежов timeout не ви казва нищо за това какво е направил сървърът: той може да не е видял заявката, или може да е записал файла и да е сложил задачата на опашка, преди отговорът да се загуби. Сляпо повторение на заявката затова рискува втора транскрипция и втора такса. Ключът решава това. Когато заявка се повтори със същия ключ и тяло в рамките на 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, означава, че задачата е спряна, докато някой избере кой участък от записа да се транскрибира, както прави стъпката за подрязване в Dashboard. Обикновено качване никога не влиза в нея, макар че jobs.wait връща и когато задачата стигне дотам. Четете GET /v1/recordings/{id}/job, докато състоянието е done или failed.
Няма webhook-ове и това е съзнателен компромис. Webhook изисква публична 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.
Едно изречение на субтитър е правилната зърнистост: всеки субтитър може да се прочете за времето, в което е на екрана, и нито един не прекъсва мисъл по средата. Когато записът има етикети на говорители, текстът на всеки субтитър започва с името на говорителя и двоеточие.
В TypeScript SDK-то 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 в Dashboard, може да се споделя с линк и да се чете отново по всяко време по-късно. Това е основният цикъл: създаване, изчакване, четене, експорт. Останалата част от урока разглежда вариантите: медия, която вече е онлайн, грешките, които ще срещнете, и какво струва един файл.
Ако медията вече е онлайн, подайте на 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, и в Dashboard. Новият акаунт започва с еднократен кредит от $5 при регистрация, без да се изисква карта.
Три ограничения на плана засягат този урок.
GET /v1/realtime/capabilities показва като limits.maxUploadBytes. Прилага се по-малкият от двата, а файл над него се отхвърля с 413, преди да е транскрибирано нещо. За дълъг запис това е още една причина да качвате компресиран моно файл, а не WAV.GET /v1/billing показва плана, баланса и лимитите ви по всяко време; пълната таблица е на страницата с цени.
Оттук нататък следващите стъпки са поточно предаване на аудио в реално време, когато не можете да чакате файл да свърши, или свързване на AI агент към същия акаунт през MCP сървъра, така че той да транскрибира и търси вместо вас, без нито един ред от кода по-горе.