Skip to content

fix(google, voice): speak Gemini Live turns that come back as text in audio mode - #7390

Open
u9g wants to merge 5 commits into
mainfrom
jason/gemini-text-only-turn
Open

u9g wants to merge 5 commits into
mainfrom
jason/gemini-text-only-turn

Conversation

@u9g

@u9g u9g commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Gemini Live in audio modality occasionally closes a turn with a text part and no audio. In one customer deployment (gemini-live-2.5-flash-native-audio on Vertex AI, livekit-agents 1.6.10) a caller's "Okay, iska menu" was answered with a 68 ms generation of 5 text tokens and 0 audio tokens; the agent then sat silent until the caller hung up.

Two things made that silence total:

  1. The Gemini plugin resolved every message's modalities future as ["audio", "text"] the moment a generation started, from the session config rather than from the content. The framework already handles a text-only realtime message (_process_one_message: TTS fallback when a TTS is configured, an error log otherwise), but it never saw one from Gemini.
  2. Since fix(google): keep unspoken model text out of the realtime transcript #6955 model-turn text is kept out of the transcript in audio sessions, because spoken words arrive through output transcription. When no audio ever comes, that text is the whole reply and was dropped.

Change

fix(google) — resolve the message modality from what the turn carries. Audio on the first inline frame; text when the turn completes without audio while holding model text, in which case the text is forwarded as the reply and a warning is logged (Gemini answered an audio-modality turn with text only; nothing was spoken). A tool call on the turn consumes the held text, since it was the call's rendering. Text-modality sessions resolve eagerly as before.

fix(voice) — when an audio-capable realtime model answers in text and no TTS voiced it, schedule one instructions-only reply asking for the same answer aloud, through the path tool replies already use, with tool_choice="none". A retry that is again text-only is not retried; an interruption or a tool call on the turn means no retry. With a TTS configured the existing fallback speaks the text and no retry happens.

Tests

  • tests/test_plugin_google_realtime.py: a text-only audio turn resolves as ["text"] and forwards its text; an audio turn stays unresolved until its first frame.
  • tests/test_realtime_text_only_reply.py: without TTS exactly one respeak generate_reply is issued and the retry's audio is spoken; a text-only retry is not retried; with a TTS the text is spoken and no retry is issued.
  • Realtime and session test files: 246 passed. ruff, mypy -p livekit.plugins.google, mypy -p livekit.agents.voice clean.

Not in this PR

  • agents-js parity. Its Gemini plugin has the same eager resolution (plugins/google/src/realtime/realtime_api.ts, Promise.resolve(modalities) in the generation start) and its agent activity the same fallback and error branch. Both changes need mirroring there.
  • No recording. The behaviour only triggers when Gemini itself chooses text for an audio turn, which cannot be provoked on demand, so the unit tests stand in for a demo. Whether the real model answers the respeak instruction with audio is what shipping this will show.

u9g added 2 commits September 21, 2026 17:19
Gemini Live occasionally answers an audio-modality turn with a text part
and no audio. The plugin resolved every message as audio up front from the
session config, so the agent forwarded an empty audio stream and the
framework's text-only fallback (TTS, or an error log) never engaged.

Resolve the modality from what arrives: audio on the first inline frame,
text when the turn completes without audio while carrying model text. That
text is then the reply and is forwarded; a tool call consumes it instead.
When an audio-modality realtime model closes a turn with text and no
audio, the framework falls back to TTS if one is configured and otherwise
only logs an error while the caller hears silence. Issue one instructions-
only reply asking for the same answer aloud, through the path tool replies
already use. A retry that is again text-only ends there; a TTS fallback or
a tool call on the turn means no retry.
@u9g
u9g requested a review from a team as a code owner September 21, 2026 21:39

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +4759 to +4778
respeak_expected = False
if (
unspoken_text_reply
and not tool_reply_expected
and not function_calls
and instructions != _RESPEAK_INSTRUCTIONS
):
# nothing voiced the text reply; ask the model once for the same reply aloud
respeak_expected = True
speech_handle._num_steps += 1
self._create_speech_task(
self._realtime_reply_task(
speech_handle=speech_handle,
model_settings=ModelSettings(tool_choice="none"),
instructions=_RESPEAK_INSTRUCTIONS,
),
speech_handle=speech_handle,
name="AgentActivity.realtime_respeak",
)
self._schedule_speech(speech_handle, SpeechHandle.SPEECH_PRIORITY_NORMAL, force=True)

