Skip to content

Commit 820a910

Browse files
wuliang229copybara-github
authored andcommitted
feat(live): allow Voice activity detection (VAD) events to be returned
Co-authored-by: Liang Wu <wuliang@google.com> PiperOrigin-RevId: 944678774
1 parent 46532d7 commit 820a910

10 files changed

Lines changed: 168 additions & 10 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ class RunConfig(BaseModel):
242242
realtime_input_config: Optional[types.RealtimeInputConfig] = None
243243
"""Realtime input config for live agents with audio input from user."""
244244

245+
explicit_vad_signal: Optional[bool] = None
246+
"""Whether to enable explicit voice activity detection (VAD) signals from the model."""
247+
245248
translation_config: Optional[types.TranslationConfig] = None
246249
"""Configures real-time speech-to-speech translation.
247250

src/google/adk/cli/api_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,6 +1706,7 @@ async def run_agent_live(
17061706
enable_affective_dialog: bool | None = Query(default=None),
17071707
enable_session_resumption: bool | None = Query(default=None),
17081708
save_live_blob: bool = Query(default=False),
1709+
explicit_vad_signal: bool | None = Query(default=None),
17091710
) -> None:
17101711
resolved_app_name = app_name or self.default_app_name
17111712
if not resolved_app_name:
@@ -1762,6 +1763,7 @@ async def forward_events():
17621763
else None
17631764
),
17641765
save_live_blob=save_live_blob,
1766+
explicit_vad_signal=explicit_vad_signal,
17651767
)
17661768
async with Aclosing(
17671769
runner.run_live(

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,7 @@ async def _postprocess_live(
11711171
and not llm_response.usage_metadata
11721172
and not llm_response.live_session_resumption_update
11731173
and not llm_response.grounding_metadata
1174+
and not llm_response.voice_activity
11741175
):
11751176
return
11761177

@@ -1182,6 +1183,12 @@ async def _postprocess_live(
11821183
yield model_response_event
11831184
return
11841185

1186+
# Handle voice activity events
1187+
if llm_response.voice_activity:
1188+
model_response_event.voice_activity = llm_response.voice_activity
1189+
yield model_response_event
1190+
return
1191+
11851192
# Handle transcription events ONCE per llm_response, outside the event loop
11861193
if llm_response.input_transcription:
11871194
model_response_event.input_transcription = (

src/google/adk/flows/llm_flows/basic.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ def _build_basic_request(
113113
llm_request.live_connect_config.realtime_input_config = (
114114
invocation_context.run_config.realtime_input_config
115115
)
116+
llm_request.live_connect_config.explicit_vad_signal = (
117+
invocation_context.run_config.explicit_vad_signal
118+
)
116119
llm_request.live_connect_config.translation_config = (
117120
invocation_context.run_config.translation_config
118121
)

src/google/adk/models/gemini_llm_connection.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,13 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
602602
live_session_id=live_session_id,
603603
)
604604
)
605+
if message.voice_activity:
606+
logger.debug('Received voice activity: %s', message.voice_activity)
607+
yield LlmResponse(
608+
voice_activity=message.voice_activity,
609+
model_version=self._model_version,
610+
live_session_id=live_session_id,
611+
)
605612
if message.go_away:
606613
logger.debug('Received GoAway message: %s', message.go_away)
607614
yield LlmResponse(

src/google/adk/models/llm_response.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ class LlmResponse(BaseModel):
123123
go_away: Optional[types.LiveServerGoAway] = None
124124
"""The GoAway signal from the Live model."""
125125

126+
voice_activity: Optional[types.VoiceActivity] = None
127+
"""Voice activity signal from the Live model."""
128+
126129
input_transcription: Optional[types.Transcription] = None
127130
"""Audio transcription of user input."""
128131

tests/unittests/cli/test_adk_web_server_run_live.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ async def _get_runner_async(_self, _app_name: str):
106106
"&enable_affective_dialog=true"
107107
"&enable_session_resumption=true"
108108
"&save_live_blob=true"
109+
"&explicit_vad_signal=true"
109110
)
110111

111112
with client.websocket_connect(url) as ws:
@@ -120,23 +121,27 @@ async def _get_runner_async(_self, _app_name: str):
120121
assert run_config.session_resumption is not None
121122
assert run_config.session_resumption.transparent is True
122123
assert run_config.save_live_blob is True
124+
assert run_config.explicit_vad_signal is True
123125

124126

125127
@pytest.mark.parametrize(
126128
(
127129
"query,expected_enable_affective,expected_proactive_audio,"
128-
"expected_session_resumption_transparent,expected_save_live_blob"
130+
"expected_session_resumption_transparent,expected_save_live_blob,"
131+
"expected_explicit_vad_signal"
129132
),
130133
[
131-
("", None, None, None, False),
132-
("&proactive_audio=true", None, True, None, False),
133-
("&proactive_audio=false", None, False, None, False),
134-
("&enable_affective_dialog=true", True, None, None, False),
135-
("&enable_affective_dialog=false", False, None, None, False),
136-
("&enable_session_resumption=true", None, None, True, False),
137-
("&enable_session_resumption=false", None, None, False, False),
138-
("&save_live_blob=true", None, None, None, True),
139-
("&save_live_blob=false", None, None, None, False),
134+
("", None, None, None, False, None),
135+
("&proactive_audio=true", None, True, None, False, None),
136+
("&proactive_audio=false", None, False, None, False, None),
137+
("&enable_affective_dialog=true", True, None, None, False, None),
138+
("&enable_affective_dialog=false", False, None, None, False, None),
139+
("&enable_session_resumption=true", None, None, True, False, None),
140+
("&enable_session_resumption=false", None, None, False, False, None),
141+
("&save_live_blob=true", None, None, None, True, None),
142+
("&save_live_blob=false", None, None, None, False, None),
143+
("&explicit_vad_signal=true", None, None, None, False, True),
144+
("&explicit_vad_signal=false", None, None, None, False, False),
140145
],
141146
)
142147
def test_run_live_defaults_and_individual_options(
@@ -145,6 +150,7 @@ def test_run_live_defaults_and_individual_options(
145150
expected_proactive_audio: bool | None,
146151
expected_session_resumption_transparent: bool | None,
147152
expected_save_live_blob: bool,
153+
expected_explicit_vad_signal: bool | None,
148154
):
149155
session_service = InMemorySessionService()
150156
asyncio.run(
@@ -210,6 +216,7 @@ async def _get_runner_async(_self, _app_name: str):
210216
is expected_session_resumption_transparent
211217
)
212218
assert run_config.save_live_blob is expected_save_live_blob
219+
assert run_config.explicit_vad_signal is expected_explicit_vad_signal
213220

214221

215222
_WS_BASE_URL = (

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,3 +1826,34 @@ async def test_postprocess_live_skips_none_function_response_event():
18261826
]
18271827

18281828
assert all(event is not None for event in events)
1829+
1830+
1831+
@pytest.mark.asyncio
1832+
async def test_postprocess_live_voice_activity_events():
1833+
"""Test that _postprocess_live yields voice activity events."""
1834+
agent = Agent(name='test_agent', model='gemini-2.0-flash')
1835+
invocation_context = await testing_utils.create_invocation_context(
1836+
agent=agent
1837+
)
1838+
flow = BaseLlmFlowForTesting()
1839+
1840+
vad = types.VoiceActivity(
1841+
voice_activity_type=types.VoiceActivityType.ACTIVITY_START,
1842+
audio_offset='1.5s',
1843+
)
1844+
llm_response = LlmResponse(voice_activity=vad)
1845+
model_response_event = Event(
1846+
invocation_id=invocation_context.invocation_id,
1847+
author=agent.name,
1848+
)
1849+
llm_request = LlmRequest(model='gemini-2.0-flash')
1850+
1851+
events = [
1852+
event
1853+
async for event in flow._postprocess_live(
1854+
invocation_context, llm_request, llm_response, model_response_event
1855+
)
1856+
]
1857+
1858+
assert len(events) == 1
1859+
assert events[0].voice_activity == vad

0 commit comments

Comments
 (0)