Skip to content

Commit 327afeb

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: support audio generation user simulator
- Add new LlmAudioUserSimulator which synthesizes text and generates audio along with the text - On top of existing BaseUserSimulatorConfig, accepts: - audio_model: TTS/audio model name - audio_model_configuration: includes a speech_config for defining voice persona and language - include_text_with_audio: whether to final user message should contain both text with the generated audio, or just the audio tokens - LlmAudioUserSimulator supports audio generation from both UserScenarios and fixed conversation plans - cloud_tts_llm.py adds an implementation of the Google Cloud API TTS model as a possible audio model for this user simulator - In evaluation_generator, strip text parts from user Content before sending to the live agent when audio inline_data is present, while preserving full Content (text + audio) in the logged Event for trajectory and autorater evaluation. PiperOrigin-RevId: 949656808
1 parent d27b4cd commit 327afeb

11 files changed

Lines changed: 1608 additions & 24 deletions

src/google/adk/evaluation/eval_config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@
3131
from .eval_metrics import BaseCriterion
3232
from .eval_metrics import MetricInfo
3333
from .eval_metrics import Threshold
34+
from .simulation._llm_audio_user_simulator import LlmAudioUserSimulatorConfig
3435
from .simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
3536

3637
logger = logging.getLogger("google_adk." + __name__)
3738

3839
# The set of user-simulator config subclasses that `EvalConfig` can
3940
# deserialize into via the `type` discriminator. Add any new subclass to
4041
# this Union (each with a unique `Literal[...]` for its `type` field).
41-
_UserSimulatorConfig = Union[LlmBackedUserSimulatorConfig]
42+
_UserSimulatorConfig = Union[
43+
LlmBackedUserSimulatorConfig, LlmAudioUserSimulatorConfig
44+
]
4245

4346
# Legacy default preserved for backward compatibility with eval configs authored
4447
# before the `type` discriminator existed. See

src/google/adk/evaluation/evaluation_generator.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,19 @@ async def _generate_inferences_for_single_user_invocation_live(
410410
invocation_id=current_invocation_id,
411411
)
412412

413-
live_request_queue.send_content(user_message)
413+
# If the user message contains audio parts, strip text parts before
414+
# sending to the agent so the model receives audio-only input.
415+
# The full Content (with text) is preserved in the Event above for
416+
# trajectory logging and autorater evaluation.
417+
message_for_agent = user_message
418+
if user_message.parts:
419+
has_audio = any(p.inline_data for p in user_message.parts)
420+
if has_audio:
421+
audio_parts = [p for p in user_message.parts if not p.text]
422+
if audio_parts:
423+
message_for_agent = Content(parts=audio_parts, role=user_message.role)
424+
425+
live_request_queue.send_content(message_for_agent)
414426

