diff --git a/pyproject.toml b/pyproject.toml index 69ba9984e6..cf62bc78b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,6 +163,10 @@ extensions = [ "toolbox-adk>=0.5.7, <0.6.0", # For tools.toolbox_toolset.ToolboxToolset ] +camb = [ + "camb-sdk>=1.0.0", # For CambAIToolset +] + otel-gcp = ["opentelemetry-instrumentation-google-genai>=0.6b0, <1.0.0"] toolbox = ["toolbox-adk>=0.5.7, <0.6.0"] diff --git a/src/google/adk/tools/__init__.py b/src/google/adk/tools/__init__.py index 28bb4670a0..05f4a4d78e 100644 --- a/src/google/adk/tools/__init__.py +++ b/src/google/adk/tools/__init__.py @@ -24,6 +24,7 @@ from .api_registry import ApiRegistry from .apihub_tool.apihub_toolset import APIHubToolset from .base_tool import BaseTool + from .camb.camb_toolset import CambAIToolset from .discovery_engine_search_tool import DiscoveryEngineSearchTool from .enterprise_search_tool import enterprise_web_search_tool as enterprise_web_search from .example_tool import ExampleTool @@ -86,6 +87,7 @@ 'MCPToolset': ('.mcp_tool.mcp_toolset', 'MCPToolset'), 'McpToolset': ('.mcp_tool.mcp_toolset', 'McpToolset'), 'ApiRegistry': ('.api_registry', 'ApiRegistry'), + 'CambAIToolset': ('.camb.camb_toolset', 'CambAIToolset'), } __all__ = list(_LAZY_MAPPING.keys()) diff --git a/src/google/adk/tools/camb/__init__.py b/src/google/adk/tools/camb/__init__.py new file mode 100644 index 0000000000..6744cbf1ea --- /dev/null +++ b/src/google/adk/tools/camb/__init__.py @@ -0,0 +1,36 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CAMB AI Tools for Google ADK. + +Provides audio and speech tools powered by camb.ai, including: + +- Text-to-Speech (TTS) +- Translation +- Transcription +- Translated TTS +- Voice Cloning +- Voice Listing +- Text-to-Sound generation +- Audio Separation + +These tools can be used with any ADK Agent by passing a +:class:`CambAIToolset` instance. +""" + +from .camb_toolset import CambAIToolset + +__all__ = [ + "CambAIToolset", +] diff --git a/src/google/adk/tools/camb/_helpers.py b/src/google/adk/tools/camb/_helpers.py new file mode 100644 index 0000000000..91344bb1c3 --- /dev/null +++ b/src/google/adk/tools/camb/_helpers.py @@ -0,0 +1,265 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared helpers for CAMB AI tools. + +Provides lazy client management, polling, audio format detection, +PCM-to-WAV conversion, and result formatting utilities. +""" + +from __future__ import annotations + +import asyncio +import json +import struct +import tempfile +from typing import Any +from typing import Optional + + +class CambHelpers: + """Shared helper utilities for CAMB AI tool functions. + + Manages a lazily-initialised ``AsyncCambAI`` client and provides + common helper methods used across all CAMB tool functions. + + Args: + api_key: camb.ai API key. Falls back to ``CAMB_API_KEY`` env var. + timeout: Request timeout in seconds. + max_poll_attempts: Maximum polling iterations for async tasks. + poll_interval: Seconds between polling attempts. + """ + + def __init__( + self, + api_key: Optional[str] = None, + timeout: float = 60.0, + max_poll_attempts: int = 60, + poll_interval: float = 2.0, + ) -> None: + from os import getenv + + self._api_key = api_key or getenv("CAMB_API_KEY") + if not self._api_key: + raise ValueError( + "CAMB_API_KEY not set. Please set the CAMB_API_KEY environment" + " variable or pass api_key to CambAIToolset." + ) + self._timeout = timeout + self._max_poll_attempts = max_poll_attempts + self._poll_interval = poll_interval + self._client: Any = None + + # ------------------------------------------------------------------ + # Client + # ------------------------------------------------------------------ + + def get_client(self) -> Any: + """Return a lazily-initialised ``AsyncCambAI`` client.""" + if self._client is None: + try: + from camb.client import AsyncCambAI + except ImportError as exc: + raise ImportError( + "The 'camb' package is required to use CambAIToolset. " + "Install it with: pip install google-adk[camb]" + ) from exc + self._client = AsyncCambAI(api_key=self._api_key, timeout=self._timeout) + return self._client + + # ------------------------------------------------------------------ + # Polling + # ------------------------------------------------------------------ + + async def poll_async( + self, + get_status_fn: Any, + task_id: Any, + *, + run_id: Any = None, + ) -> Any: + """Poll a camb.ai async task until it completes or fails.""" + for _ in range(self._max_poll_attempts): + status = await get_status_fn(task_id, run_id=run_id) + if hasattr(status, "status"): + val = status.status + if val in ("completed", "SUCCESS"): + return status + if val in ("failed", "FAILED", "error"): + raise RuntimeError( + f"Task failed: {getattr(status, 'error', 'Unknown error')}" + ) + await asyncio.sleep(self._poll_interval) + raise TimeoutError( + f"Task {task_id} did not complete within " + f"{self._max_poll_attempts * self._poll_interval}s" + ) + + # ------------------------------------------------------------------ + # Audio helpers + # ------------------------------------------------------------------ + + @staticmethod + def detect_audio_format(data: bytes, content_type: str = "") -> str: + """Detect audio format from raw bytes or content-type header.""" + if data.startswith(b"RIFF"): + return "wav" + if data.startswith((b"\xff\xfb", b"\xff\xfa", b"ID3")): + return "mp3" + if data.startswith(b"fLaC"): + return "flac" + if data.startswith(b"OggS"): + return "ogg" + ct = content_type.lower() + for key, fmt in [ + ("wav", "wav"), + ("wave", "wav"), + ("mpeg", "mp3"), + ("mp3", "mp3"), + ("flac", "flac"), + ("ogg", "ogg"), + ]: + if key in ct: + return fmt + return "pcm" + + @staticmethod + def add_wav_header(pcm_data: bytes) -> bytes: + """Wrap raw PCM data (24 kHz, mono, 16-bit) with a WAV header.""" + sr, ch, bps = 24000, 1, 16 + byte_rate = sr * ch * bps // 8 + block_align = ch * bps // 8 + data_size = len(pcm_data) + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + 36 + data_size, + b"WAVE", + b"fmt ", + 16, + 1, + ch, + sr, + byte_rate, + block_align, + bps, + b"data", + data_size, + ) + return header + pcm_data + + @staticmethod + def save_audio(data: bytes, suffix: str = ".wav") -> str: + """Save audio bytes to a temporary file and return the file path.""" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f: + f.write(data) + return f.name + + # ------------------------------------------------------------------ + # Formatting helpers + # ------------------------------------------------------------------ + + @staticmethod + def gender_str(g: int) -> str: + """Convert a numeric gender code to a human-readable string.""" + return { + 0: "not_specified", + 1: "male", + 2: "female", + 9: "not_applicable", + }.get(g, "unknown") + + @staticmethod + def format_transcription(transcription: Any) -> str: + """Format a transcription result as a JSON string.""" + out: dict[str, Any] = { + "text": getattr(transcription, "text", ""), + "segments": [], + "speakers": [], + } + if hasattr(transcription, "segments"): + for seg in transcription.segments: + out["segments"].append({ + "start": getattr(seg, "start", 0), + "end": getattr(seg, "end", 0), + "text": getattr(seg, "text", ""), + "speaker": getattr(seg, "speaker", None), + }) + if hasattr(transcription, "speakers"): + out["speakers"] = list(transcription.speakers) + elif out["segments"]: + out["speakers"] = list( + {s["speaker"] for s in out["segments"] if s.get("speaker")} + ) + return json.dumps(out, indent=2) + + def format_voices(self, voices: Any) -> str: + """Format a list of voice objects as a JSON string.""" + out: list[dict[str, Any]] = [] + for v in voices: + if isinstance(v, dict): + out.append({ + "id": v.get("id"), + "name": v.get("voice_name", v.get("name", "Unknown")), + "gender": self.gender_str(v.get("gender", 0)), + "age": v.get("age"), + "language": v.get("language"), + }) + else: + out.append({ + "id": getattr(v, "id", None), + "name": getattr(v, "voice_name", getattr(v, "name", "Unknown")), + "gender": self.gender_str(getattr(v, "gender", 0)), + "age": getattr(v, "age", None), + "language": getattr(v, "language", None), + }) + return json.dumps(out, indent=2) + + def format_separation(self, sep: Any) -> str: + """Format an audio-separation result as a JSON string.""" + out: dict[str, Any] = { + "vocals": None, + "background": None, + "status": "completed", + } + for attr, key in [ + ("vocals_url", "vocals"), + ("vocals", "vocals"), + ("voice_url", "vocals"), + ("background_url", "background"), + ("background", "background"), + ("instrumental_url", "background"), + ]: + val = getattr(sep, attr, None) + if val and out[key] is None: + if isinstance(val, bytes): + out[key] = self.save_audio(val, f"_{key}.wav") + else: + out[key] = val + return json.dumps(out, indent=2) + + @staticmethod + def extract_translation(result: Any) -> str: + """Extract translated text from an SDK result.""" + if hasattr(result, "__iter__") and not isinstance(result, (str, bytes)): + parts: list[str] = [] + for chunk in result: + if hasattr(chunk, "text"): + parts.append(chunk.text) + elif isinstance(chunk, str): + parts.append(chunk) + return "".join(parts) + if hasattr(result, "text"): + return str(result.text) + return str(result) diff --git a/src/google/adk/tools/camb/_tools.py b/src/google/adk/tools/camb/_tools.py new file mode 100644 index 0000000000..6c84f0430b --- /dev/null +++ b/src/google/adk/tools/camb/_tools.py @@ -0,0 +1,425 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CAMB AI tool function factories. + +Each factory accepts a :class:`CambHelpers` instance and returns an +``async def`` that can be wrapped by :class:`FunctionTool`. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from typing import Any +from typing import Callable +from typing import Coroutine +from typing import Optional + +from ._helpers import CambHelpers + +_CAMB_TTS_RESULT_URL = "https://client.camb.ai/apis/tts-result/{run_id}" + + +def make_tts_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_tts`` async function.""" + + async def camb_tts( + text: str, + language: str = "en-us", + voice_id: int = 147320, + speech_model: str = "mars-flash", + user_instructions: Optional[str] = None, + ) -> str: + """Convert text to speech using camb.ai. + + Supports 140+ languages and multiple voice models. The audio is + saved to a temporary file and the file path is returned. + + Args: + text: Text to convert to speech (3-3000 characters). + language: BCP-47 language code (e.g. 'en-us', 'fr-fr'). + voice_id: Voice ID. Use camb_list_voices to find voices. + speech_model: Model: 'mars-flash', 'mars-pro', 'mars-instruct'. + user_instructions: Instructions for mars-instruct model only. + """ + from camb import StreamTtsOutputConfiguration + + client = helpers.get_client() + kwargs: dict[str, Any] = { + "text": text, + "language": language, + "voice_id": voice_id, + "speech_model": speech_model, + "output_configuration": StreamTtsOutputConfiguration(format="wav"), + } + if user_instructions and speech_model == "mars-instruct": + kwargs["user_instructions"] = user_instructions + + chunks: list[bytes] = [] + async for chunk in client.text_to_speech.tts(**kwargs): + chunks.append(chunk) + return helpers.save_audio(b"".join(chunks), ".wav") + + return camb_tts + + +def make_translate_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_translate`` async function.""" + + async def camb_translate( + text: str, + source_language: int, + target_language: int, + formality: Optional[int] = None, + ) -> str: + """Translate text between 140+ languages using camb.ai. + + Provide integer language codes: 1=English, 2=Spanish, 3=French, + 4=German, 5=Italian, 6=Portuguese, 7=Dutch, 8=Russian, 9=Japanese, + 10=Korean, 11=Chinese. + + Args: + text: Text to translate. + source_language: Source language code (integer). + target_language: Target language code (integer). + formality: Optional formality level: 1=formal, 2=informal. + """ + from camb.core.api_error import ApiError + + client = helpers.get_client() + kwargs: dict[str, Any] = { + "text": text, + "source_language": source_language, + "target_language": target_language, + } + if formality: + kwargs["formality"] = formality + + try: + result = await client.translation.translation_stream(**kwargs) + return helpers.extract_translation(result) + except ApiError as e: + # The CAMB SDK sometimes wraps a successful (HTTP 200) translation + # response inside an ApiError. When this happens the body contains + # the translated text. Re-raise for genuine errors. + if e.status_code == 200 and e.body: + return str(e.body) + raise + + return camb_translate + + +def make_transcribe_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_transcribe`` async function.""" + + async def camb_transcribe( + language: int, + audio_url: Optional[str] = None, + audio_file_path: Optional[str] = None, + ) -> str: + """Transcribe audio to text with speaker identification using camb.ai. + + Supports audio URLs or local file paths. Returns JSON with full + transcription text, timed segments, and speaker labels. + + Args: + language: Language code (integer). 1=English, 2=Spanish, etc. + audio_url: URL of the audio file to transcribe. + audio_file_path: Local file path to the audio file. + """ + client = helpers.get_client() + kwargs: dict[str, Any] = {"language": language} + + if audio_url: + import httpx + + async with httpx.AsyncClient(timeout=helpers._timeout) as http: + resp = await http.get(audio_url) + resp.raise_for_status() + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + try: + with open(tmp_path, "rb") as f: + kwargs["media_file"] = f + result = await client.transcription.create_transcription(**kwargs) + finally: + os.unlink(tmp_path) + elif audio_file_path: + with open(audio_file_path, "rb") as f: + kwargs["media_file"] = f + result = await client.transcription.create_transcription(**kwargs) + else: + raise ValueError("Provide either audio_url or audio_file_path") + + task_id = result.task_id + status = await helpers.poll_async( + client.transcription.get_transcription_task_status, task_id + ) + transcription = await client.transcription.get_transcription_result( + status.run_id + ) + return helpers.format_transcription(transcription) + + return camb_transcribe + + +def make_translated_tts_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_translated_tts`` async function.""" + + async def camb_translated_tts( + text: str, + source_language: int, + target_language: int, + voice_id: int = 147320, + formality: Optional[int] = None, + ) -> str: + """Translate text and convert to speech in one step using camb.ai. + + Returns the file path to the generated audio file. + + Args: + text: Text to translate and speak. + source_language: Source language code (integer). + target_language: Target language code (integer). + voice_id: Voice ID for TTS output. + formality: Optional formality: 1=formal, 2=informal. + """ + import httpx + + client = helpers.get_client() + kwargs: dict[str, Any] = { + "text": text, + "voice_id": voice_id, + "source_language": source_language, + "target_language": target_language, + } + if formality: + kwargs["formality"] = formality + + result = await client.translated_tts.create_translated_tts(**kwargs) + status = await helpers.poll_async( + client.translated_tts.get_translated_tts_task_status, + result.task_id, + ) + + run_id = getattr(status, "run_id", None) + audio_data = b"" + fmt = "pcm" + if run_id: + url = _CAMB_TTS_RESULT_URL.format(run_id=run_id) + async with httpx.AsyncClient(timeout=helpers._timeout) as http: + resp = await http.get( + url, headers={"x-api-key": helpers._api_key or ""} + ) + if resp.status_code == 200: + audio_data = resp.content + fmt = helpers.detect_audio_format( + audio_data, resp.headers.get("content-type", "") + ) + + if not audio_data: + raise RuntimeError( + f"Translated TTS failed: no audio data received (run_id={run_id})" + ) + + if fmt == "pcm" and audio_data: + audio_data = helpers.add_wav_header(audio_data) + fmt = "wav" + + ext = {"wav": ".wav", "mp3": ".mp3", "flac": ".flac", "ogg": ".ogg"}.get( + fmt, ".wav" + ) + return helpers.save_audio(audio_data, ext) + + return camb_translated_tts + + +def make_clone_voice_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_clone_voice`` async function.""" + + async def camb_clone_voice( + voice_name: str, + audio_file_path: str, + gender: int, + description: Optional[str] = None, + age: Optional[int] = None, + language: Optional[int] = None, + ) -> str: + """Clone a voice from an audio sample using camb.ai. + + Creates a custom voice from a 2+ second audio sample that can be + used with camb_tts and camb_translated_tts. + + Args: + voice_name: Name for the new cloned voice. + audio_file_path: Path to audio file (minimum 2 seconds). + gender: Gender: 1=Male, 2=Female, 0=Not Specified, 9=Not Applicable. + description: Optional description of the voice. + age: Optional age of the voice. + language: Optional language code for the voice. + """ + client = helpers.get_client() + with open(audio_file_path, "rb") as f: + kwargs: dict[str, Any] = { + "voice_name": voice_name, + "gender": gender, + "file": f, + } + if description: + kwargs["description"] = description + if age: + kwargs["age"] = age + if language: + kwargs["language"] = language + result = await client.voice_cloning.create_custom_voice(**kwargs) + + out: dict[str, Any] = { + "voice_id": getattr(result, "voice_id", getattr(result, "id", None)), + "voice_name": voice_name, + "status": "created", + } + if hasattr(result, "message"): + out["message"] = result.message + return json.dumps(out, indent=2) + + return camb_clone_voice + + +def make_list_voices_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_list_voices`` async function.""" + + async def camb_list_voices() -> str: + """List all available voices from camb.ai. + + Returns voice IDs, names, genders, ages, and languages. Use the + voice ID with camb_tts or camb_translated_tts. + """ + client = helpers.get_client() + voices = await client.voice_cloning.list_voices() + return helpers.format_voices(voices) + + return camb_list_voices + + +def make_text_to_sound_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_text_to_sound`` async function.""" + + async def camb_text_to_sound( + prompt: str, + duration: Optional[float] = None, + audio_type: Optional[str] = None, + ) -> str: + """Generate sounds, music, or soundscapes from text descriptions using camb.ai. + + Describe the sound or music you want and the tool will generate it. + Returns the file path to the generated audio file. + + Args: + prompt: Description of the sound or music to generate. + duration: Optional duration in seconds. + audio_type: Optional type: 'music' or 'sound'. + """ + client = helpers.get_client() + kwargs: dict[str, Any] = {"prompt": prompt} + if duration: + kwargs["duration"] = duration + if audio_type: + kwargs["audio_type"] = audio_type + + result = await client.text_to_audio.create_text_to_audio(**kwargs) + status = await helpers.poll_async( + client.text_to_audio.get_text_to_audio_status, result.task_id + ) + + chunks: list[bytes] = [] + async for chunk in client.text_to_audio.get_text_to_audio_result( + status.run_id + ): + chunks.append(chunk) + return helpers.save_audio(b"".join(chunks), ".wav") + + return camb_text_to_sound + + +def make_audio_separation_func( + helpers: CambHelpers, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Create the ``camb_audio_separation`` async function.""" + + async def camb_audio_separation( + audio_url: Optional[str] = None, + audio_file_path: Optional[str] = None, + ) -> str: + """Separate vocals/speech from background audio using camb.ai. + + Provide either an audio URL or a local file path. Returns JSON with + paths to the separated vocals and background audio files. + + Args: + audio_url: URL of the audio file to separate. + audio_file_path: Local file path to the audio file. + """ + client = helpers.get_client() + kwargs: dict[str, Any] = {} + + if audio_url: + import httpx + + async with httpx.AsyncClient(timeout=helpers._timeout) as http: + resp = await http.get(audio_url) + resp.raise_for_status() + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + try: + with open(tmp_path, "rb") as f: + kwargs["media_file"] = f + result = await client.audio_separation.create_audio_separation( + **kwargs + ) + finally: + os.unlink(tmp_path) + elif audio_file_path: + with open(audio_file_path, "rb") as f: + kwargs["media_file"] = f + result = await client.audio_separation.create_audio_separation(**kwargs) + else: + raise ValueError("Provide either audio_url or audio_file_path") + + status = await helpers.poll_async( + client.audio_separation.get_audio_separation_status, result.task_id + ) + sep = await client.audio_separation.get_audio_separation_run_info( + status.run_id + ) + return helpers.format_separation(sep) + + return camb_audio_separation diff --git a/src/google/adk/tools/camb/camb_toolset.py b/src/google/adk/tools/camb/camb_toolset.py new file mode 100644 index 0000000000..124283c040 --- /dev/null +++ b/src/google/adk/tools/camb/camb_toolset.py @@ -0,0 +1,152 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CAMB AI Toolset for Google ADK. + +Provides audio and speech tools powered by camb.ai, including text-to-speech, +translation, transcription, translated TTS, voice cloning, voice listing, +text-to-sound generation, and audio separation. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing import Union + +from typing_extensions import override + +from ..base_tool import BaseTool +from ..base_toolset import BaseToolset +from ..base_toolset import ToolPredicate +from ..function_tool import FunctionTool +from ._helpers import CambHelpers +from ._tools import make_audio_separation_func +from ._tools import make_clone_voice_func +from ._tools import make_list_voices_func +from ._tools import make_text_to_sound_func +from ._tools import make_transcribe_func +from ._tools import make_translate_func +from ._tools import make_translated_tts_func +from ._tools import make_tts_func + +try: + from ...agents.readonly_context import ReadonlyContext +except ImportError: # pragma: no cover + ReadonlyContext = None # type: ignore[assignment,misc] + + +class CambAIToolset(BaseToolset): + """Toolset that exposes camb.ai audio/speech services as ADK tools. + + Each enabled service is wrapped in a :class:`FunctionTool` so that ADK + agents can call it. The underlying ``camb`` SDK is imported lazily; + installing the ``camb`` extra is only required when the toolset is used. + + Example:: + + from google.adk.tools.camb import CambAIToolset + + toolset = CambAIToolset(api_key="your-key") + agent = Agent( + name="audio_agent", + model="gemini-2.0-flash", + tools=[toolset], + ) + + Args: + api_key: camb.ai API key. Falls back to ``CAMB_API_KEY`` env var. + timeout: Request timeout in seconds. + max_poll_attempts: Maximum polling iterations for async tasks. + poll_interval: Seconds between polling attempts. + tool_filter: Optional filter to select a subset of tools. + include_tts: Include the text-to-speech tool. + include_translation: Include the translation tool. + include_transcription: Include the transcription tool. + include_translated_tts: Include the translated TTS tool. + include_voice_clone: Include the voice cloning tool. + include_voice_list: Include the voice listing tool. + include_text_to_sound: Include the text-to-sound tool. + include_audio_separation: Include the audio separation tool. + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + timeout: float = 60.0, + max_poll_attempts: int = 60, + poll_interval: float = 2.0, + tool_filter: Optional[Union[ToolPredicate, List[str]]] = None, + include_tts: bool = True, + include_translation: bool = True, + include_transcription: bool = True, + include_translated_tts: bool = True, + include_voice_clone: bool = True, + include_voice_list: bool = True, + include_text_to_sound: bool = True, + include_audio_separation: bool = True, + ) -> None: + super().__init__(tool_filter=tool_filter) + self._helpers = CambHelpers( + api_key=api_key, + timeout=timeout, + max_poll_attempts=max_poll_attempts, + poll_interval=poll_interval, + ) + self._include_tts = include_tts + self._include_translation = include_translation + self._include_transcription = include_transcription + self._include_translated_tts = include_translated_tts + self._include_voice_clone = include_voice_clone + self._include_voice_list = include_voice_list + self._include_text_to_sound = include_text_to_sound + self._include_audio_separation = include_audio_separation + + @override + async def get_tools( + self, + readonly_context: Optional[ReadonlyContext] = None, + ) -> List[BaseTool]: + """Return the enabled CAMB AI tools as :class:`FunctionTool` instances.""" + helpers = self._helpers + all_tools: list[BaseTool] = [] + + if self._include_tts: + all_tools.append(FunctionTool(make_tts_func(helpers))) + if self._include_translation: + all_tools.append(FunctionTool(make_translate_func(helpers))) + if self._include_transcription: + all_tools.append(FunctionTool(make_transcribe_func(helpers))) + if self._include_translated_tts: + all_tools.append(FunctionTool(make_translated_tts_func(helpers))) + if self._include_voice_clone: + all_tools.append(FunctionTool(make_clone_voice_func(helpers))) + if self._include_voice_list: + all_tools.append(FunctionTool(make_list_voices_func(helpers))) + if self._include_text_to_sound: + all_tools.append(FunctionTool(make_text_to_sound_func(helpers))) + if self._include_audio_separation: + all_tools.append(FunctionTool(make_audio_separation_func(helpers))) + + return [ + tool + for tool in all_tools + if self._is_tool_selected(tool, readonly_context) + ] + + @override + async def close(self) -> None: + """Release resources held by the toolset.""" + pass diff --git a/tests/unittests/tools/camb/__init__.py b/tests/unittests/tools/camb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unittests/tools/camb/test_camb_tools.py b/tests/unittests/tools/camb/test_camb_tools.py new file mode 100644 index 0000000000..4353777284 --- /dev/null +++ b/tests/unittests/tools/camb/test_camb_tools.py @@ -0,0 +1,709 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the CAMB AI toolset with mocked CAMB SDK.""" + +from __future__ import annotations + +import json +import os +import struct +import tempfile +from types import SimpleNamespace +from typing import Any +from unittest import mock + +from google.adk.tools.camb._helpers import CambHelpers +from google.adk.tools.camb._tools import make_audio_separation_func +from google.adk.tools.camb._tools import make_clone_voice_func +from google.adk.tools.camb._tools import make_list_voices_func +from google.adk.tools.camb._tools import make_text_to_sound_func +from google.adk.tools.camb._tools import make_transcribe_func +from google.adk.tools.camb._tools import make_translate_func +from google.adk.tools.camb._tools import make_translated_tts_func +from google.adk.tools.camb._tools import make_tts_func +from google.adk.tools.camb.camb_toolset import CambAIToolset +from google.adk.tools.function_tool import FunctionTool +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _set_api_key(monkeypatch): + """Ensure CAMB_API_KEY is always available for tests.""" + monkeypatch.setenv("CAMB_API_KEY", "test-api-key-123") + + +def _make_helpers() -> CambHelpers: + """Create a CambHelpers instance for testing.""" + return CambHelpers( + api_key="test-api-key", + timeout=10.0, + max_poll_attempts=3, + poll_interval=0.01, + ) + + +# --------------------------------------------------------------------------- +# CambHelpers tests +# --------------------------------------------------------------------------- + + +class TestCambHelpers: + """Tests for the CambHelpers utility class.""" + + def test_init_with_explicit_key(self): + h = CambHelpers(api_key="my-key") + assert h._api_key == "my-key" + + def test_init_from_env(self): + h = CambHelpers() + assert h._api_key == "test-api-key-123" + + def test_init_missing_key_raises(self, monkeypatch): + monkeypatch.delenv("CAMB_API_KEY", raising=False) + with pytest.raises(ValueError, match="CAMB_API_KEY not set"): + CambHelpers() + + def test_detect_audio_format_wav(self): + assert CambHelpers.detect_audio_format(b"RIFF....") == "wav" + + def test_detect_audio_format_mp3_sync(self): + assert CambHelpers.detect_audio_format(b"\xff\xfb\x90\x00") == "mp3" + + def test_detect_audio_format_mp3_id3(self): + assert CambHelpers.detect_audio_format(b"ID3....") == "mp3" + + def test_detect_audio_format_flac(self): + assert CambHelpers.detect_audio_format(b"fLaC....") == "flac" + + def test_detect_audio_format_ogg(self): + assert CambHelpers.detect_audio_format(b"OggS....") == "ogg" + + def test_detect_audio_format_from_content_type(self): + assert CambHelpers.detect_audio_format(b"\x00\x00", "audio/mpeg") == "mp3" + + def test_detect_audio_format_pcm_fallback(self): + assert CambHelpers.detect_audio_format(b"\x00\x00") == "pcm" + + def test_add_wav_header(self): + pcm = b"\x00" * 100 + wav = CambHelpers.add_wav_header(pcm) + assert wav.startswith(b"RIFF") + assert wav[8:12] == b"WAVE" + # Total size should be 44-byte header + pcm data + assert len(wav) == 44 + 100 + + def test_save_audio(self): + path = CambHelpers.save_audio(b"fake-audio-data", ".wav") + try: + assert path.endswith(".wav") + with open(path, "rb") as f: + assert f.read() == b"fake-audio-data" + finally: + os.unlink(path) + + def test_gender_str(self): + assert CambHelpers.gender_str(0) == "not_specified" + assert CambHelpers.gender_str(1) == "male" + assert CambHelpers.gender_str(2) == "female" + assert CambHelpers.gender_str(9) == "not_applicable" + assert CambHelpers.gender_str(99) == "unknown" + + def test_format_transcription(self): + transcription = SimpleNamespace( + text="Hello world", + segments=[ + SimpleNamespace(start=0.0, end=1.0, text="Hello", speaker="A"), + SimpleNamespace(start=1.0, end=2.0, text="world", speaker="B"), + ], + speakers=["A", "B"], + ) + result = json.loads(CambHelpers.format_transcription(transcription)) + assert result["text"] == "Hello world" + assert len(result["segments"]) == 2 + assert result["speakers"] == ["A", "B"] + + def test_format_voices(self): + h = _make_helpers() + voices = [ + SimpleNamespace( + id=1, voice_name="Test Voice", gender=1, age=30, language=1 + ), + {"id": 2, "voice_name": "Dict Voice", "gender": 2, "age": 25}, + ] + result = json.loads(h.format_voices(voices)) + assert len(result) == 2 + assert result[0]["name"] == "Test Voice" + assert result[0]["gender"] == "male" + assert result[1]["name"] == "Dict Voice" + assert result[1]["gender"] == "female" + + def test_format_separation(self): + h = _make_helpers() + sep = SimpleNamespace( + vocals_url="https://example.com/vocals.wav", + background_url="https://example.com/bg.wav", + ) + result = json.loads(h.format_separation(sep)) + assert result["vocals"] == "https://example.com/vocals.wav" + assert result["background"] == "https://example.com/bg.wav" + assert result["status"] == "completed" + + def test_extract_translation_string(self): + assert CambHelpers.extract_translation("Hello") == "Hello" + + def test_extract_translation_object_with_text(self): + obj = SimpleNamespace(text="Bonjour") + assert CambHelpers.extract_translation(obj) == "Bonjour" + + def test_extract_translation_iterable(self): + chunks = [ + SimpleNamespace(text="Bon"), + SimpleNamespace(text="jour"), + ] + assert CambHelpers.extract_translation(chunks) == "Bonjour" + + +# --------------------------------------------------------------------------- +# Polling tests +# --------------------------------------------------------------------------- + + +class TestPolling: + """Tests for the async polling helper.""" + + @pytest.mark.asyncio + async def test_poll_success(self): + h = _make_helpers() + status = SimpleNamespace(status="completed", run_id="run-1") + + async def get_status(task_id, *, run_id=None): + return status + + result = await h.poll_async(get_status, "task-1") + assert result.run_id == "run-1" + + @pytest.mark.asyncio + async def test_poll_failure(self): + h = _make_helpers() + status = SimpleNamespace(status="failed", error="boom") + + async def get_status(task_id, *, run_id=None): + return status + + with pytest.raises(RuntimeError, match="Task failed"): + await h.poll_async(get_status, "task-1") + + @pytest.mark.asyncio + async def test_poll_timeout(self): + h = CambHelpers(api_key="key", max_poll_attempts=2, poll_interval=0.01) + status = SimpleNamespace(status="pending") + + async def get_status(task_id, *, run_id=None): + return status + + with pytest.raises(TimeoutError): + await h.poll_async(get_status, "task-1") + + +# --------------------------------------------------------------------------- +# Toolset tests +# --------------------------------------------------------------------------- + + +class TestCambAIToolset: + """Tests for the CambAIToolset class.""" + + @pytest.mark.asyncio + async def test_default_tools_count(self): + toolset = CambAIToolset(api_key="test-key") + tools = await toolset.get_tools() + assert len(tools) == 8 + assert all(isinstance(t, FunctionTool) for t in tools) + + @pytest.mark.asyncio + async def test_all_tool_names(self): + toolset = CambAIToolset(api_key="test-key") + tools = await toolset.get_tools() + names = {t.name for t in tools} + expected = { + "camb_tts", + "camb_translate", + "camb_transcribe", + "camb_translated_tts", + "camb_clone_voice", + "camb_list_voices", + "camb_text_to_sound", + "camb_audio_separation", + } + assert names == expected + + @pytest.mark.asyncio + async def test_include_flags_disable_all(self): + toolset = CambAIToolset( + api_key="test-key", + include_tts=False, + include_translation=False, + include_transcription=False, + include_translated_tts=False, + include_voice_clone=False, + include_voice_list=False, + include_text_to_sound=False, + include_audio_separation=False, + ) + tools = await toolset.get_tools() + assert len(tools) == 0 + + @pytest.mark.asyncio + async def test_include_only_tts(self): + toolset = CambAIToolset( + api_key="test-key", + include_tts=True, + include_translation=False, + include_transcription=False, + include_translated_tts=False, + include_voice_clone=False, + include_voice_list=False, + include_text_to_sound=False, + include_audio_separation=False, + ) + tools = await toolset.get_tools() + assert len(tools) == 1 + assert tools[0].name == "camb_tts" + + @pytest.mark.asyncio + async def test_tool_filter_by_name(self): + toolset = CambAIToolset( + api_key="test-key", + tool_filter=["camb_tts", "camb_translate"], + ) + tools = await toolset.get_tools() + assert len(tools) == 2 + names = {t.name for t in tools} + assert names == {"camb_tts", "camb_translate"} + + @pytest.mark.asyncio + async def test_tool_filter_unknown_name(self): + toolset = CambAIToolset( + api_key="test-key", + tool_filter=["nonexistent_tool"], + ) + tools = await toolset.get_tools() + assert len(tools) == 0 + + @pytest.mark.asyncio + async def test_tool_filter_mixed(self): + toolset = CambAIToolset( + api_key="test-key", + tool_filter=["camb_tts", "nonexistent"], + ) + tools = await toolset.get_tools() + assert len(tools) == 1 + assert tools[0].name == "camb_tts" + + @pytest.mark.asyncio + async def test_close(self): + toolset = CambAIToolset(api_key="test-key") + # Should not raise + await toolset.close() + + def test_missing_api_key_raises(self, monkeypatch): + monkeypatch.delenv("CAMB_API_KEY", raising=False) + with pytest.raises(ValueError, match="CAMB_API_KEY not set"): + CambAIToolset() + + +# --------------------------------------------------------------------------- +# Tool function tests (with mocked CAMB SDK) +# --------------------------------------------------------------------------- + + +class TestToolFunctions: + """Tests for individual CAMB tool functions with mocked SDK client.""" + + def _mock_client(self) -> mock.MagicMock: + """Create a fully mocked AsyncCambAI client.""" + return mock.MagicMock() + + def _helpers_with_mock_client(self) -> tuple[CambHelpers, mock.MagicMock]: + """Create helpers with a pre-injected mock client.""" + h = _make_helpers() + client = self._mock_client() + h._client = client + return h, client + + @pytest.mark.asyncio + async def test_camb_tts(self): + h, client = self._helpers_with_mock_client() + audio_data = b"RIFF" + b"\x00" * 100 + + async def mock_tts(**kwargs): + yield audio_data + + client.text_to_speech.tts = mock_tts + + # Mock the StreamTtsOutputConfiguration import + mock_config = mock.MagicMock() + with mock.patch.dict( + "sys.modules", + {"camb": mock.MagicMock(StreamTtsOutputConfiguration=mock_config)}, + ): + func = make_tts_func(h) + result = await func(text="Hello world") + + assert result.endswith(".wav") + with open(result, "rb") as f: + assert f.read() == audio_data + os.unlink(result) + + @pytest.mark.asyncio + async def test_camb_translate(self): + h, client = self._helpers_with_mock_client() + + async def mock_translate(**kwargs): + return [SimpleNamespace(text="Hola mundo")] + + client.translation.translation_stream = mock_translate + + mock_api_error = mock.MagicMock() + with mock.patch.dict( + "sys.modules", + { + "camb": mock.MagicMock(), + "camb.core": mock.MagicMock(), + "camb.core.api_error": mock.MagicMock(ApiError=mock_api_error), + }, + ): + func = make_translate_func(h) + result = await func( + text="Hello world", source_language=1, target_language=2 + ) + + assert result == "Hola mundo" + + @pytest.mark.asyncio + async def test_camb_transcribe_with_file(self): + h, client = self._helpers_with_mock_client() + + task_result = SimpleNamespace(task_id="t-1") + status_result = SimpleNamespace(status="completed", run_id="r-1") + transcription_result = SimpleNamespace( + text="Hello", + segments=[ + SimpleNamespace(start=0.0, end=1.0, text="Hello", speaker="A") + ], + speakers=["A"], + ) + + client.transcription.create_transcription = mock.AsyncMock( + return_value=task_result + ) + client.transcription.get_transcription_task_status = mock.AsyncMock( + return_value=status_result + ) + client.transcription.get_transcription_result = mock.AsyncMock( + return_value=transcription_result + ) + + # Create a temp audio file + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(b"fake audio") + tmp_path = tmp.name + + try: + func = make_transcribe_func(h) + result = await func(language=1, audio_file_path=tmp_path) + parsed = json.loads(result) + assert parsed["text"] == "Hello" + assert len(parsed["segments"]) == 1 + finally: + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_camb_transcribe_no_input(self): + h, _client = self._helpers_with_mock_client() + func = make_transcribe_func(h) + with pytest.raises( + ValueError, match="Provide either audio_url or audio_file_path" + ): + await func(language=1) + + @pytest.mark.asyncio + async def test_camb_clone_voice(self): + h, client = self._helpers_with_mock_client() + + clone_result = SimpleNamespace(voice_id=42, message="Voice created") + client.voice_cloning.create_custom_voice = mock.AsyncMock( + return_value=clone_result + ) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(b"fake audio sample") + tmp_path = tmp.name + + try: + func = make_clone_voice_func(h) + result = await func( + voice_name="TestVoice", + audio_file_path=tmp_path, + gender=1, + description="A test voice", + ) + parsed = json.loads(result) + assert parsed["voice_id"] == 42 + assert parsed["voice_name"] == "TestVoice" + assert parsed["status"] == "created" + finally: + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_camb_list_voices(self): + h, client = self._helpers_with_mock_client() + + voices = [ + SimpleNamespace( + id=1, voice_name="Voice1", gender=1, age=30, language=1 + ), + SimpleNamespace( + id=2, voice_name="Voice2", gender=2, age=25, language=2 + ), + ] + client.voice_cloning.list_voices = mock.AsyncMock(return_value=voices) + + func = make_list_voices_func(h) + result = await func() + parsed = json.loads(result) + assert len(parsed) == 2 + assert parsed[0]["name"] == "Voice1" + assert parsed[1]["name"] == "Voice2" + + @pytest.mark.asyncio + async def test_camb_text_to_sound(self): + h, client = self._helpers_with_mock_client() + audio_data = b"RIFF" + b"\x00" * 50 + + task_result = SimpleNamespace(task_id="t-1") + status_result = SimpleNamespace(status="completed", run_id="r-1") + + client.text_to_audio.create_text_to_audio = mock.AsyncMock( + return_value=task_result + ) + client.text_to_audio.get_text_to_audio_status = mock.AsyncMock( + return_value=status_result + ) + + async def mock_get_result(run_id): + yield audio_data + + client.text_to_audio.get_text_to_audio_result = mock_get_result + + func = make_text_to_sound_func(h) + result = await func(prompt="birds chirping") + assert result.endswith(".wav") + with open(result, "rb") as f: + assert f.read() == audio_data + os.unlink(result) + + @pytest.mark.asyncio + async def test_camb_audio_separation_with_file(self): + h, client = self._helpers_with_mock_client() + + task_result = SimpleNamespace(task_id="t-1") + status_result = SimpleNamespace(status="completed", run_id="r-1") + sep_result = SimpleNamespace( + vocals_url="https://example.com/vocals.wav", + background_url="https://example.com/bg.wav", + ) + + client.audio_separation.create_audio_separation = mock.AsyncMock( + return_value=task_result + ) + client.audio_separation.get_audio_separation_status = mock.AsyncMock( + return_value=status_result + ) + client.audio_separation.get_audio_separation_run_info = mock.AsyncMock( + return_value=sep_result + ) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(b"fake audio") + tmp_path = tmp.name + + try: + func = make_audio_separation_func(h) + result = await func(audio_file_path=tmp_path) + parsed = json.loads(result) + assert parsed["vocals"] == "https://example.com/vocals.wav" + assert parsed["background"] == "https://example.com/bg.wav" + assert parsed["status"] == "completed" + finally: + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_camb_audio_separation_no_input(self): + h, _client = self._helpers_with_mock_client() + func = make_audio_separation_func(h) + with pytest.raises( + ValueError, match="Provide either audio_url or audio_file_path" + ): + await func() + + @pytest.mark.asyncio + async def test_camb_transcribe_url_cleans_up_temp_file(self): + h, client = self._helpers_with_mock_client() + + task_result = SimpleNamespace(task_id="t-1") + status_result = SimpleNamespace(status="completed", run_id="r-1") + transcription_result = SimpleNamespace( + text="Hello", segments=[], speakers=[] + ) + + client.transcription.create_transcription = mock.AsyncMock( + return_value=task_result + ) + client.transcription.get_transcription_task_status = mock.AsyncMock( + return_value=status_result + ) + client.transcription.get_transcription_result = mock.AsyncMock( + return_value=transcription_result + ) + + mock_resp = mock.MagicMock() + mock_resp.content = b"fake audio data" + mock_resp.raise_for_status = mock.MagicMock() + + with ( + mock.patch("google.adk.tools.camb._tools.os.unlink") as mock_unlink, + mock.patch("httpx.AsyncClient") as mock_httpx, + ): + mock_http_instance = mock.AsyncMock() + mock_http_instance.get = mock.AsyncMock(return_value=mock_resp) + mock_http_instance.__aenter__ = mock.AsyncMock( + return_value=mock_http_instance + ) + mock_http_instance.__aexit__ = mock.AsyncMock(return_value=False) + mock_httpx.return_value = mock_http_instance + + func = make_transcribe_func(h) + await func(language=1, audio_url="https://example.com/audio.wav") + + mock_unlink.assert_called_once() + + @pytest.mark.asyncio + async def test_camb_audio_separation_url_cleans_up_temp_file(self): + h, client = self._helpers_with_mock_client() + + task_result = SimpleNamespace(task_id="t-1") + status_result = SimpleNamespace(status="completed", run_id="r-1") + sep_result = SimpleNamespace( + vocals_url="https://example.com/vocals.wav", + background_url="https://example.com/bg.wav", + ) + + client.audio_separation.create_audio_separation = mock.AsyncMock( + return_value=task_result + ) + client.audio_separation.get_audio_separation_status = mock.AsyncMock( + return_value=status_result + ) + client.audio_separation.get_audio_separation_run_info = mock.AsyncMock( + return_value=sep_result + ) + + mock_resp = mock.MagicMock() + mock_resp.content = b"fake audio data" + mock_resp.raise_for_status = mock.MagicMock() + + with ( + mock.patch("google.adk.tools.camb._tools.os.unlink") as mock_unlink, + mock.patch("httpx.AsyncClient") as mock_httpx, + ): + mock_http_instance = mock.AsyncMock() + mock_http_instance.get = mock.AsyncMock(return_value=mock_resp) + mock_http_instance.__aenter__ = mock.AsyncMock( + return_value=mock_http_instance + ) + mock_http_instance.__aexit__ = mock.AsyncMock(return_value=False) + mock_httpx.return_value = mock_http_instance + + func = make_audio_separation_func(h) + await func(audio_url="https://example.com/audio.wav") + + mock_unlink.assert_called_once() + + +# --------------------------------------------------------------------------- +# FunctionTool wrapping tests +# --------------------------------------------------------------------------- + + +class TestFunctionToolWrapping: + """Verify that tool functions are correctly wrapped by FunctionTool.""" + + def test_tts_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_tts_func(h)) + assert ft.name == "camb_tts" + + def test_translate_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_translate_func(h)) + assert ft.name == "camb_translate" + + def test_transcribe_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_transcribe_func(h)) + assert ft.name == "camb_transcribe" + + def test_translated_tts_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_translated_tts_func(h)) + assert ft.name == "camb_translated_tts" + + def test_clone_voice_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_clone_voice_func(h)) + assert ft.name == "camb_clone_voice" + + def test_list_voices_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_list_voices_func(h)) + assert ft.name == "camb_list_voices" + + def test_text_to_sound_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_text_to_sound_func(h)) + assert ft.name == "camb_text_to_sound" + + def test_audio_separation_function_tool_name(self): + h = _make_helpers() + ft = FunctionTool(make_audio_separation_func(h)) + assert ft.name == "camb_audio_separation" + + def test_tts_has_docstring(self): + h = _make_helpers() + ft = FunctionTool(make_tts_func(h)) + assert ft.description + assert "text to speech" in ft.description.lower() + + def test_function_tool_generates_declaration(self): + h = _make_helpers() + ft = FunctionTool(make_tts_func(h)) + decl = ft._get_declaration() + assert decl is not None + assert decl.name == "camb_tts"