الرفع يُعيد مهمةً لا نصًا. ضع Idempotency-Key على كل طلب إنشاء حتى لا تُنشئ إعادة المحاولة تفريغًا ثانيًا ولا تُحاسَب عليه، ثم استعلم عن المهمة حتى تكتمل، واقرأ الجمل أو الكلمات أو التصدير من المعرّف نفسه.
معالجة الملف هي أبسط ما تفعله واجهة VocalStack API، ولها شكل أي مهمة غير متزامنة: ارفع، ثم انتظر، ثم اقرأ. يُجيب الرفع فورًا بتفريغ ومهمته، لكن دون كلمات بعد. وبينما يستعلم برنامجك عن المهمة، يُفرّغ الخادم الملف أسرع بعدة أضعاف من سرعة تشغيل الصوت. وعندما تصبح الحالة done تقرأ النص مقسّمًا إلى جمل مع المتحدثين وتوقيتاتهم، أو تصدّره ملفًا بأحد أربعة تنسيقات، باللغة المنطوقة أو مترجمًا.
كل ما في هذا الشكل ينطبق على أي واجهة دفعات: إنشاء يُعيد معرّفًا، وحالة تستعلم عنها، ونتيجة تجلبها بعد ذلك. أما أسماء الحقول فهي وحدها الخاصة بـVocalStack، وهذا الدليل يمرّ عليها بحزمتي SDK لـTypeScript وPython، وبطلبات HTTP حيثما تفيد.
افتح Developers → API keys في لوحة التحكم، وأنشئ مفتاحًا بالصلاحيتين Read وWrite. يُعرض المفتاح مرة واحدة فقط، فانسخه إلى متغير بيئة:
export VOCALSTACK_API_KEY="<your API key>"
المفتاح مخصص للخوادم والسكربتات. كل ما يُشحن إلى المتصفح يحمل المفتاح معه، فأبقه خارج شيفرة الصفحة وخارج عناوين URL، لأنها تنتهي في السجلات. وللبث من المتصفح، ينشئ خادمك جلسة فورية له؛ وهذا موضوع دليل آخر.
كل استدعاء يتوثّق بالمفتاح كرمز حامل (bearer token):
Authorization: Bearer vs_key_...
وبوجود المفتاح في بيئتك، تأتي الخطوة التالية: عميل يرسله.
هناك حزمتا SDK. لكل منهما عميل واحد هو VocalStack، وتُجمَّع طرقه حسب المورد: vs.transcriptions وvs.jobs وvs.recordings وvs.realtime وبضعة أخرى. تعمل حزمة TypeScript على Node 20 أو أحدث، وفي المتصفحات. أما حزمة Python فمبنية على asyncio وتحتاج إلى Python 3.10 أو أحدث.
حزم SDK غير منشورة على npm أو PyPI، إذ تقدّم الواجهة نسخها لكل حساب. أوامر التثبيت، معبّأةً لبيئتك، موجودة في قسم التثبيت من دليل البدء السريع في مرجع 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 أحادي القناة بمعدل 64 kbps أو 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 يعود أيضًا عند وصولها إليها. اقرأ GET /v1/recordings/{id}/job حتى تصبح الحالة done أو failed.
لا توجد webhooks، وهذا خيار مقصود. فالـwebhook يحتاج إلى نقطة HTTPS عامة عندك، وفحصًا للتوقيع حتى لا يستدعيها غيرك، وسياسة إعادة محاولة لحالة تعطّل نقطتك، وهذه كثير من الأنابيب لمهمة تستغرق دقيقة. أما الاستعلام الدوري فلا يحتاج شيئًا من ذلك. سيسأل برنامجك "هل انتهت؟" كل ثانيتين، مثل طفل في المقعد الخلفي للسيارة، لكنه على عكس الطفل يحصل على نسبة مئوية.
صفحة ويب مفتوحة أصلًا يمكنها متابعة قناة أحداث التفريغ بدلًا من ذلك (WS /v1/transcriptions/{id}/events، مع إطار job.progress يتقدم مع المهمة). أما للسكربت فالاستعلام الدوري هو الأداة الصحيحة.
تتولى حزم SDK الاستعلام الدوري: تقرأ vs.jobs.wait المهمة كل ثانيتين، وتمرّر كل قراءة إلى 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. والقيمة -1 في speaker تعني أن تسميات المتحدثين كانت معطلة للتسجيل. أضف ?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، يعيد 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
يبقى التفريغ محفوظًا، فيظهر ضمن التفريغات النصية في لوحة التحكم، ويمكن مشاركته برابط، وإعادة قراءته في أي وقت. هذه هي الحلقة الأساسية: أنشئ، وانتظر، واقرأ، وصدّر. وبقية الدليل تغطي التنويعات: وسائط موجودة على الإنترنت أصلًا، والأخطاء التي ستواجهها، وما تكلّفه الملفات.
إن كانت الوسائط موجودة على الإنترنت أصلًا، فأعطِ الواجهة الرابط وستجلب الملف بنفسها: عنوان 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. وجلب الروابط يضيف طرقًا أخرى للفشل، لذا إليك الأخطاء التي ستراها فعلًا.
كل استدعاء فاشل يعيد غلافًا واحدًا بصيغة 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. أما الإنشاء بلا مفتاح فيُترك لك، لأن الحزمة لا تعرف هل وصلت المحاولة الأولى. وهذا هو السبب العملي لوضع مفتاح على كل رفع.
يحمل كل رد أيضًا 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، فيفرّغ ويبحث عنك دون أي من الشيفرة أعلاه.