חזרה לבלוג

בניית סוכן קולי על Gemini Live, ואיך פתרנו את ה־barge-in

סוכנים קוליים מרגישים שבורים מיד אם הם ממשיכים לדבר אחרי שהמשתמש קוטע אותם. ה־API של Gemini Live לא נותן את זה חינם. הנה ה־desync בין שני שעונים שגרם לבעיה, מה ניסיתי, והתיקון של ~30 שורות.

voice-agents gemini-live barge-in audio web-audio react

בתקופה האחרונה אני משלבת סוכנים קוליים בכמה אפליקציות, גם בטלפון וגם בדפדפן.

הסוכנים שעל הטלפון עונים לשיחות במספר ייעודי. אלה שבדפדפן יושבים בתוך ה־UI של האפליקציה עצמה, מאחורי כפתור מיקרופון ליד תיבת הצ’אט.

שני משטחים שונים, אבל אותה בעיה קשה: הסוכן חייב להפסיק לדבר ברגע שהמשתמש מתחיל לדבר. אם זה לא קורה, כל החוויה מרגישה שבורה — בצורה שאי אפשר לתקן עם מודל יותר טוב או latency נמוך יותר. להוציא את זה כמו שצריך מול Gemini Live היה כל המשחק.

ניסיתי גם את ה־Realtime של OpenAI והוא ממש מרשים — ה־prosody מצוין וה־barge-in עובד מחוץ לקופסה. בפרויקט הזה נשארתי על Gemini בגלל עלויות והעדפה קיימת של הלקוח, מה שאומר שאת ה־barge-in הייתי צריכה לפתור בעצמי.

חוויית המשתמש עולה בצורה משמעותית כשאפשר לדבר עם האפליקציה. משתמשים סיפרו לי שזה גורם להם לחזור אליה יותר, וזו דרך אפקטיבית לצרוך מידע שבדרך כלל קוראים. זה ה־למה.

כל השאר זה ה־איך.

מה בנינו

מוד קולי בתוך אפליקציית web שמבוססת על מסמכים. המשתמש פותח מסמך, מקליק על המיקרופון ומתחיל לדבר. הסוכן מחליט מה לעשות — לגלול לסקשן רלוונטי, לענות על שאלה עובדתית מתוך המסמך, או לחפש משהו ברשת דרך הכלים הקיימים — ואז עונה בקול.

הארכיטקטורה היא שלושה שחקנים ושני חיבורים ארוכי טווח:

  • דפדפן ↔ בקאנד (HTTPS). ל־mint של ephemeral token, ול־proxy לסוכן טקסט כבד יותר כשצריך.
  • דפדפן ↔ Gemini Live (WebSocket). לזרם האודיו ולפרוטוקול של ה־tool calls.

ה־mint flow חשוב. ה־API key של גוגל אף פעם לא יוצא מהשרת. הדפדפן מבקש מהבקאנד טוקן קצר־מועד שמוגבל למודל, voice, modality וסט כלים ספציפי, ומתחבר ל־Gemini עם זה. גם אם הטוקן דולף — הוא חסר ערך מחוץ למסמך שבשבילו הוא הונפק.

כמה החלטות ששווה להדגיש כי קל לפשל בהן:

  • WebSocket ישיר, לא proxy דרך הבקאנד. רוב הבקאנדים מבוססי serverless הם request/response, לא streaming. proxy היה הורג את כל הפואנטה. ה־ephemeral tokens הופכים את החיבור הישיר לבטוח.

  • מיקרופון רציף עם server VAD, לא tap-to-talk. המוצר הוא “מדברים כדי לדבר,” כלומר שיחה — לא ווקי־טוקי. הדפדפן רק משדר; Gemini מחליט איפה גבולות ה־turn.

  • הקליינט מדבר ראשון. Gemini Live אף פעם לא פותח turn מיוזמתו — onopen רק אומר שה־socket למעלה. אנחנו שולחים פעם אחת "[greeting]" דרך sendClientContent, וה־system prompt הופך את זה להקדמה קצרה מדוברת. כבונוס זה גם מחמם את ה־playback pipeline, כך שב־turn האמיתי הראשון אין cold-start.

  • שני AudioContexts, לא אחד. 16kHz לקלט מהמיקרופון, 24kHz לפלט. WebAudio נועל את ה־sample rate לכל context, אז אי אפשר להסתפק באחד.

  • ScriptProcessorNode, לא AudioWorklet. Deprecated, אבל נתמך בכל הדפדפנים. שלושים שורות של המרת PCM inline מול קובץ worklet נפרד שצריך לפלמבר ל־SPA serving. כשכרום באמת יוריד אותו — נעבור.

החלק הזה שוחרר נקי. ה־barge-in לא.

הבאג של ה־barge-in

הסימפטום: הסוכן מדבר, המשתמש מנסה לקטוע אותו — והוא ממשיך לקרוא. עשר, לפעמים עשרים שניות.

כל שאר ההיבטים של המוד הקולי יכלו להיות מושלמים, וההתנהגות הזו לבדה הייתה גורמת לחוויה להרגיש שבורה.

הסיבה האמיתית הייתה desync בין שני שעונים.

הפונקציה הנאיבית של ה־playback קבעה כל chunk נכנס לניגון מיידי, אחד אחרי השני, על ה־AudioContext של ה־playback. נשמע תמים, אבל Gemini משדר תשובה של 25 שניות אודיו בבערך 3 שניות של burst.

כלומר תוך 3 שניות מתחילת התשובה, 25 שניות של אודיו כבר יושבות מתוזמנות בגרף של Web Audio. המשתמש שומע את זה, אבל מבחינת השרת — ה־turn הסתיים.

