بارگذاری یک فایل، شناسهی یک کار را برمیگرداند، نه متن رونویسی. روی هر ایجاد یک Idempotency-Key بگذارید تا تلاش دوباره هرگز رونویسی یا هزینهی دومی نسازد؛ سپس کار را تا پایان بررسی کنید و جملهها، کلمهها یا خروجی را با همان شناسه بخوانید.
رونویسی فایل سادهترین کاری است که VocalStack API انجام میدهد، و ساختاری مثل هر کار ناهمگام دارد: بارگذاری، انتظار، خواندن. بارگذاری بیدرنگ پاسخ میدهد با یک رونویسی و کارش، اما هنوز کلمهای ندارد. تا برنامهی شما کار را بررسی میکند، سرور فایل را چندین برابر سریعتر از پخش شدن صدا رونویسی میکند. وقتی وضعیت done شد، متن را بهصورت جملههایی همراه با گوینده و زمانبندی میخوانید، یا آن را در یکی از چهار قالب فایل خروجی میگیرید، به زبان گفتاری یا ترجمهشده.
هر چیزی با این شکل به هر API دستهای منتقل میشود: ایجادی که شناسه برمیگرداند، وضعیتی که مرتب بررسی میکنید، و نتیجهای که بعداً میگیرید. فقط نام فیلدها مخصوص VocalStack است، و این آموزش آنها را با SDKهای TypeScript و Python و در جاهایی که کمک میکند با فراخوانیهای HTTP توضیح میدهد.
در Developers → API keys در داشبورد، کلیدی با دسترسیهای Read و Write بسازید. کلید فقط یک بار نشان داده میشود؛ آن را در یک متغیر محیطی کپی کنید:
export VOCALSTACK_API_KEY="<your API key>"
کلید برای سرورها و اسکریپتهاست. هر چیزی که به مرورگر فرستاده شود، کلید را هم همراهش میفرستد، پس کلید را از کد صفحه و از URLها دور نگه دارید، چون URLها در لاگها ثبت میشوند. برای استریم از مرورگر، سرور شما یک نشست بلادرنگ برای آن میسازد؛ این موضوع آموزشی جداگانه دارد.
هر فراخوانی کلید را بهصورت توکن bearer احراز هویت میکند:
Authorization: Bearer vs_key_...
با کلید در محیط، قدم بعدی ساختن کلاینتی است که آن را بفرستد.
دو SDK وجود دارد. هر کدام یک کلاینت به نام VocalStack دارد که متدهایش بر اساس منبع گروهبندی شدهاند: vs.transcriptions، vs.jobs، vs.recordings، vs.realtime و چند مورد دیگر. SDK تایپاسکریپت روی Node 20 یا جدیدتر و در مرورگرها اجرا میشود؛ 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 کیلوهرتز تککاناله کار میکند و همهچیز در لحظهی رسیدن به همین نرخ تغییر نمونه داده میشود. یک WAV استریوی 48 کیلوهرتزی از یک نفر که صحبت میکند، بیشترِ حجمش را صرف توصیف سکوت با وفاداری بالا میکند. دقیقاً به اندازهی یک فایل MP3 تککانالهی 64 کیلوبیت بر ثانیه یا Opus از همان گفتار رونویسی میشود، اما حدود بیست برابر بزرگتر است و بارگذاریاش بیست برابر طول میکشد. اگر پیش از بارگذاری تبدیل میکنید، تککاناله بودن و نرخ بیت متوسط دو تنظیمیاند که ارزش تغییر دارند.
یک 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 وقتی کار به آن برسد هم برمیگردد. تا وقتی وضعیت done یا failed شود، GET /v1/recordings/{id}/job را بخوانید.
وبهوک وجود ندارد، و این یک انتخاب عمدی است. وبهوک یک نقطهی پایانی HTTPS عمومی در سمت شما لازم دارد، بررسی امضا تا کس دیگری نتواند آن را صدا بزند، و سیاست تلاش دوباره برای وقتی که نقطهی پایانی شما از کار افتاده؛ این حجم زیادی زیرساخت برای کاری است که یک دقیقه طول میکشد. پولینگ به هیچیک از اینها نیاز ندارد. اسکریپت شما هر دو ثانیه میپرسد «تمام شد؟»، مثل کودکی در صندلی عقب ماشین، با این تفاوت که برخلاف کودک درصد پیشرفت هم دریافت میکند.
صفحهی وبی که از قبل باز است میتواند به جای آن، سوکت رویدادهای رونویسی را دنبال کند (WS /v1/transcriptions/{id}/events، با فریم job.progress وقتی کار پیش میرود). برای یک اسکریپت، پولینگ ابزار درست است.
SDKها پولینگ را انجام میدهند: vs.jobs.wait هر 2 ثانیه کار را میخواند و هر خواندن را به onProgress میدهد (در Python، on_progress). 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 ارزش آشنایی دارد، چون قالب زیرنویسی است که تقریباً هر پخشکنندهای میپذیرد و متن ساده است. فایل فهرستی از cueها (بخشهای زیرنویس) است که با خط خالی از هم جدا شدهاند، و هر cue یک شمارنده، یک بازهی زمانی و متن است. زمانها پیش از میلیثانیه از ویرگول استفاده میکنند، عادتی فرانسوی که این قالب هرگز از آن دست نکشیده:
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
یک جمله برای هر cue، اندازهی درست است: هر cue در زمانی که روی صفحه است خوانده میشود و هیچکدام وسط فکر قطع نمیشود. وقتی ضبط برچسب گوینده داشته باشد، متن هر cue با نام گوینده و یک دونقطه شروع میشود.
در 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 در داشبورد دیده میشود، با لینک قابل اشتراکگذاری است، و هر زمان بعد دوباره میشود آن را خواند. این حلقهی اصلی است: ایجاد، انتظار، خواندن، خروجی. بقیهی این آموزش به تنوعها میپردازد: رسانهای که از قبل آنلاین است، خطاهایی که با آنها روبهرو میشوید، و هزینهی یک فایل.
اگر رسانه از قبل آنلاین است، لینکش را به 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 (در Python، interval_s) میگیرد.
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، تا بدون هیچیک از کدهای بالا رونویسی و جستوجو کند.