@devin-ai-integration devin-ai-integration Bot Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Original answer lost after failed retry

When a text-only reply triggers a retry, respeak_expected omits its answer from history before the retry succeeds. If the retry fails or is interrupted, the answer disappears from local conversation history.

Learn more

The initial text-only reply is forwarded, but this branch excludes it from the local assistant history and speech items. The retry runs later as a separate task in _realtime_reply_task. That task can fail before returning a generation or stop after an interruption, leaving no replacement assistant message. Gemini also cannot remove the original from its server-side history, so the local and provider histories then disagree.

Example: Gemini returns "The menu is on the left" as text. The retry times out waiting for a generation. The session records neither that answer nor a spoken replacement, although the original reply was generated.

Recommended fix: Keep the original answer until the retry has produced a usable spoken response. If the retry fails, is interrupted before speaking, or returns no playable message, commit the original reply to local history and speech items; only discard it once a replacement is confirmed. Check how this interacts with Gemini's append-only server history.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1599 to +1600
# model-turn text on a tool turn is the call's rendering, never a spoken reply
gen.unspoken_text = ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Combined tool turns expose call rendering

When completion and a tool call share one server message, unspoken_text is cleared after the receive loop publishes it. Tool-call rendering then enters captions and assistant history as a reply.

Learn more

The receive loop handles server_content before tool_call from the same LiveServerMessage. A generation_complete or turn_complete flag closes the streams and converts held model text into reply text. _handle_tool_calls then clears the held value, but the text channel and modality future are already finalized.

Example: One server message carries model text call:getWeather{...}, generation_complete=True, and a getWeather tool call. The text is emitted and stored before this assignment runs, instead of remaining hidden as the tool call's rendering.

Recommended fix: Clear or classify held model text before processing completion whenever the same server message contains a tool call. Reorder the receive handling or pass tool-call presence into _handle_server_content so _close_output_streams cannot publish tool rendering.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1421 to +1422
if not current_gen.modalities.done():
current_gen.modalities.set_result(["audio", "text"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rejected audio suppresses text fallback

When an inline audio payload is invalid, modalities resolves to audio before frame validation discards it. Held text then skips text fallback, leaving the turn silent.

Learn more

The modality future is the definitive signal consumed by the voice layer. These lines mark the turn as audio before checking that part.inline_data.data is bytes and before constructing or sending an AudioFrame. The following ValueError handler keeps the session alive, but stream closure sees a completed modality future and cannot recover unspoken_text.

Example: Gemini sends held text Okay. plus an odd or non-byte inline payload that AudioFrame rejects. The error is logged and no frame reaches audio_ch, yet the message still reports audio and Okay. remains suppressed.

Recommended fix: Resolve the audio modality only after a valid frame is successfully created and queued. Track successful audio delivery rather than receipt of an inline-data part.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

u9g added 3 commits October 6, 2026 11:11
…follows

The error fired as soon as a text-only message arrived in an audio
session, before the respeak decision, so a turn the model then spoke
still logged an error blaming chat context sync.
The emulation (rejecting tool calls emitted during the turn) is the
supported behavior, and each rejection already logs when one happens.
The framework sets tool_choice='none' routinely (final reply after max
tool steps, realtime respeak), so the warning fired on normal turns.
…respeaking

The text-only message and the respeak that voices it were both recorded
as assistant messages, so the history held the same reply twice. Decide
the respeak before recording the generation's messages and leave the
unspoken text out when one follows; with no respeak it is still recorded.
@u9g

u9g commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Before/after audio of one call with gemini-live-2.5-flash-native-audio on Vertex AI, driven by lk agent debugger start --audio (livekit-cli#1000). The caller asks "What is two plus two? Answer in one short sentence." To reproduce the production failure on demand, the agent strips the audio from Gemini's first reply and replaces it with a text part ("Two plus two is four."), so the turn arrives text-only as it did for the customer. Both takes use the same harness; only the agents code differs.

Before (base c0a8f44): the text-only turn is resolved as audio, its text is dropped, and the caller hears nothing (0.0 s of agent audio; the debugger reports (no reply)).

gemini_text_only_before.mp4

After (this PR, f0857c3): the turn resolves as ["text"], the agent asks Gemini once to say the reply aloud, and the caller hears "2 + 2 is 4." about 1.5 s after they stop speaking.

gemini_text_only_after.mp4

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant