Skip to content

Commit bc86a99

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add save_live_blob and audio caching/artifact persistence to GenAI SDK
PiperOrigin-RevId: 971326432
1 parent 2cc99a9 commit bc86a99

7 files changed

Lines changed: 1462 additions & 2 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
import time
19+
from typing import Any, Optional
20+
import uuid
21+
22+
from pydantic import Field
23+
24+
from . import _common
25+
from . import types
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def _require_audio_data(blob: Optional[types.Blob]) -> bytes:
31+
if blob is None:
32+
raise ValueError('Audio blob cannot be None.')
33+
data = blob.data
34+
if not isinstance(data, bytes):
35+
raise ValueError('Audio blobs must contain byte data.')
36+
return data
37+
38+
39+
def require_agent_name(context: Any) -> str:
40+
if hasattr(context, 'agent_name') and context.agent_name:
41+
return str(context.agent_name)
42+
if (
43+
hasattr(context, 'agent')
44+
and hasattr(context.agent, 'name')
45+
and context.agent.name
46+
):
47+
return str(context.agent.name)
48+
return 'agent'
49+
50+
51+
class Event(_common.BaseModel):
52+
"""Representation of an event occurring in a session."""
53+
54+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
55+
invocation_id: Optional[str] = None
56+
author: str = 'user'
57+
content: Optional[types.Content] = None
58+
timestamp: float = Field(default_factory=time.time)
59+
60+
61+
class AudioCacheManager:
62+
"""Manages audio caching and flushing for live streaming flows."""
63+
64+
def __init__(self, config: Optional[types.AudioCacheConfig] = None) -> None:
65+
"""Initialize the audio cache manager.
66+
67+
Args:
68+
config: Configuration for audio caching behavior.
69+
"""
70+
self.config = config or types.AudioCacheConfig()
71+
72+
def cache_audio(
73+
self,
74+
invocation_context: Any,
75+
audio_blob: types.Blob,
76+
cache_type: str,
77+
) -> None:
78+
"""Cache incoming user or outgoing model audio data.
79+
80+
Args:
81+
invocation_context: The current invocation context.
82+
audio_blob: The audio data to cache.
83+
cache_type: Type of audio to cache, either 'input' or 'output'.
84+
85+
Raises:
86+
ValueError: If cache_type is not 'input' or 'output'.
87+
"""
88+
audio_data = _require_audio_data(audio_blob)
89+
if cache_type == 'input':
90+
if getattr(invocation_context, 'input_realtime_cache', None) is None:
91+
invocation_context.input_realtime_cache = []
92+
cache = invocation_context.input_realtime_cache
93+
role = 'user'
94+
elif cache_type == 'output':
95+
if getattr(invocation_context, 'output_realtime_cache', None) is None:
96+
invocation_context.output_realtime_cache = []
97+
cache = invocation_context.output_realtime_cache
98+
role = 'model'
99+
else:
100+
raise ValueError("cache_type must be either 'input' or 'output'")
101+
102+
audio_entry = types.RealtimeCacheEntry(
103+
role=role, data=audio_blob, timestamp=time.time()
104+
)
105+
cache.append(audio_entry)
106+
107+
logger.debug(
108+
'Cached %s audio chunk: %d bytes, cache size: %d',
109+
cache_type,
110+
len(audio_data),
111+
len(cache),
112+
)
113+
114+
async def flush_caches(
115+
self,
116+
invocation_context: Any,
117+
flush_user_audio: bool = True,
118+
flush_model_audio: bool = True,
119+
) -> list[Event]:
120+
"""Flush audio caches to artifact services.
121+
122+
Args:
123+
invocation_context: The invocation context containing audio caches.
124+
flush_user_audio: Whether to flush the input (user) audio cache.
125+
flush_model_audio: Whether to flush the output (model) audio cache.
126+
127+
Returns:
128+
A list of Event objects created from the flushed caches.
129+
"""
130+
flushed_events: list[Event] = []
131+
if flush_user_audio and getattr(
132+
invocation_context, 'input_realtime_cache', None
133+
):
134+
audio_event = await self._flush_cache_to_services(
135+
invocation_context,
136+
invocation_context.input_realtime_cache,
137+
'input_audio',
138+
)
139+
if audio_event:
140+
flushed_events.append(audio_event)
141+
invocation_context.input_realtime_cache = []
142+
143+
if flush_model_audio and getattr(
144+
invocation_context, 'output_realtime_cache', None
145+
):
146+
logger.debug('Flushed output audio cache')
147+
audio_event = await self._flush_cache_to_services(
148+
invocation_context,
149+
invocation_context.output_realtime_cache,
150+
'output_audio',
151+
)
152+
if audio_event:
153+
flushed_events.append(audio_event)
154+
invocation_context.output_realtime_cache = []
155+
156+
return flushed_events
157+
158+
async def _flush_cache_to_services(
159+
self,
160+
invocation_context: Any,
161+
audio_cache: list[types.RealtimeCacheEntry],
162+
cache_type: str,
163+
) -> Optional[Event]:
164+
"""Flush a list of audio cache entries to artifact services.
165+
166+
Args:
167+
invocation_context: The invocation context.
168+
audio_cache: The audio cache to flush.
169+
cache_type: Type identifier for the cache ('input_audio' or
170+
'output_audio').
171+
172+
Returns:
173+
The created Event if the cache was successfully flushed, None otherwise.
174+
"""
175+
if (
176+
not getattr(invocation_context, 'artifact_service', None)
177+
or not audio_cache
178+
):
179+
logger.debug('Skipping cache flush: no artifact service or empty cache')
180+
return None
181+
182+
try:
183+
first_entry = audio_cache[0]
184+
first_blob = first_entry.data
185+
mime_type = (first_blob.mime_type if first_blob else None) or 'audio/pcm'
186+
if 'rate=' not in mime_type:
187+
if cache_type == 'output_audio':
188+
mime_type = f'{mime_type};rate=24000'
189+
elif cache_type == 'input_audio':
190+
mime_type = f'{mime_type};rate=16000'
191+
192+
combined_audio_data = b''.join(
193+
(entry.data.data if entry.data and entry.data.data else b'')
194+
for entry in audio_cache
195+
)
196+
197+
# Generate filename with timestamp from first audio chunk (when recording started)
198+
first_ts = (
199+
first_entry.timestamp
200+
if first_entry.timestamp is not None
201+
else time.time()
202+
)
203+
timestamp = int(first_ts * 1000) # milliseconds
204+
filename = f"live_audio_storage_{cache_type}_{timestamp}.{mime_type.split('/')[-1]}"
205+
206+
# Save to artifact service
207+
combined_audio_part = types.Part(
208+
inline_data=types.Blob(data=combined_audio_data, mime_type=mime_type)
209+
)
210+
211+
app_name = getattr(invocation_context, 'app_name', 'app')
212+
user_id = getattr(invocation_context, 'user_id', 'user')
213+
session_id = getattr(
214+
getattr(invocation_context, 'session', None), 'id', None
215+
) or getattr(invocation_context, 'session_id', 'default_session')
216+
217+
revision_id = await invocation_context.artifact_service.save_artifact(
218+
app_name=app_name,
219+
user_id=user_id,
220+
session_id=session_id,
221+
filename=filename,
222+
artifact=combined_audio_part,
223+
)
224+
225+
artifact_ref = f'artifact://{app_name}/{user_id}/{session_id}/_live/{filename}#{revision_id}'
226+
227+
author = (
228+
require_agent_name(invocation_context)
229+
if audio_cache[0].role == 'model'
230+
else (audio_cache[0].role or 'user')
231+
)
232+
audio_event = Event(
233+
invocation_id=getattr(invocation_context, 'invocation_id', None),
234+
author=author,
235+
content=types.Content(
236+
role=audio_cache[0].role,
237+
parts=[
238+
types.Part(
239+
file_data=types.FileData(
240+
file_uri=artifact_ref, mime_type=mime_type
241+
)
242+
)
243+
],
244+
),
245+
timestamp=audio_cache[0].timestamp or time.time(),
246+
)
247+
248+
logger.debug(
249+
'Successfully flushed %s cache: %d chunks, %d bytes, saved as %s',
250+
cache_type,
251+
len(audio_cache),
252+
len(combined_audio_data),
253+
filename,
254+
)
255+
256+
if hasattr(invocation_context, 'events') and isinstance(
257+
invocation_context.events, list
258+
):
259+
invocation_context.events.append(audio_event)
260+
session_obj = getattr(invocation_context, 'session', None)
261+
if (
262+
session_obj
263+
and hasattr(session_obj, 'events')
264+
and isinstance(session_obj.events, list)
265+
):
266+
session_obj.events.append(audio_event)
267+
268+
return audio_event
269+
270+
except Exception as e:
271+
logger.error('Failed to flush %s cache: %s', cache_type, e)
272+
return None
273+
274+
def get_cache_stats(self, invocation_context: Any) -> dict[str, int]:
275+
"""Get statistics about current cache state.
276+
277+
Args:
278+
invocation_context: The invocation context.
279+
280+
Returns:
281+
Dictionary containing cache statistics.
282+
"""
283+
input_chunks = (
284+
getattr(invocation_context, 'input_realtime_cache', None) or []
285+
)
286+
output_chunks = (
287+
getattr(invocation_context, 'output_realtime_cache', None) or []
288+
)
289+
290+
input_count = len(input_chunks)
291+
output_count = len(output_chunks)
292+
293+
input_bytes = sum(
294+
len(_require_audio_data(entry.data)) for entry in input_chunks
295+
)
296+
output_bytes = sum(
297+
len(_require_audio_data(entry.data)) for entry in output_chunks
298+
)
299+
300+
return {
301+
'input_chunks': input_count,
302+
'output_chunks': output_count,
303+
'input_bytes': input_bytes,
304+
'output_bytes': output_bytes,
305+
'total_chunks': input_count + output_count,
306+
'total_bytes': input_bytes + output_bytes,
307+
}

0 commit comments

Comments
 (0)