415427
try:
416428
await asyncio.wait_for(

src/google/adk/evaluation/simulation/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,39 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
from typing import Any
16+
from typing import TYPE_CHECKING
17+
18+
from google.adk.models.registry import LLMRegistry as _LLMRegistry
19+
20+
if TYPE_CHECKING:
21+
from ._llm_audio_user_simulator import LlmAudioUserSimulatorConfig
22+
23+
# Register _CloudTTSLlm lazily so that LLMRegistry.resolve("cloud_tts") works
24+
# without eagerly importing the Cloud TTS dependency.
25+
_LLMRegistry._register_lazy(
26+
[r"cloud_tts"],
27+
f"{__name__}._cloud_tts_llm",
28+
"_CloudTTSLlm",
29+
)
30+
31+
__all__ = [
32+
"LlmAudioUserSimulatorConfig",
33+
]
34+
35+
36+
def __getattr__(name: str) -> Any:
37+
"""Lazily exposes public symbols to avoid circular imports at init time.
38+
39+
``conversation_scenarios`` imports this subpackage (via
40+
``pre_built_personas``), while ``_llm_audio_user_simulator`` imports
41+
``conversation_scenarios``. Deferring the import until first attribute
42+
access breaks that cycle while keeping ``LlmAudioUserSimulatorConfig``
43+
importable from the package level.
44+
"""
45+
if name == "LlmAudioUserSimulatorConfig":
46+
from ._llm_audio_user_simulator import LlmAudioUserSimulatorConfig
47+
48+
return LlmAudioUserSimulatorConfig
49+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
"""BaseLlm adapter for Google Cloud Text-to-Speech.
16+
17+
Wraps the Cloud TTS API behind the standard ``BaseLlm`` interface.
18+
Voice selection is read from ``LlmRequest.config.speech_config``; audio
19+
is returned as ``inline_data`` in the ``LlmResponse``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
import os
26+
from typing import Any
27+
from typing import AsyncGenerator
28+
from typing import Optional
29+
30+
import google.api_core.exceptions
31+
from google.genai import types as genai_types
32+
from pydantic import Field
33+
34+
from ...models.base_llm import BaseLlm
35+
from ...models.llm_request import LlmRequest
36+
from ...models.llm_response import LlmResponse
37+
38+
logger = logging.getLogger("google_adk." + __name__)
39+
40+
# Mapping from Cloud TTS AudioEncoding names to MIME types.
41+
_TTS_ENCODING_TO_MIME_TYPE = {
42+
"LINEAR16": "audio/l16",
43+
"MP3": "audio/mpeg",
44+
"OGG_OPUS": "audio/ogg",
45+
"MULAW": "audio/basic",
46+
"ALAW": "audio/alaw",
47+
}
48+
49+
50+
class _CloudTTSLlm(BaseLlm):
51+
"""A BaseLlm that delegates to Google Cloud Text-to-Speech.
52+
53+
Voice selection is read from GenerateContentConfig.speech_config.
54+
Extract voice_name and language_code from LlmRequest.config.speech_config.
55+
56+
- language_code: (Default: "en-US") is a BCP-47 language tag
57+
(e.g. "en-US", "en-GB", "fr-FR", "de-DE", "ja-JP")
58+
- voice_name: (Default: "en-US-Studio-O") must be a Cloud TTS voice
59+
that matches the language code.
60+
Examples include:
61+
62+
- ``"en-US-Studio-O"`` — US English, Studio quality
63+
- ``"en-US-Neural2-A"`` — US English, Neural2
64+
- ``"en-GB-Neural2-A"`` — British English, Neural2
65+
- ``"fr-FR-Neural2-A"`` — French, Neural2
66+
67+
See https://cloud.google.com/text-to-speech/docs/voices for the full
68+
list of supported voices.
69+
70+
TTS-specific parameters (encoding, speaking speed, pitch) are exposed
71+
as optional model-level fields. The GCP project is read from the
72+
GOOGLE_CLOUD_PROJECT environment variable.
73+
"""
74+
75+
# -- TTS-specific fields (not available in GenerateContentConfig) ----------
76+
77+
audio_encoding: str = Field(
78+
default="LINEAR16",
79+
description=(
80+
"Audio encoding format for TTS output. Supported values:"
81+
" LINEAR16, MP3, OGG_OPUS, MULAW, ALAW."
82+
),
83+
)
84+
85+
speaking_speed: Optional[float] = Field(
86+
default=1.0,
87+
description="Speaking speed multiplier (0.25–4.0). 1.0 is normal speed.",
88+
)
89+
90+
pitch: Optional[float] = Field(
91+
default=0.0,
92+
description="Pitch adjustment in semitones (-20.0 to 20.0).",
93+
)
94+
95+
# -- Internal state --------------------------------------------------------
96+
97+
_tts_client: Any = None
98+
99+
@classmethod
100+
def supported_models(cls) -> list[str]: # pragma: no cover
101+
return [r"cloud_tts"]
102+
103+
# -- Helpers ---------------------------------------------------------------
104+
105+
@staticmethod
106+
def _extract_text(llm_request: LlmRequest) -> str:
107+
"""Extract plain text from the request contents."""
108+
texts = []
109+
for content in llm_request.contents:
110+
if content.parts:
111+
for part in content.parts:
112+
if part.text:
113+
texts.append(part.text)
114+
if not texts:
115+
raise ValueError("_CloudTTSLlm requires text in LlmRequest.contents")
116+
return " ".join(texts)
117+
118+
@staticmethod
119+
def _extract_voice_config(
120+
llm_request: LlmRequest,
121+
) -> tuple[str, str]:
122+
"""Extract voice config from LlmRequest.config.speech_config."""
123+
voice_name = "en-US-Studio-O"
124+
language_code = "en-US"
125+
126+
config = llm_request.config
127+
if config and isinstance(config.speech_config, genai_types.SpeechConfig):
128+
sc = config.speech_config
129+
if sc.language_code:
130+
language_code = sc.language_code
131+
if (
132+
sc.voice_config
133+
and sc.voice_config.prebuilt_voice_config
134+
and sc.voice_config.prebuilt_voice_config.voice_name
135+
):
136+
voice_name = sc.voice_config.prebuilt_voice_config.voice_name
137+
138+
return voice_name, language_code
139+
140+
# -- BaseLlm interface -----------------------------------------------------
141+
142+
async def generate_content_async(
143+
self, llm_request: LlmRequest, stream: bool = False
144+
) -> AsyncGenerator[LlmResponse, None]:
145+
"""Synthesize speech from text via the Cloud TTS API.
146+
147+
Args:
148+
llm_request: Request containing text contents and optional speech_config
149+
for voice selection.
150+
stream: Ignored — TTS always returns a single response.
151+
152+
Yields:
153+
A single ``LlmResponse`` with audio data in ``inline_data``.
154+
"""
155+
# Lazy imports to avoid mandatory dependency when TTS is not used.
156+
from google.cloud.texttospeech_v1 import TextToSpeechAsyncClient
157+
from google.cloud.texttospeech_v1.types import cloud_tts
158+
159+
# Initialise client lazily.
160+
if self._tts_client is None:
161+
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", None)
162+
if project_id:
163+
from google.api_core import client_options as client_options_lib
164+
165+
opts = client_options_lib.ClientOptions(
166+
quota_project_id=project_id,
167+
)
168+
self._tts_client = TextToSpeechAsyncClient(client_options=opts)
169+
else:
170+
self._tts_client = TextToSpeechAsyncClient()
171+
172+
# Extract text and voice config from the request.
173+
text = self._extract_text(llm_request)
174+
voice_name, language_code = self._extract_voice_config(llm_request)
175+
176+
# Map encoding string to the Cloud TTS enum.
177+
try:
178+
audio_encoding_enum = cloud_tts.AudioEncoding[self.audio_encoding]
179+
except KeyError as exc:
180+
raise ValueError(
181+
f"Unsupported audio_encoding: '{self.audio_encoding}'."
182+
f" Supported: {[e.name for e in cloud_tts.AudioEncoding]}"
183+
) from exc
184+
185+
# Build voice selection params.
186+
voice_params = cloud_tts.VoiceSelectionParams(
187+
language_code=language_code,
188+
name=voice_name,
189+
)
190+
191+
# Build audio config with optional rate/pitch overrides.
192+
audio_config_kwargs: dict[str, Any] = {
193+
"audio_encoding": audio_encoding_enum,
194+
}
195+
if self.speaking_speed is not None:
196+
audio_config_kwargs["speaking_rate"] = self.speaking_speed
197+
if self.pitch is not None:
198+
audio_config_kwargs["pitch"] = self.pitch
199+
audio_config = cloud_tts.AudioConfig(**audio_config_kwargs)
200+
201+
# Call the Cloud TTS API.
202+
try:
203+
tts_response = await self._tts_client.synthesize_speech(
204+
input=cloud_tts.SynthesisInput(text=text),
205+
voice=voice_params,
206+
audio_config=audio_config,
207+
)
208+
except google.api_core.exceptions.GoogleAPICallError as e:
209+
logger.error("Cloud TTS synthesis failed: %s", e)
210+
yield LlmResponse(
211+
error_code="TTS_SYNTHESIS_FAILED",
212+
error_message=str(e),
213+
)
214+
return
215+
216+
mime_type = _TTS_ENCODING_TO_MIME_TYPE.get(self.audio_encoding, "audio/l16")
217+
logger.info(
218+
"Cloud TTS synthesis completed: %d bytes of %s audio",
219+
len(tts_response.audio_content),
220+
self.audio_encoding,
221+
)
222+
223+
yield LlmResponse(
224+
content=genai_types.Content(
225+
role="model",
226+
parts=[
227+
genai_types.Part(
228+
inline_data=genai_types.Blob(
229+
mime_type=mime_type,
230+
data=tts_response.audio_content,
231+
)
232+
)
233+
],
234+
),
235+
)

0 commit comments

Comments
 (0)