ומכאן הטיימינג מתפרק:

  • השרת פולט turnComplete בשנייה 3.
  • המשתמש מתחיל לדבר בשנייה 10, מעל אודיו שהוא עדיין שומע.
  • אבל ה־turn של השרת הסתיים לפני 7 שניות.

ה־docs של Gemini מנוסחים מפורשות: “When VAD detects an interruption, the ongoing generation is canceled.” הקאטץ’ זה המילים ongoing generation. אין כזה כרגע.

הדיבור של המשתמש מתפרש כתחילת turn חדש, לא כ־barge-in. אף interrupted event לא נופל. stopPlayback לא רץ. כל ה־audio שמתוזמן מתנגן עד הסוף של אותן 25 שניות, לא משנה כמה המשתמש צועק על האפליקציה.

הקוד שמחובר ל־interrupted: true היה נכון. ה־server VAD עבד. כל שכבה הייתה תקינה בפני עצמה. הבאג היה קיים רק ביחסים שבין תור ה־playback לבין שעון ה־turn של השרת — סוג של באג ש־type check או code review של קובץ בודד לעולם לא יתפסו.

מה ניסינו

שני דברים, לפי הסדר:

  • RMS gate ידני על קלט המיקרופון. ניסיתי פעמיים, החזרתי לאחור פעמיים. הוא הוריד דיבור אמיתי (מילים בעוצמה נמוכה, התחלות של משפט) ובאופן כללי לא היה אמין על פני מיקרופונים וחדרים שונים. לא שווה.

  • לתחום את עומק תור ה־playback. זה היה התיקון.

למה לא הלכנו על VAD “אמיתי” client-side כמו Silero: הבאג בכלל לא היה בצד המיקרופון, אלא בצד ה־playback. VAD שני על הקלט לא היה משנה את הסימפטום.

התיקון

לא לתזמן אודיו יותר מחצי שנייה קדימה לעומת realtime.

ספציפית:

  • chunks שמגיעים מה־WebSocket נופלים למערך JS פשוט (pendingChunksRef), במקום ללכת ישר ל־start() על ה־AudioContext.

  • scheduler קטן — נקרא לו pumpPlayback — מרוקן את התור just-in-time. הוא מתזמן את ה־chunk הבא רק אם ה־playback lead הנוכחי קטן מ־MAX_PLAYBACK_LEAD_SEC (0.5s), ואחרת עוצר.

  • הוא edge-triggered, לא polled. הוא נקרא שוב כשמגיע chunk חדש, ובכל פעם ש־onended נורה על AudioBufferSourceNode מתוזמן. התור מתחדש תוך כדי שהוא מתרוקן, בלי timer שמחזיק את הלולאה.

ההתנהגות אחרי השינוי הזה: ה־playback הנתפס נצמד לשעון ה־turn של השרת בתוך חצי שנייה.

כשהמשתמש מדבר מעל הבוט, ה־turn עדיין חי על השרת, ה־server VAD יורה interrupted: true, ה־hook עוצר את כל ה־sources המתוזמנים, מנקה את התור הממתין, מאפס את הסמן, והחדר נשתק.

בערך שלושים שורות קוד. אפס תלויות חדשות. ה־audio routing עצמו לא נגוע.

הסתייגות הוגנת: זה גורם ל־barge-in לעבוד, זה לא הופך אותו למיידי. עדיין משלמים round trip של מיקרופון → שרת → VAD → interrupted → קליינט, על גבי 0.5s של lead cap, כך שהעצירה לוקחת בערך 0.7 עד שנייה. למוצר שיחתי זה בסדר גמור.

לקחים ששווה לקחת הלאה

שני דברים מהבילד הזה ששווה לקחת לסוכן הקולי הבא.

כשמשהו מרגיש שבור בשכבת האינטגרציה, להתאפק מלהחליף transports. ה־transport כמעט אף פעם לא הבעיה. הבאג בדרך כלל חי ביחסים בין שני חתיכות state שנראות תקינות בפני עצמן — כאן, תור ה־playback ושעון ה־turn של השרת. לצייר את הטיימינג על דף, עם שני השעונים זה לצד זה, חושף את הבאג בערך בדקה.

ו: לבדוק פיצ’רים קוליים מאחורי בן אדם שמשתמש בהם כמו בן אדם, לא מאחורי unit tests. הבאג כולו היה בלתי נראה ל־type system, ל־agent loop, ל־deploy pipeline. הדבר היחיד שתופס “הבוט לא שותק כשאני מדבר עליו” זה לדבר על הבוט.

ריפו תבנית

הוצאתי את החלקים העובדים מהקוד לריפו React קטן ועצמאי, כדי שלא אצטרך לבנות את זה מחדש בפרויקט הבא — ושאתם תוכלו לדלג על העיקוף של ה־queue cap.

github.com/Nitzan94/gemini-live-voice-react

מה יש בפנים:

  • src/useGeminiLiveVoice.ts — ה־hook עצמו, בערך 400 שורות, אפס תלויות מעבר ל־@google/genai. ה־scheduler של ה־queue cap מהפוסט הזה נמצא בפנים.

  • example/ — דמו מלא עם שרת Bun, שמדגים את התבנית הנכונה ל־production: ה־API key נשאר בצד השרת, והדפדפן מנפיק ephemeral token דרך endpoint משלך.

  • playground/ — וריאנט סטטי שאפשר לדפלוי ל־Vercel. המשתמשים מדביקים מפתח משלהם ומנסים בדפדפן בלי install; המפתח חי רק בדפדפן ומנפיק טוקנים ישירות מול גוגל מעל CORS. שימושי לדמו מהיר או כדי לתת לעמית לנסות לפני שמחברים את צד השרת.

אם בניתם משהו על גביו, אשמח לשמוע על מה השתמשתם ואיפה זה נשבר.