يصل النص المفرَّغ المباشر مرتين: أولًا كمسودة تتغير باستمرار، ثم كسطر نهائي لا يتغير. اعرض المسودة كذيل قابل للاستبدال والسطر النهائي كسجلّ، وما تبقّى من عرض التسميات الحية فهو أعمال توصيل.
الجلسة المباشرة هي أصغر ما تقدمه واجهة VocalStack البرمجية. يُجري خادمك طلب HTTP واحدًا ويحصل على streamUrl موقَّعًا. يفتح عميلك WebSocket إلى ذلك العنوان، ويرسل الصوت الخام أثناء التقاطه، ويقرأ النص المفرَّغ كإطارات JSON نصية تصل بعد المتحدث بلحظات. وعندما ينتهي الصوت ترسل eof، فيُنهي الخادم المعالجة، ويُبلغ بما قاسته الجلسة، ثم يغلق الاتصال.
لا يُخزَّن شيء. يوجد النص المفرَّغ على الشبكة فقط، وهذا ما يحتاجه عرض التسميات أو تراكب الاجتماعات أو ميزة صوتية داخل تطبيقك. وإن أردت حفظ الكلمات ومشاركتها وتصديرها لاحقًا، فأنت تحتاج إلى تفريغ نصي مباشر وبث، والقسم الأخير يشير إلى ذلك.
هذا الشكل هو الذي تشترك فيه معظم واجهات تحويل الكلام إلى نص المتدفقة: تبادل بيانات الاعتماد عبر HTTP، ومقبس طويل الأمد يدخل فيه الصوت ويخرج منه JSON، وطريقة للتمييز بين النص المؤقت والنص النهائي. تختلف الأسماء من واجهة إلى أخرى، أما الأجزاء المتحركة فنادرًا ما تختلف. وهذه الخطوات بالترتيب:
POST /v1/realtime/sessions بمفتاح API الخاص بك، على خادمك. والرد هو { sessionId, streamUrl, expiresAt }.streamUrl.session.loading حتى يصل session.ready.transcript.draft (استبدل الذيل) وtranscript.committed (أضف سطرًا نهائيًا).{"type":"eof"}، واقرأ session.done.الخطوة الأولى وحدها تحتاج إلى مفتاحك، لذلك تحدث على خادمك.
أنشئ مفتاحًا من Developers, API keys بصلاحية Write. المفتاح مخصص للخوادم والسكربتات: تُرفض واجهة API أي مفتاح يُرسل من صفحة ويب أو في عنوان URL، لذلك تنشئ الجلسة خلف مسار خاص بك، ولا ترى الصفحة سوى streamUrl.
يحمل الرابط رمزه الخاص بسبب قيد في المتصفح يستحق الانتباه بشكل عام. لا يستطيع مُنشئ WebSocket في المتصفح ضبط ترويسات الطلب، فلا توجد ترويسة Authorization يمكن وضع المفتاح فيها. لذلك تُصادق كل واجهة كلام تعمل مع المتصفح مقبسها برمز قصير العمر في عنوان URL (أو مُدمج في اسم بروتوكول فرعي). وقِصَر العمر هو المقصود، فالروابط تنتهي في السجلات وسجل التصفح، لذلك يجب أن ينتهي صلاحية ما تحمله. اتصل قبل expiresAt.
تُنزَّل حزم SDK من منطقة المطورين، لا من مستودع حزم، ويعرض قسم التثبيت في دليل البدء السريع الأوامر الخاصة بهذه البيئة. ومع SDK لـ TypeScript يبدو مسار الخادم هكذا:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
من دون SDK يكفي fetch واحد. يطلب هذا المثال الإسبانية أيضًا، ويوقف تجميع الجمل، مطابقًا لعينة الإطارات الواردة لاحقًا:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
لكل خيار قيمة افتراضية. أهمها في البداية spokenLanguage (إن تركته تُكتشف اللغة تلقائيًا)، وtranslate (قائمة لغات الهدف)، وspeakerLabels وlatencyMode، أما البقية فتظهر عند الحاجة في ما يلي.
أعطِ streamUrl لما يلتقط الصوت، ومن هنا فصاعدًا لا يدخل أي مفتاح في الصورة.
اتصل بـ streamUrl كما هو، بلا ترويسة. وبينما تبدأ الجلسة يرسل الخادم نبضات session.loading، ثم يأتي session.ready. لا ترسل أي صوت قبله، فالصوت المرسل مبكرًا لا يُحفظ لوقت لاحق؛ إذ يعامله الخادم كخطأ، فيرسل إطار error ويغلق المقبس.
بعد فتح المقبس يأخذ كل فشل شكل ذلك الإطار: {"type":"error","code":"…","message":"…","retryable":true|false}. الحقل code قائمة مغلقة (invalid_audio، invalid_options، invalid_token، capacity_exceeded، session_expired، slow_consumer، engine_unavailable، internal)، وmessage مخصص لسجلاتك، وretryable يخبرك هل تستحق جلسة جديدة المحاولة. تعامل معه من أول إطار:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
على العميل الخام أن يتولى أمرين إضافيين بنفسه، أما SDK فتتولاهما عنك. الأول: رفض ترقية الاتصال يحمل مظروف الخطأ المعتاد في واجهة HTTP، وهو 401 invalid_token أو 410 session_expired أو 503 unavailable مع ترويسة Retry-After يجب احترامها.
الثاني: الانشغال يبدو كصمت لا كخطأ. فإن لم يصل أي إطار خلال 30 ثانية، فاعتبرها capacity_exceeded وأعد الاتصال، فالجلسة الجديدة تنجح غالبًا. وإن استمرت نبضات التحميل في الوصول دون أن يصل session.ready أبدًا، فاعتبرها engine_unavailable، وهي قابلة للمحاولة أيضًا. وفي SDK يُحل RealtimeSession.connect عند session.ready، ويفشل بعد readyDeadlineMs بالرمز الصحيح.
وما إن يصل session.ready حتى يتوقع الخادم الصوت بالوتيرة التي يُنطق بها.
يدخل الصوت كإطارات ثنائية من PCM خام: 32-bit float، بترتيب little-endian، وتردد 16 kHz، وقناة واحدة (pcm_f32le، وهي صيغة audio الافتراضية للجلسة). أرسله كما يُلتقط، في مقاطع تقارب 100 إلى 250 ميلي ثانية، دون انتظار النص بين مقطع وآخر.
يستحق حساب البايتات أن تقوم به مرة واحدة. 16,000 عينة في الثانية بأربعة بايتات لكل عينة تعني 64,000 بايت في الثانية، فـ100 ميلي ثانية تساوي 6,400 بايت، و250 ميلي ثانية تساوي 16,000 بايت. وتُدرَّب نماذج الكلام عادةً على صوت 16 kHz أحادي، ولهذا تطلبه كثير من واجهات الوقت الفعلي، بينما تلتقط الميكروفونات والمتصفحات بتردد 44.1 أو 48 kHz وغالبًا بقناتين. لذا لا بد من شيء يعيد أخذ العينات ويدمج القنوات في قناة واحدة قبل إرسال البايتات: هنا هذا الدور لـ ffmpeg، وفي المتصفح يتولاه مساعد الميكروفون في SDK. وإرسال صوت 48 kHz مع الإعلان عن 16 kHz لا يفشل، لكنه يفرّغ كلامًا كأنه بالحركة البطيئة، وبدقة سيئة.
في Node يُعد ffmpeg أبسط جهاز التقاط:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.send لا يتوقف أبدًا، بل يضع البيانات في طابور. وbufferedAmount هو حجم ذلك الطابور، وعلى رابط سليم يبقى قريبًا من الصفر. والصوت المباشر لا يمكن إيقافه مؤقتًا، لذا فالرد المفيد على طابور متنامٍ هو ملاحظته، لا إسقاط الإطارات. ويطبق الخادم الضغط العكسي كذلك: يقبل الصوت قبل الوقت الفعلي بثوانٍ قليلة (limits.audioBufferS)، ثم يتوقف عن القراءة حتى يلحق. وفي الاتجاه المعاكس تُوضع التثبيتات في طابور دون فقدان، بينما تُدمج المسودات، فالقارئ البطيء يرى مسودات أقل وأحدث. أما العميل الذي لا يلحق حتى بالتثبيتات، فيتلقى إطار error برمز slow_consumer وretryable: false ثم يُفصل؛ وإن رأيت ذلك، فمعالج الرسائل لديك يقوم بعمل زائد.
في المتصفح، تطلب streamMicrophone من @vocalstack/sdk/mic إذن الميكروفون، وتعيد أخذ العينات إلى 16 kHz، وترسل المقاطع مع إطارات clock التي تتيح للخادم تسجيل الفجوات حين يُبطئ المتصفح التبويب. تحصل الصفحة على الجلسة من مسارك ولا ترى المفتاح أبدًا:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
توقيع render(committed, tail) هذا هو نمط الواجهة كلها، والقسم التالي يشرح السبب.
يبني إطاران النص، والخلط بينهما هو أكثر الأخطاء شيوعًا.
أولًا، لماذا يوجد إطاران أصلًا؟ فنموذج الكلام يحدد ما كانت عليه الكلمة جزئيًا بالاستناد إلى الكلمات التي تليها: فـ«I scream» و«ice cream» يتشابهان في النطق حتى تكتمل الجملة. لذلك يُصدر المتعرّف في الوقت الفعلي تخمينًا مبكرًا، ثم يعدّله كلما وصل صوت جديد، ولا يستقر إلا بعد أن يسمع ما يكفي. تعرض كل واجهة تحويل كلام متدفقة هذا بطريقة ما. بعضها يسمي التخمينات نتائج «مرحلية» أو «جزئية»، ويُعلّم المستقرة منها is_final؛ تختلف المصطلحات، أما الانضباط فواحد. هنا يُسمّى التخمين مسودة، والنص المستقر تثبيتًا.
transcript.draft هو الذيل المباشر: الكلمات المسموعة منذ آخر تثبيت، وما زالت تتغير. كل مسودة استبدال كامل للسابقة. أعد رسم الذيل بحقل segments، ولا تُلحقه أبدًا. والمسودات لا تحمل seq عن قصد، فلا تحاول مطابقتها بالتثبيتات التي تصير إليها.
transcript.committed نهائي: مقطع واحد segment فيه seq (0، 1، 2، متصلة بلا فجوات)، وstart وend وtext وspeaker وwords مع توقيت كل كلمة. أضفه وأسقط المسودة التي يغطيها، فالمسودة التالية تبدأ حيث انتهى هذا التثبيت.
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
الواجهة التي تُلحق المسودات تعرض نصًا مكررًا ومرتعشًا، فيظهر لعرض التسميات تقطّع لم يكن المتحدث يعانيه. اعرض كل المقاطع المثبتة، ثم أحدث مسودة بعدها، وامسح المسودة عند وصول تثبيت. وقد يحتوي التثبيت على كلمات لم تظهر في أي مسودة قط.
إليك بضع ثوانٍ من الكلام كما تصل على الخط، من مثال fetch أعلاه (translate: ["es"]، وتجميع الجمل متوقف):
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
هناك إطاران إضافيان حول هذه. transcript.amended يصحح مقطعًا مثبتًا في مكانه (بالـseq نفسه، ونص text أو speaker جديد) عندما تُصحَّح كلمة فيه؛ طبِّقه على السطر الذي رسمته من قبل. وtranscript.words يبث توقيتات الكلمات للذيل قبل تثبيته مباشرة، ويحتاج إلى events.words، وهو مفعّل افتراضيًا؛ والتثبيت يحمل words دائمًا، مهما كانت خيارات الأحداث. وأخيرًا، تجاهل أنواع الإطارات التي لا تعرفها، فالجديد منها يُضاف دون إشعار.
هذه حلقة القراءة في SDK بلغة Python، والطرفية هي عرض التسميات. وتوقف تجميع الجمل هنا أيضًا، فتصل الترجمات كـtranscript.translated، ويظهر إطار error كاستثناء VocalStackError يخرج من الحلقة:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
الترجمة لكل مقطع، كما في المثال أعلاه، هي الصيغة البسيطة. أما الصيغة التي يفضلها القراء فلها قسم خاص بها.
يطلب translate: ["es", "fr"] ترجمات أثناء سير الجلسة. وطريقة وصولها تعتمد على تجميع الجمل (sentences: { enabled: true })، وهو مفعّل افتراضيًا.
مع إيقاف الجمل، يتبع كل تثبيتَ بعد لحظة إطار transcript.translated يذكر seq الخاص به ويحمل translations مفهرسة حسب اللغة، فتُلحقها بذلك المقطع. وهكذا تعمل الأمثلة حتى الآن. إنها بسيطة وتقرأ كسجل تثبيتات: مقطع واحد وترجمة واحدة، وحدّ الجملة حيث التقط المتحدث أنفاسه.
مع تشغيل الجمل، يجمع الخادم التثبيتات في جمل كاملة ويترجمها، وهذا أفضل بكثير للقراءة. كل إطار transcript.sentence استبدال كامل لجملة واحدة مفتاحها sentenceId. وتنتقل status من open إلى closed (أو dropped، أي تجاهلها)، وتكون translations مؤقتة ما دامت الجملة مفتوحة، ونهائية بعد إغلاقها. حدّث بالـsentenceId ولا تُلحق أبدًا. وتوفر SDK الخاصة بـ TypeScript الدالة applySentence لهذا الغرض بالذات:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
let sentences: SentenceSegment[] = [];
for await (const ev of session) {
if (ev.type !== 'transcript.sentence') continue;
sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`);
}
تتأخر الترجمات عن النص ثانية أو ثانيتين. وإن أردت أن تُنطق أيضًا، فأضف voice: ["es"] (ويحتاج إلى الجمل). عندها تصل الترجمة الإسبانية لكل جملة مغلقة كإطار transcript.voice يحمل المقطع الصوتي كـ Ogg/Opus بترميز base64 وبتردد 24 kHz أحادي. وتصل الجملة الطويلة على أجزاء (part من parts) لتُشغَّل متتالية. ولا يُنطق النص المؤقت أبدًا. ويغطي دليل الترجمة المباشرة الأصوات بالتفصيل، وأين تذهب الثواني يشرح سلسلة التأخير.
لا تُحدَّد لغات الهدف عند الإنشاء.
يغيّر إطار النص session.update القيم spokenLanguage وtranslate وspeakerLabels وlatencyMode وvoice أو voiceByLang من تلك النقطة فصاعدًا، والحقول التي تتركها تبقى كما هي.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
تعرضها SDK كـsession.update(…). يستبدل translate القائمة كاملة، والقائمة [] توقف الترجمة. لا تُعاد تفريغ المقاطع المسلَّمة ولا ترجمتها، وإن فعّلت تسميات المتحدثين فإنها تُطبَّق على ما بعد ذلك فقط. ما قيل يبقى كما قيل، والإعدادات تحكم ما يأتي فقط.
عند انتهاء الصوت، أرسل {"type":"eof"} (session.eof() في SDK). يفرّغ الخادم ما بقي لديه، ويرسل آخر التثبيتات، ثم session.done ويغلق الاتصال. أما إرسال cancel فيوقف فورًا ويُسقط الباقي.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
قيمة reason إما eof أو max_duration (وصلت الجلسة إلى الحد الأقصى لطول الخطة، وأجرت التفريغ الأخير نفسه) أو cancelled. وstats يقيسها الخادم: فـaudioS هو الصوت الذي أرسلته، وprocessingS هو زمن الساعة الذي استغرقته الجلسة، وrealtimeFactor نسبتهما. تقترب الجلسة المباشرة من 1 قليلًا، لأن زمن الساعة يشمل التفريغ الأخير بعد eof. والرقم الذي ينخفض كثيرًا عن ذلك يعني أن الجلسة استغرقت وقتًا أطول بوضوح من مدة الصوت، وأول ما تتحقق منه هو سرعة وصول الصوت. أما للملف، فالرقم نفسه يخبرك بكم مرة أسرع من التشغيل عاد النص.
تدوم الجلسة على الأكثر limits.maxDurationS من GET /v1/realtime/capabilities، ويُخصم صوتها من رصيدك بالثانية بسعر البث المباشر في خطتك، مع الحد الأدنى نفسه البالغ 5 دقائق لأي تفريغ: فكل جلسة تستقبل صوتًا تُحتسب على الأقل 5 دقائق (الأسعار). والمسار نفسه يقبل ملفًا كاملًا أيضًا: أنشئ الجلسة بـinput: { kind: "file" }، وأرسل بايتات الملف نفسه (mp3 أو wav أو flac أو ogg أو m4a) ثم eof، فيعود النص أسرع من تشغيل الصوت بمرات كثيرة، مع إطارات transcript.progress على الطريق.
لا يوجد استئناف. فالاتصال المقطوع ينهي الجلسة، والاتصال من جديد يعني إنشاء جلسة جديدة تُحتسب حدها الأدنى الخاص، والبث من مصدرك المباشر من جديد مع عودة seq إلى 0. وإعادة الاتصال نفسها نمط قياسي لأي واجهة مقابس، وتستحق أن تُتقن مرة واحدة:
retryable: true في إطار error، أو 503 مع Retry-After، أو 30 ثانية دون أي إطار، وهذا يعني capacity_exceeded. أما invalid_token وsession_expired فتعنيان streamUrl جديدًا من خادمك، لا محاولة أخرى بالرابط القديم.أبقِ المسار الذي ينشئ الجلسة رخيصًا في الاستدعاء، وتعامل مع الإغلاق كبداية جديدة. وغياب الاستئناف هو ثمن عدم تخزين أي شيء؛ فإن كان لا بد أن يعيش النص بعد المقبس، فاحتفظ به أنت.
حين تريد حفظ البث نفسه كتسجيل، وتصحيحه لاحقًا، وتصديره، وقراءته من جمهور بلغاته، فانشره كتفريغ مباشر بدلًا من ذلك. يشرح دليل البث المباشر كيف يمر مقبس PCM نفسه عبر تفريغ مباشر ورابط بث، ويعرض دليل الاستماع الجانب المشترك، حيث يختار كل مستمع لغته، ويلحق من يتأخر في الانضمام بما فاته عبر مؤشر lastSeq. وما يبدو عليه ذلك لغرفة واحدة هو موضوع رابط واحد، بكل اللغات.
ولملف تريد حفظه، فالمسار المخزَّن هو تفريغ ملف عبر واجهة API. والمرجع الكامل لإطارات البث، بكل حقل فيه، موجود في دليل بث الميكروفون في مرجع API.