תמליל בזמן אמת מגיע פעמיים: קודם כטיוטה שממשיכה להשתנות, ואז כשורה סופית שלא משתנה. הציגו את הטיוטה כזנב שניתן להחליף ואת השורה הסופית כהיסטוריה, וכל השאר בתצוגת כתוביות חיה הוא אינסטלציה.
סשן בזמן אמת הוא הדבר הקטן ביותר ש-VocalStack API עושה. השרת שלכם מבצע קריאת HTTP אחת ומקבל בחזרה streamUrl חתום. הלקוח פותח WebSocket לכתובת הזו, שולח שמע גולמי ככל שהוא נלכד, וקורא את התמליל בחזרה כמסגרות טקסט של JSON, כמה רגעים אחרי הדובר. כשהשמע נגמר שולחים eof, השרת מבצע flush, מדווח על מה שהסשן מדד, ונסגר.
לא נשמר דבר. התמליל קיים רק על הרשת, וזה בדיוק מה שרוצה תצוגת כתוביות, שכבת-על בשיחת וידאו או פיצ'ר קולי באפליקציה שלכם. אם אתם רוצים לשמור את המילים, לשתף אותן ולייצא אותן אחר כך, כדאי לכם תמלול בשידור חי ושידור במקום; הסעיף האחרון מפנה לשם.
המבנה הוא זה שרוב ממשקי הדיבור לטקסט בזרימה חולקים: החלפת אישורים דרך HTTP, סוקט ארוך שבו שמע נכנס ו-JSON יוצא, ודרך כלשהי להבחין בין טקסט זמני לטקסט סופי. השמות משתנים מממשק לממשק; הרכיבים הנעים כמעט אף פעם לא. הנה, לפי הסדר:
POST /v1/realtime/sessions עם המפתח שלכם, בשרת שלכם. התשובה היא { sessionId, streamUrl, expiresAt }.streamUrl.session.loading עד session.ready.transcript.draft (החלפת הזנב) ו-transcript.committed (הוספת שורה סופית).{"type":"eof"}; קריאת session.done.רק שלב 1 צריך את המפתח שלכם, ולכן הוא מתרחש בשרת שלכם.
צרו מפתח תחת Developers, API keys עם ההרשאה Write. מפתח מיועד לשרתים ולסקריפטים: ה-API דוחה מפתח שנשלח מדף אינטרנט או בכתובת URL, ולכן יוצרים את הסשן מאחורי נתיב משלכם, והדף רואה רק את streamUrl.
ה-URL נושא אסימון משלו בגלל מגבלה של הדפדפן ששווה לדעת באופן כללי. הבנאי WebSocket בדפדפן לא יכול להגדיר כותרות בקשה, כך שאין כותרת Authorization שאפשר לשים בה מפתח; כל ממשק דיבור שפונה לדפדפן מאמת את הסוקט שלו באמצעות אסימון קצר-מועד בכתובת ה-URL (או מוחדר לשם של subprotocol). קצר-מועד הוא העניין: כתובות URL מגיעות ליומנים ולהיסטוריה, ולכן מה שהן נושאות חייב לפוג. התחברו לפני expiresAt.
ה-SDKs מורדים מאזור המפתחים ולא ממאגר חבילות; הסעיף Install במדריך ההתחלה המהירה מראה את הפקודות של הסביבה הזו. עם ה-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;
}
});
לקוח גולמי צריך לטפל בעוד שני דברים בעצמו; ה-SDKs כבר עושים זאת. ראשית, שדרוג שנדחה מגיע עם מעטפת השגיאה הרגילה של ה-API ב-HTTP: 401 invalid_token, 410 session_expired, או 503 unavailable עם כותרת Retry-After שיש לכבד.
שנית, עומס נראה כמו שתיקה, לא כשגיאה. אם אף מסגרת לא מגיעה תוך 30 שניות, התייחסו לזה כ-capacity_exceeded והתחברו שוב; סשן חדש בדרך כלל עובר. אם פעימות ה-loading ממשיכות להגיע אבל session.ready לא מגיע לעולם, התייחסו לזה כ-engine_unavailable, שגם הוא ניתן לניסיון חוזר. ב-SDKs, RealtimeSession.connect מסתיים ב-session.ready ונכשל לאחר readyDeadlineMs עם הקוד הנכון.
ברגע שמגיע session.ready, השרת מצפה לשמע בקצב שבו מדברים.
השמע נשלח כמסגרות בינאריות של PCM גולמי: 32-bit float, little-endian, 16 kHz, מונו (pcm_f32le, פורמט ברירת המחדל של השמע בסשן). שלחו אותו ככל שהוא נלכד, במקטעים של כ-100 עד 250 ms, בלי לחכות לתמליל בין מקטע למקטע.
שווה לעשות את חישוב הבייטים פעם אחת. 16,000 דגימות בשנייה, בנות 4 בייטים כל אחת, הן 64,000 בייטים בשנייה, ולכן 100 ms הם 6,400 בייטים ו-250 ms הם 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 הוא גודל התור הזה, ובקשר בריא הוא נשאר קרוב לאפס. שמע חי לא ניתן להשהיה, ולכן התגובה השימושית לתור שגדל היא להבחין בכך, לא להפיל מסגרות. גם השרת מפעיל 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) היא כל תבנית ה-UI; הסעיף הבא מסביר למה.
שני סוגי מסגרות בונים את התמליל, וערבוב ביניהם הוא הטעות הנפוצה ביותר.
ראשית, למה יש שניים בכלל. מודל דיבור מחליט מה הייתה מילה בחלקה לפי המילים שבאות אחריה: "I scream" ו-"ice cream" נשמעים אותו דבר עד שהמשפט ממשיך. לכן מזהה דיבור בזמן אמת מפיק ניחוש מוקדם, ואז מתקן אותו ככל שמגיע עוד שמע, ורק מתייצב כשהוא שמע מספיק. כל ממשק דיבור בזרימה חושף זאת בדרך כלשהי. יש שקוראים לניחושים תוצאות "interim" או "partial" ומסמנים את היציבות כ-is_final; האוצר המילים משתנה, המשמעת לא. כאן הניחוש הוא טיוטה, והטקסט היציב הוא קומיט.
transcript.draft הוא הזנב החי: המילים שנשמעו מאז הקומיט האחרון, והן עדיין משתנות. כל טיוטה מחליפה לחלוטין את הקודמת. ציירו מחדש את הזנב עם ה-segments שלו; לעולם אל תוסיפו אותו. לטיוטות אין seq בכוונה, ולכן אל תנסו להתאים אותן לקומיטים שהן נהפכות אליהם.
transcript.committed הוא סופי: סגמנט אחד עם 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"}
ה-SDKs חושפים זאת כ-session.update(…). translate מחליף את כל הרשימה, והערך [] מכבה את התרגום. סגמנטים שכבר נמסרו לא מתומללים או מתורגמים מחדש, ואם תפעילו תוויות דוברים, הדוברים יסומנו מאותה נקודה בלבד. מה שנאמר נשאר נאמר; ההגדרות קובעות רק מה שיבוא אחר כך.
כשהשמע נגמר, שלחו {"type":"eof"} (session.eof() ב-SDKs). השרת מתמלל את מה שעדיין בידיו, שולח את הקומיטים האחרונים, ואז session.done, ונסגר. שליחת cancel במקום זאת עוצרת מיד ומשליכה את השאר.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
ה-reason הוא eof, max_duration (הסשן הגיע לאורך המרבי של המסלול, ועבר את אותו flush) או cancelled. ה-stats נמדדים על ידי השרת: audioS הוא השמע ששלחתם, processingS הוא זמן הקיר שבו הסשן רץ, ו-realtimeFactor הוא היחס ביניהם. סשן חי מגיע קצת מתחת ל-1, כי זמן הקיר כולל את ה-flush הסופי שאחרי ה-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.