|
| 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