A voice assistant that reads a shared screen needs correctly encoded audio and image frames. ADK forwards those bytes to the Live API without converting them. You will label PCM audio with its actual sample rate, send JPEG frames, select a voice and explicitly mark push-to-talk turns.
In the saved screen-frame run on gemini-2.5-flash-native-audio-preview-12-2025, the caller shares three terminal frames and the agent reads the failed test from the third. The separate proactivity run uses gemini-3.1-flash-live-preview and records whether that setting reaches the connection. The failure examples show garbled speech, silence, a connection error and a silently dropped setting.
When you need this. A developer can share a terminal screen and ask the docs assistant which test failed. That client needs correctly labelled frames and audio, plus a clear choice of whether the client or server ends the input turn.
1. server messages
3. Event.content
gemini-2.5-flash-native-audio-preview-12-2025.Download the complete lab ZIP, extract it and open a terminal in adk-live-media/. Create the environment below and set GOOGLE_API_KEY in that terminal before running a live command. The three frames are 768 by 768 JPEG files rendered locally from a terminal mock-up (frame-3.jpg shows 6 passed, 1 failed with FAILED test_resume_after_drop); the WAV files are synthesized questions, one of them recorded at 8 kHz on purpose.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. Choose the media and the voice
"""Send audio and JPEG frames to a live agent, choose its voice, and take turns without server VAD."""
import asyncio
from google.adk.agents import Agent
from google.adk.agents.run_config import RunConfig
from google.adk.models import Gemini
from google.genai import types
from live_harness import MODEL, PACE, RATE_IN
INSTRUCTION = ("You are the docs assistant on a call. The caller may share their screen as images. "
"Answer in one short spoken sentence, and quote names exactly as they appear on screen.")
def voice(name: str, language: str = "en-US") -> types.SpeechConfig:
return types.SpeechConfig(language_code=language, voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=name)))
def build_agent(model=MODEL, agent_voice: str | None = None) -> Agent:
"""A voice set on the Gemini model object wins over RunConfig.speech_config."""
if agent_voice:
model = Gemini(model=model) if isinstance(model, str) else model
model.speech_config = voice(agent_voice)
return Agent(name="docs_helper", model=model, instruction=INSTRUCTION)
def session_config(voice_name="Kore", push_to_talk=False) -> RunConfig:
vad = types.RealtimeInputConfig(automatic_activity_detection=types.AutomaticActivityDetection(disabled=True))
return RunConfig(response_modalities=["AUDIO"], speech_config=voice(voice_name),
realtime_input_config=vad if push_to_talk else None)
def share_screen(frames: list[bytes], fps: float = 1.0):
"""A video stream is a sequence of JPEG stills; about one per second is the recommended maximum."""
async def send(queue):
for frame in frames:
queue.send_realtime(types.Blob(mime_type="image/jpeg", data=frame))
await asyncio.sleep(PACE / fps)
return send
def push_to_talk(pcm: bytes, rate: int = RATE_IN):
"""With server VAD off, the client marks the turn: activity start, the audio, activity end."""
step = rate * 2 // 10
async def send(queue):
queue.send_activity_start()
for start in range(0, len(pcm), step):
queue.send_realtime(types.Blob(mime_type=f"audio/pcm;rate={rate}", data=pcm[start:start + step]))
await asyncio.sleep(PACE * 0.1)
queue.send_activity_end()
return send
def then(*senders):
"""Run several senders in order as one turn, for example frames and then a question."""
async def send(queue):
for sender in senders:
await sender(queue)
return sendOne send method for all media. Audio, images and video frames all go through send_realtime as a types.Blob. This lab sends 16-bit little-endian mono PCM at 16 kHz, labelled audio/pcm;rate=16000. The Live API can accept other input sample rates and resample them on the server; the MIME rate must describe the actual samples. Video is a sequence of JPEG stills with no codec; about one frame per second at 768 by 768 is the recommended maximum. One frame per second shows what is on screen but nothing that depends on motion.
Two places for a voice. RunConfig.speech_config sets the voice for the whole session. A speech_config on the Gemini model object belongs to one agent and wins, which lets each agent in a workflow sound different. The recorded connection configs show it: the session asked for Kore, the agent’s model set Puck, and the connection used Puck.
Who ends the turn. With automatic voice activity detection, the server decides when the caller stopped talking. session_config(push_to_talk=True) disables it, so the client must mark each turn: send_activity_start(), the audio, send_activity_end(). Push-to-talk suits noisy rooms and clients that run their own detection. The cost is that the model can no longer tell on its own that the caller started speaking.
Unconverted 8 kHz audio. Sent with the 16 kHz label, the speech arrives garbled: Which the micro micro micro micro audio?. The agent answered The microphone sample rate is "44.1 kHz".. Resample to the lab’s 16 kHz before sending, or use the actual supported input rate in the MIME label. Changing the label alone does not resample the bytes. Check: test_unconverted_8khz_audio_garbles_the_transcript.
VAD off without activity signals. With automatic detection disabled and no send_activity_start/send_activity_end, the saved run timed out before a turn completed: TimeoutError: no turn_complete within 25 s. Send explicit activity-start and activity-end signals when disabling automatic detection. Check: test_with_vad_off_and_no_signals_no_turn_completes.
Unknown voice name. voice_name="Narrator" fails at connect: APIError: 1007 None. Requested voice api_name 'Narrator' is not available for model models/gemini-2.5-flash-native-audio-preview-12-2025. Use a prebuilt voice the model lists. Check: test_an_unknown_voice_fails_at_connect.
Proactivity on a Gemini 3 Live model. No error and no effect: ADK removes proactivity from the connect config for Gemini 3 Live models, as the recorded 3.1 connection shows. Check the connect config, not the absence of an error. Check: test_adk_drops_proactivity_for_gemini_3_live_models.
2. Record the runs
python record.pyThe screen run sent the three frames, one per second, then the spoken question. The agent heard Which test failed on my screen? and answered The test "test_resume_after_drop" failed., which names the test on the third frame. The check on the transcript passed.
The push-to-talk turn shows both sides of a live agent. The turn itself worked: the model heard Which sample rate should the microphone audio use? between the two activity signals. The answer, The microphone audio should use `44100.0`., is wrong. This agent has no docs page in its instruction, and its check for 16 failed. A working media path says nothing about whether the answer is right.
Here is what went over the wire for the screen run, as ADK sent it:
[
{
"sent": "realtime",
"mime_type": "image/jpeg",
"bytes": 76220,
"chunks": 3
},
{
"sent": "realtime",
"mime_type": "audio/pcm;rate=16000",
"bytes": 60800,
"chunks": 19
},
{
"sent": "realtime",
"mime_type": "audio/pcm;rate=16000",
"bytes": 6400,
"chunks": 2
},
{
"sent": "realtime",
"mime_type": "audio/pcm;rate=16000",
"bytes": 3200,
"chunks": 1
},
{
"sent": "realtime",
"mime_type": "audio/pcm;rate=16000",
"bytes": 3200,
"chunks": 1
},
{
"sent": "realtime",
"mime_type": "audio/pcm;rate=16000",
"bytes": 20140,
"chunks": 7
},
{
"sent": "close"
}
]Read record.py
"""Record: screen frames with a spoken question, push-to-talk, voice precedence and four failures."""
import asyncio
import json
import wave
from pathlib import Path
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
import live_harness
from live_harness import RecordingGemini, environment, MODEL, read_pcm, say_audio, say_text, spoken, talk
from media import build_agent, push_to_talk, session_config, share_screen, then
HERE = Path(__file__).parent
FRAMES = [(HERE / f"frame-{n}.jpg").read_bytes() for n in (1, 2, 3)]
async def run(name, agent_model, turn, run_config, agent_voice=None, timeout=60, check=None):
model = RecordingGemini(model=agent_model, connections=[])
runner = Runner(app_name="voice_docs", agent=build_agent(model, agent_voice), session_service=InMemorySessionService())
session = await runner.session_service.create_session(app_name="voice_docs", user_id="dev")
entry = {"name": name}
try:
rows = await talk(runner, session.id, run_config, [turn], timeout=timeout)
entry.update(heard=spoken(rows, "input_transcription"), answer=spoken(rows), events=len(rows))
if check:
entry["check"] = {"expects": check, "passed": check in entry["answer"]}
except asyncio.TimeoutError:
entry["error"] = f"TimeoutError: no turn_complete within {timeout} s"
except Exception as error:
entry["error"] = f"{type(error).__name__}: {str(error).splitlines()[0][:240]}"
entry["connections"] = [{"model": c["model"], "config": c.get("config"), "traffic": c["traffic"]} for c in model.connections]
print(f"[{name}] " + (entry.get("error") or f"heard={entry['heard']!r} said={entry['answer']!r}"))
return entry
async def main():
with wave.open(str(HERE / "slow.wav")) as clip: # an 8 kHz microphone, sent unconverted
slow = clip.readframes(clip.getnframes())
runs = [
await run("screen frames, then a spoken question", MODEL,
then(share_screen(FRAMES), say_audio(read_pcm(HERE / "which-test.wav"))),
session_config(), check="test_resume_after_drop"),
await run("push-to-talk turn", MODEL, push_to_talk(read_pcm(HERE / "question.wav")),
session_config(push_to_talk=True), check="16"),
await run("agent voice over session voice", MODEL, say_text("Say hello."), session_config("Kore"), agent_voice="Puck"),
await run("8 kHz audio labelled 16 kHz", MODEL, say_audio(slow), session_config(), check="16"),
await run("VAD off, no activity signals", MODEL, say_audio(read_pcm(HERE / "question.wav")),
session_config(push_to_talk=True), timeout=25),
await run("unknown voice name", MODEL, say_text("Say hello."), session_config("Narrator")),
]
from google.adk.agents.run_config import RunConfig
runs.append(await run("current Live model greeting", MODEL, say_text("Say hello."),
RunConfig(response_modalities=["AUDIO"])))
record = {"environment": environment(), "runs": runs}
(HERE / "media-run.json").write_text(json.dumps(record, indent=1) + "\n")
if __name__ == "__main__":
asyncio.run(main())Ungrounded answer to a clean turn. The push-to-talk turn was heard correctly and answered The microphone audio should use `44100.0`.. The check on the transcript failed; provide the source page and check the spoken answer against it. Check: test_ungrounded_answers_fail_the_check.
3. Test it without the network
Inspect the saved session output
[screen frames, then a spoken question] heard='Which test failed on my screen?' said='The test "test_resume_after_drop" failed.'
[push-to-talk turn] heard='Which sample rate should the microphone audio use?' said='The microphone audio should use `44100.0`.'
[agent voice over session voice] heard='' said='Hello, how can I help?'
[8 kHz audio labelled 16 kHz] heard='Which the micro micro micro micro audio?' said='The microphone sample rate is "44.1 kHz".'
[VAD off, no activity signals] TimeoutError: no turn_complete within 25 s
[unknown voice name] APIError: 1007 None. Requested voice api_name 'Narrator' is not available for model models/gemini-2.5-flash-native-audio-preview-12-2025
[proactivity on Gemini 3.1 Flash Live] heard='' said="Hello, I'm here to help with your documents."python -m unittest -v test_media.pytest_adk_drops_proactivity_for_gemini_3_live_models (test_media.LiveMedia.test_adk_drops_proactivity_for_gemini_3_live_models) ... ok
test_an_unknown_voice_fails_at_connect (test_media.LiveMedia.test_an_unknown_voice_fails_at_connect) ... ok
test_frames_go_before_the_question_as_jpeg_blobs (test_media.LiveMedia.test_frames_go_before_the_question_as_jpeg_blobs) ... ok
test_push_to_talk_brackets_the_audio_with_activity_signals (test_media.LiveMedia.test_push_to_talk_brackets_the_audio_with_activity_signals) ... ok
test_the_agent_voice_wins_over_the_session_voice (test_media.LiveMedia.test_the_agent_voice_wins_over_the_session_voice) ... ok
test_unconverted_8khz_audio_garbles_the_transcript (test_media.LiveMedia.test_unconverted_8khz_audio_garbles_the_transcript) ... ok
test_ungrounded_answers_fail_the_check (test_media.LiveMedia.test_ungrounded_answers_fail_the_check) ... ok
test_with_vad_off_and_no_signals_no_turn_completes (test_media.LiveMedia.test_with_vad_off_and_no_signals_no_turn_completes) ... ok
----------------------------------------------------------------------
Ran 8 tests in 10.076s
OKAll 8 tests pass. The replayed runs check the order of what ADK sends: three image/jpeg blobs before any audio, and ActivityStart and ActivityEnd around the push-to-talk audio. test_adk_drops_proactivity_for_gemini_3_live_models builds the live request offline for two model ids and reads the connect config ADK produces.
Read the test source
"""Replay the recorded media runs through ADK's run_live loop; no network, no API key."""
import asyncio
import copy
import json
import logging
import unittest
from pathlib import Path
from google.adk.agents.run_config import RunConfig
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
import live_harness
from live_harness import ReplayLive, read_pcm, say_audio, say_text, spoken, talk
from media import build_agent, push_to_talk, session_config, share_screen, then
HERE = Path(__file__).parent
RECORD = json.loads((HERE / "media-run.json").read_text())
RUNS = {run["name"]: run for run in RECORD["runs"]}
FRAMES = [(HERE / f"frame-{n}.jpg").read_bytes() for n in (1, 2, 3)]
live_harness.PACE = 0
logging.disable(logging.CRITICAL)
def replay(name, model_id="replay"):
connections = copy.deepcopy(RUNS[name]["connections"]) if name else [{"traffic": []}]
return ReplayLive(model=model_id, connections=connections, opened=[])
def converse(model, turn, run_config, timeout=10):
async def run():
runner = Runner(app_name="voice_docs", agent=build_agent(model), session_service=InMemorySessionService())
session = await runner.session_service.create_session(app_name="voice_docs", user_id="dev")
return await talk(runner, session.id, run_config, [turn], timeout=timeout)
return asyncio.run(run())
def sent_kinds(model):
return [mime for _, connection in model.opened for kind, mime in connection.received if kind == "realtime"]
class LiveMedia(unittest.TestCase):
def test_frames_go_before_the_question_as_jpeg_blobs(self):
model = replay("screen frames, then a spoken question")
rows = converse(model, then(share_screen(FRAMES), say_audio(read_pcm(HERE / "which-test.wav"))), session_config())
self.assertEqual(spoken(rows), RUNS["screen frames, then a spoken question"]["answer"])
kinds = sent_kinds(model)
self.assertEqual(kinds[:3], ["image/jpeg"] * 3)
self.assertEqual(set(kinds[3:]), {"audio/pcm;rate=16000"})
def test_push_to_talk_brackets_the_audio_with_activity_signals(self):
model = replay("push-to-talk turn")
converse(model, push_to_talk(read_pcm(HERE / "question.wav")), session_config(push_to_talk=True))
kinds = sent_kinds(model)
self.assertEqual((kinds[0], kinds[-1]), ("ActivityStart", "ActivityEnd"))
def test_the_agent_voice_wins_over_the_session_voice(self):
config = RUNS["agent voice over session voice"]["connections"][0]["config"]
self.assertEqual(config["speech_config"]["voice_config"]["prebuilt_voice_config"]["voice_name"], "Puck")
def test_unconverted_8khz_audio_garbles_the_transcript(self):
run = RUNS["8 kHz audio labelled 16 kHz"]
self.assertNotEqual(run["heard"], RUNS["push-to-talk turn"]["heard"])
self.assertFalse(run["check"]["passed"])
def test_with_vad_off_and_no_signals_no_turn_completes(self):
with self.assertRaises(asyncio.TimeoutError):
converse(replay("VAD off, no activity signals"), say_audio(read_pcm(HERE / "question.wav")),
session_config(push_to_talk=True), timeout=2)
self.assertIn("TimeoutError", RUNS["VAD off, no activity signals"]["error"])
def test_an_unknown_voice_fails_at_connect(self):
self.assertIn("'Narrator' is not available", RUNS["unknown voice name"]["error"])
def test_adk_drops_proactivity_for_gemini_3_live_models(self):
config = RunConfig(response_modalities=["AUDIO"], proactivity=types.ProactivityConfig(proactive_audio=True))
for model_id, kept in (("gemini-3.1-flash-live-preview", False), ("gemini-2.5-flash-native-audio-preview-12-2025", True)):
model = replay(None, model_id)
with self.assertRaises(asyncio.TimeoutError):
converse(model, say_text("Say hello."), config, timeout=1)
self.assertEqual(model.opened[0][0].live_connect_config.proactivity is not None, kept, model_id)
self.assertNotIn("proactivity", RUNS["proactivity on Gemini 3.1 Flash Live"]["connections"][0]["config"])
def test_ungrounded_answers_fail_the_check(self):
self.assertFalse(RUNS["push-to-talk turn"]["check"]["passed"])
self.assertTrue(RUNS["screen frames, then a spoken question"]["check"]["passed"])
if __name__ == "__main__":
unittest.main()How it works
The send task in run_live forwards each LiveRequest from the queue to the connection unchanged. A blob becomes realtime input; send_activity_start and send_activity_end become ActivityStart and ActivityEnd messages. Nothing in ADK checks that the bytes match their MIME label. The lab sends 8 kHz samples with a 16 kHz label, misrepresenting the audio duration: the recorded input transcript was Which the micro micro micro micro audio? and the agent answered a question nobody asked.
The voice and the turn settings are fixed when the connection opens. ADK copies speech_config, realtime_input_config and the transcription settings from RunConfig into the Live connect config, then Gemini.connect replaces speech_config with the model’s own when it has one. For Gemini 3 Live models, ADK 2.12 sets proactivity and enable_affective_dialog to None before it connects. adk.dev says leaving them set is the most common failure when moving from 2.5 to 3.1; with this package, the setting disappears without an error instead.
Output audio comes back at 24 kHz, a different rate from this lab’s 16 kHz input. A client that plays it at 16 kHz sounds slow and low.
What’s next
- Call tools during a live conversation: let the agent look up the page itself, run a slow job without going silent, and stream progress from a tool.
Check your understanding
Read the explanation
With automatic detection disabled, push_to_talk sends both activity boundaries around the audio frames.
References
Official sources checked October 22, 2026, against the installed ADK package.