یک رونویسی بلادرنگ دو بار میرسد: اول بهصورت پیشنویسی که دائم تغییر میکند، و بعد بهصورت یک خط نهایی که دیگر تغییر نمیکند. پیشنویس را بهعنوان بخش انتهایی قابلجایگزینی نمایش دهید و خط نهایی را بهعنوان تاریخچه؛ بقیهٔ نمای زیرنویس زنده فقط لولهکشی است.
یک نشست بلادرنگ کوچکترین کاری است که API ویساستک انجام میدهد. سرور شما یک درخواست HTTP میفرستد و یک streamUrl امضاشده دریافت میکند. کلاینت شما یک WebSocket به آن URL باز میکند، صوت خام را همانطور که ضبط میشود میفرستد، و متن رونویسی را بهصورت فریمهای متنی JSON، چند لحظه پس از گوینده، میخواند. وقتی صوت تمام میشود، eof میفرستید؛ سرور تخلیه میکند، آمار اندازهگیریشدهٔ نشست را گزارش میدهد و بسته میشود.
هیچ چیزی ذخیره نمیشود. متن فقط روی سیم وجود دارد؛ و این همان چیزی است که یک نمای زیرنویس، یک لایه روی جلسهٔ تصویری یا یک قابلیت صوتی در اپ خودتان لازم دارد. اگر میخواهید کلمات بعداً نگه داشته، به اشتراک گذاشته و خروجی گرفته شوند، بهجای آن به یک رونویسی زنده و یک پخش نیاز دارید؛ بخش آخر به آنها اشاره میکند.
الگوی این کار در بیشتر APIهای رونویسی جریانی مشترک است: تبادل اعتبار از طریق HTTP، یک سوکت بلندمدت که صوت به درونش میرود و JSON از آن بیرون میآید، و راهی برای تشخیص متن موقت از متن نهایی. نامها از یک API به API دیگر فرق میکنند؛ اجزای اصلی تقریباً همیشه یکی هستند. در اینجا، به ترتیب:
POST /v1/realtime/sessions با کلید API شما، روی سرور شما. پاسخ { sessionId, streamUrl, expiresAt } است.streamUrl باز کنید.session.loading صبر کنید تا session.ready برسد.transcript.draft را بخوانید (بخش انتهایی را جایگزین کنید) و transcript.committed را (یک خط نهایی اضافه کنید).{"type":"eof"} بفرستید؛ session.done را بخوانید.فقط مرحلهٔ ۱ به کلید شما نیاز دارد، پس روی سرور شما انجام میشود.
یک کلید با دسترسی Write در Developers, API keys بسازید. کلید برای سرورها و اسکریپتها است: API هر کلیدی را که از یک صفحهٔ وب یا در یک URL فرستاده شود رد میکند، پس نشست را پشت یک مسیر خودتان میسازید و صفحه فقط streamUrl را میبیند.
URL توکن خودش را حمل میکند، بهخاطر یک محدودیت مرورگر که دانستنش بهطور کلی ارزش دارد. سازندهٔ WebSocket در مرورگر نمیتواند هدرهای درخواست تنظیم کند، پس هدر Authorization ای وجود ندارد که کلید در آن گذاشته شود؛ هر API گفتاری که برای مرورگر طراحی شده، سوکتش را با یک توکن کوتاهعمر در URL احراز هویت میکند (یا در نام یک subprotocol پنهان میشود). کوتاهعمر بودن نکتهٔ اصلی است: URL در لاگها و تاریخچه ثبت میشود، پس آنچه حمل میکند باید منقضی شود. پیش از expiresAt وصل شوید.
SDKها از بخش Developers دانلود میشوند، نه از یک مخزن بستهها؛ بخش Install در راهنمای شروع سریع دستورهای مخصوص همین محیط را نشان میدهد. با SDK تایپاسکریپت، یک مسیر سرور این شکل را دارد:
// 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 از API را همراه دارد: 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 بیتی، little-endian، 16 kHz، تککاناله (pcm_f32le، قالب پیشفرض audio نشست). همانطور که ضبط میشود بفرستید، در تکههای حدود 100 تا 250 میلیثانیه، بدون اینکه بین تکهها منتظر متن بمانید.
محاسبهٔ بایتها یک بار ارزش دارد. 16,000 نمونه در ثانیه با 4 بایت برای هر نمونه میشود 64,000 بایت در ثانیه، پس 100 میلیثانیه میشود 6,400 بایت و 250 میلیثانیه میشود 16,000 بایت. مدلهای گفتار معمولاً روی صدای تککاناله با 16 kHz آموزش دیدهاند، و به همین دلیل خیلی از APIهای بلادرنگ این را میخواهند؛ در حالی که میکروفونها و مرورگرها با 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 اندازهٔ همان صف است و روی یک لینک سالم نزدیک صفر میماند. صوت زنده را نمیتوان متوقف کرد، پس پاسخ مفید به صفی که بزرگ میشود این است که آن را متوجه شوید، نه اینکه فریمها را دور بریزید. سرور هم فشار برگشتی (backpressure) اعمال میکند. تا چند ثانیه جلوتر از زمان واقعی صوت را میپذیرد (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" تا وقتی جمله ادامه پیدا نکند، شبیه هم صدا میدهند. پس یک بازشناس بلادرنگ زود حدسی میزند و با رسیدن صوت بیشتر آن را اصلاح میکند، و فقط وقتی بهاندازهٔ کافی شنید ثابت میشود. هر API بازشناسی گفتار جریانی این را به نوعی نشان میدهد. بعضی حدسها را «interim» یا «partial» مینامند و نتیجههای ثابتشده را با 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 پایتون، با ترمینال بهعنوان نمای زیرنویس. تقسیم جمله اینجا هم خاموش است، پس ترجمهها بهصورت 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 تایپاسکریپت دقیقاً برای همین 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 میرسد که کلیپ را بهصورت base64 Ogg/Opus با 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"} بفرستید (در SDKها session.eof()). سرور آنچه را هنوز دارد رونویسی میکند، آخرین کامیتها را میفرستد، سپس 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 شروع میشود. اتصال مجدد الگوی استاندارد هر API سوکتی است و ارزش دارد یک بار درست انجامش دهید:
retryable: true در یک فریم error، یک 503 با Retry-After آن، یا 30 ثانیه بدون فریم که بهجای capacity_exceeded میایستد. invalid_token و session_expired یعنی یک streamUrl تازه از سرور شما، نه تلاش دوباره با URL قبلی.مسیر ساختن نشست را ارزان نگه دارید و بستن را بهعنوان شروعی تازه در نظر بگیرید. نداشتن بازیابی بهای ذخیرهنکردن چیزی است؛ اگر متن باید بیش از عمر سوکت دوام بیاورد، خودتان آن را نگه دارید.
وقتی میخواهید همان جریان بهعنوان یک ضبط ذخیره شود، بعداً اصلاح شود، خروجی گرفته شود و مخاطبان به زبان خودشان بخوانند، بهجایش آن را به یک رونویسی زنده منتشر کنید. راهنمای زنده شدن همان سوکت PCM را از یک رونویسی و یک لینک پخش نشان میدهد، و راهنمای گوش دادن سمت مشترک را نشان میدهد، جایی که هر شنونده زبانش را انتخاب میکند و یک شنوندهٔ دیرآمده با یک نشانگر lastSeq از همانجا که هست میگیرد. اینکه این برای یک اتاق چه شکلی است موضوع یک لینک، هر زبان است.
برای فایلی که میخواهید نگه دارید، مسیر ذخیرهشده رونویسی یک فایل با API است. مرجع کامل فریمها، با هر فیلد، راهنمای استریم کردن یک میکروفون در مرجع API است.