From 21f5345dc20ee654fe92206ee1537029b08d5824 Mon Sep 17 00:00:00 2001 From: Sandeep Belgavi Date: Tue, 4 Aug 2026 23:43:53 +0530 Subject: [PATCH] feat: Add TTS/STT voice agent system with routing and resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete voice-enabled agent system to ADK-Java: Voice Agent Core: - VoiceAgent orchestrator (BaseAgent subclass) with STT → classify → route → TTS pipeline - VoiceMode enum: TEXT_ONLY, VOICE_NAVIGATION, VOICE_FULL, AUTO - VoiceConfig builder for wiring STT/TTS/LLM endpoints - IntentClassifier with keyword, LLM, and hybrid strategies - VoiceNavigationHandler for instant command responses TTS Service: - TtsService interface (sync, async, streaming) - TtsConfig, TtsAudioFormat, TtsResult, TtsException - OpenAiCompatibleTtsService (works with Piper, Kokoro, AllTalk) - TtsCapabilities for server format negotiation - TtsServiceFactory with caching STT Service: - OllamaWhisperSttService (OpenAI-compatible /v1/audio/transcriptions) - StreamingWhisperSttService (WebSocket real-time streaming) - Added OLLAMA_WHISPER to ServiceType Resilience: - RetryPolicy with exponential backoff - CircuitBreaker (CLOSED/OPEN/HALF_OPEN states) - ResilientService composing both Metrics: - VoiceMetrics singleton (STT/TTS call tracking, latency, success rates) - VoiceMetricsSnapshot for point-in-time stats - VoiceMetricsInterceptor decorator Sample: - LiveAndTtsVoiceAgent: Dev UI with Gemini Live bidi + TTS routing agents - VoiceAgentDemo: CLI interactive demo Tests: 127 tests, all passing --- contrib/samples/pom.xml | 1 + .../LiveAndTtsVoiceAgent.java | 275 ++++++++ contrib/samples/voice-agent-demo/README.md | 81 +++ .../voice-agent-demo/VoiceAgentDemo.java | 242 +++++++ contrib/samples/voice-agent-demo/pom.xml | 124 ++++ core/pom.xml | 3 +- .../google/adk/agents/IntentClassifier.java | 283 +++++++++ .../com/google/adk/agents/VoiceAgent.java | 544 ++++++++++++++++ .../com/google/adk/agents/VoiceConfig.java | 378 +++++++++++ .../java/com/google/adk/agents/VoiceMode.java | 52 ++ .../adk/agents/VoiceNavigationHandler.java | 112 ++++ .../google/adk/transcription/ServiceType.java | 5 +- .../transcription/metrics/VoiceMetrics.java | 199 ++++++ .../metrics/VoiceMetricsInterceptor.java | 231 +++++++ .../metrics/VoiceMetricsSnapshot.java | 224 +++++++ .../resilience/CircuitBreaker.java | 292 +++++++++ .../CircuitBreakerOpenException.java | 52 ++ .../resilience/ResilientService.java | 154 +++++ .../transcription/resilience/RetryPolicy.java | 224 +++++++ .../strategy/OllamaWhisperSttService.java | 435 +++++++++++++ .../strategy/StreamingWhisperSttService.java | 592 ++++++++++++++++++ .../strategy/TranscriptionServiceFactory.java | 26 + .../tts/OpenAiCompatibleTtsService.java | 579 +++++++++++++++++ .../adk/transcription/tts/TtsAudioFormat.java | 74 +++ .../transcription/tts/TtsCapabilities.java | 201 ++++++ .../adk/transcription/tts/TtsConfig.java | 159 +++++ .../adk/transcription/tts/TtsException.java | 57 ++ .../adk/transcription/tts/TtsResult.java | 126 ++++ .../adk/transcription/tts/TtsService.java | 90 +++ .../transcription/tts/TtsServiceFactory.java | 210 +++++++ .../adk/agents/IntentClassifierLlmTest.java | 221 +++++++ .../adk/agents/IntentClassifierTest.java | 175 ++++++ .../google/adk/agents/VoiceConfigTest.java | 157 +++++ .../agents/VoiceNavigationHandlerTest.java | 199 ++++++ .../metrics/VoiceMetricsTest.java | 219 +++++++ .../resilience/CircuitBreakerTest.java | 362 +++++++++++ .../resilience/ResilientServiceTest.java | 137 ++++ .../resilience/RetryPolicyTest.java | 235 +++++++ .../tts/OpenAiCompatibleTtsServiceTest.java | 325 ++++++++++ .../tts/TtsCapabilitiesTest.java | 133 ++++ .../adk/transcription/tts/TtsConfigTest.java | 128 ++++ 41 files changed, 8313 insertions(+), 3 deletions(-) create mode 100644 contrib/samples/voice-agent-demo/LiveAndTtsVoiceAgent.java create mode 100644 contrib/samples/voice-agent-demo/README.md create mode 100644 contrib/samples/voice-agent-demo/VoiceAgentDemo.java create mode 100644 contrib/samples/voice-agent-demo/pom.xml create mode 100644 core/src/main/java/com/google/adk/agents/IntentClassifier.java create mode 100644 core/src/main/java/com/google/adk/agents/VoiceAgent.java create mode 100644 core/src/main/java/com/google/adk/agents/VoiceConfig.java create mode 100644 core/src/main/java/com/google/adk/agents/VoiceMode.java create mode 100644 core/src/main/java/com/google/adk/agents/VoiceNavigationHandler.java create mode 100644 core/src/main/java/com/google/adk/transcription/metrics/VoiceMetrics.java create mode 100644 core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsInterceptor.java create mode 100644 core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsSnapshot.java create mode 100644 core/src/main/java/com/google/adk/transcription/resilience/CircuitBreaker.java create mode 100644 core/src/main/java/com/google/adk/transcription/resilience/CircuitBreakerOpenException.java create mode 100644 core/src/main/java/com/google/adk/transcription/resilience/ResilientService.java create mode 100644 core/src/main/java/com/google/adk/transcription/resilience/RetryPolicy.java create mode 100644 core/src/main/java/com/google/adk/transcription/strategy/OllamaWhisperSttService.java create mode 100644 core/src/main/java/com/google/adk/transcription/strategy/StreamingWhisperSttService.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsService.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsAudioFormat.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsCapabilities.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsConfig.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsException.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsResult.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsService.java create mode 100644 core/src/main/java/com/google/adk/transcription/tts/TtsServiceFactory.java create mode 100644 core/src/test/java/com/google/adk/agents/IntentClassifierLlmTest.java create mode 100644 core/src/test/java/com/google/adk/agents/IntentClassifierTest.java create mode 100644 core/src/test/java/com/google/adk/agents/VoiceConfigTest.java create mode 100644 core/src/test/java/com/google/adk/agents/VoiceNavigationHandlerTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/metrics/VoiceMetricsTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/resilience/CircuitBreakerTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/resilience/ResilientServiceTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/resilience/RetryPolicyTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsServiceTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/tts/TtsCapabilitiesTest.java create mode 100644 core/src/test/java/com/google/adk/transcription/tts/TtsConfigTest.java diff --git a/contrib/samples/pom.xml b/contrib/samples/pom.xml index 514ad2b92..8dd9704a7 100644 --- a/contrib/samples/pom.xml +++ b/contrib/samples/pom.xml @@ -27,5 +27,6 @@ github/adktriaging helloworld mcpfilesystem + voice-agent-demo diff --git a/contrib/samples/voice-agent-demo/LiveAndTtsVoiceAgent.java b/contrib/samples/voice-agent-demo/LiveAndTtsVoiceAgent.java new file mode 100644 index 000000000..947e90877 --- /dev/null +++ b/contrib/samples/voice-agent-demo/LiveAndTtsVoiceAgent.java @@ -0,0 +1,275 @@ +/* + * Copyright 2025 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. + */ + +package com.example.voiceagentdemo; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.IntentClassifier; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.VoiceAgent; +import com.google.adk.agents.VoiceConfig; +import com.google.adk.agents.VoiceMode; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.FunctionTool; +import com.google.adk.web.AdkWebServer; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Combined Live Audio + TTS Voice Agent demo. + * + *

This example registers TWO agents with the ADK Dev UI: + * + *

    + *
  1. live_voice_agent — Uses Gemini Live model for real-time bidirectional audio + * (mic → Gemini → speaker directly). This is the native live streaming approach. + *
  2. tts_voice_agent — Uses the VoiceAgent with standard Gemini + TTS/STT routing. + * Navigation commands are handled instantly; complex queries go to Gemini and the text + * response is sent back (with TTS audio if a TTS server is configured). + *
+ * + *

How to run:

+ * + *
+ * export GOOGLE_API_KEY=your-gemini-api-key
+ * cd adk-java
+ * ./mvnw install -pl core,dev -DskipTests -q
+ * ./mvnw compile exec:java -pl contrib/samples/voice-agent-demo -Dexec.mainClass=com.example.voiceagentdemo.LiveAndTtsVoiceAgent
+ * 
+ * + *

Then open http://localhost:8080 in your browser. You'll see both agents in the Dev UI dropdown. + * + *

+ * + *

Optional: Add local TTS/STT servers

+ * + *
+ * # STT server (Whisper)
+ * docker run -p 8000:8000 fedirz/faster-whisper-server
+ * export ADK_STT_ENDPOINT=http://localhost:8000
+ *
+ * # TTS server (Piper/AllTalk)
+ * docker run -p 8001:8001 rhasspy/piper-http
+ * export ADK_TTS_ENDPOINT=http://localhost:8001
+ * 
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class LiveAndTtsVoiceAgent { + + // ============================================================ + // Agent 1: LIVE VOICE (Gemini Live model — real-time bidi audio) + // ============================================================ + + /** + * Live agent using gemini-2.5-flash-live-001 for real-time bidirectional audio streaming. The Dev + * UI mic button sends audio → Gemini processes → audio response streams back directly. + */ + public static final BaseAgent LIVE_VOICE_AGENT = + LlmAgent.builder() + .name("live_voice_agent") + .model("gemini-2.5-flash-live-001") + .description( + "Real-time voice agent using Gemini Live. Click the mic button to talk and" + + " hear responses in real-time.") + .instruction( + """ + You are a friendly, conversational voice assistant. You speak naturally and concisely. + + You can help with: + - Answering questions on any topic + - Weather information (use the getWeather tool) + - Current time (use the getCurrentTime tool) + - General knowledge, trivia, and explanations + - Creative tasks like stories, jokes, and poems + + Keep responses SHORT (1-3 sentences) since this is voice conversation. + Be warm, natural, and engaging. Use contractions like "I'm", "it's", "don't". + If you don't know something, say so honestly. + """) + .tools( + FunctionTool.create(LiveAndTtsVoiceAgent.class, "getWeather"), + FunctionTool.create(LiveAndTtsVoiceAgent.class, "getCurrentTime")) + .build(); + + // ============================================================ + // Agent 2: TTS VOICE (Standard Gemini + VoiceAgent routing) + // ============================================================ + + /** + * TTS-routed agent using standard Gemini model with the VoiceAgent orchestrator. Navigation + * commands are handled instantly. Complex queries go to Gemini and responses can optionally be + * synthesized to audio via a local TTS server. + */ + public static final BaseAgent TTS_VOICE_AGENT = buildTtsVoiceAgent(); + + private static BaseAgent buildTtsVoiceAgent() { + // Inner reasoning agent (standard Gemini, not live) + LlmAgent reasoningAgent = + LlmAgent.builder() + .name("gemini_reasoner") + .description("Gemini-powered reasoning agent for complex queries.") + .model("gemini-2.5-flash") + .instruction( + """ + You are a helpful voice assistant. Keep responses concise and conversational + (1-3 sentences) since they may be spoken aloud via TTS. + + You can help with: + - Answering questions on any topic + - Weather information (use the getWeather tool) + - Current time (use the getCurrentTime tool) + - General knowledge and explanations + - Creative tasks + + Respond naturally as if speaking to someone. + """) + .tools( + FunctionTool.create(LiveAndTtsVoiceAgent.class, "getWeather"), + FunctionTool.create(LiveAndTtsVoiceAgent.class, "getCurrentTime")) + .build(); + + // Navigation commands (handled without LLM call) + Map navCommands = new HashMap<>(); + navCommands.put("help", + "I can answer questions, check weather, tell the time, or just chat. " + + "Say 'next' to continue, 'back' to go back, or 'stop' to end."); + navCommands.put("next", "Moving forward. What would you like to know?"); + navCommands.put("back", "Going back. What would you like to revisit?"); + navCommands.put("stop", "Stopping. Goodbye!"); + navCommands.put("repeat", "Let me repeat that for you."); + navCommands.put("pause", "Paused. Say something when you're ready."); + navCommands.put("hello", "Hello! How can I help you today?"); + navCommands.put("hi", "Hi there! What can I do for you?"); + navCommands.put("thanks", "You're welcome! Anything else I can help with?"); + navCommands.put("thank you", "Happy to help! Need anything else?"); + + // Voice configuration + String sttEndpoint = System.getenv("ADK_STT_ENDPOINT"); + String ttsEndpoint = System.getenv("ADK_TTS_ENDPOINT"); + + VoiceConfig voiceConfig = + VoiceConfig.builder() + .voiceMode(VoiceMode.AUTO) + .language("en") + .sttEndpoint(sttEndpoint != null ? sttEndpoint : "") + .ttsEndpoint(ttsEndpoint != null ? ttsEndpoint : "") + .ttsVoice("alloy") + .ttsModel("tts-1") + .llmModel("gemini-2.5-flash") + .navigationCommands( + List.of("help", "next", "back", "stop", "repeat", "pause", + "hello", "hi", "thanks", "thank you")) + .build(); + + // Build VoiceAgent + return VoiceAgent.builder() + .name("tts_voice_agent") + .description( + "Voice agent with TTS routing. Navigation commands (help, next, back, stop) " + + "are instant. Complex queries go to Gemini. " + + "Set ADK_TTS_ENDPOINT for audio output.") + .voiceConfig(voiceConfig) + .delegate(reasoningAgent) + .intentClassifier(IntentClassifier.keyword(voiceConfig.getNavigationCommands())) + .navigationCommands(navCommands) + .build(); + } + + // ============================================================ + // Shared Tools (used by both agents) + // ============================================================ + + /** Gets weather for a location (mock data for demo). */ + public static Map getWeather( + @Schema(name = "location", description = "City name to get weather for") String location) { + + Map> weatherData = + Map.of( + "new york", Map.of("temp", "72°F (22°C)", "condition", "Partly cloudy", + "summary", "New York is partly cloudy at 72°F."), + "london", Map.of("temp", "59°F (15°C)", "condition", "Rainy", + "summary", "London is rainy at 59°F. Bring an umbrella!"), + "tokyo", Map.of("temp", "68°F (20°C)", "condition", "Clear", + "summary", "Tokyo is clear and pleasant at 68°F."), + "mumbai", Map.of("temp", "88°F (31°C)", "condition", "Humid", + "summary", "Mumbai is hot and humid at 88°F."), + "bangalore", Map.of("temp", "75°F (24°C)", "condition", "Partly cloudy", + "summary", "Bangalore is mild at 75°F with some clouds."), + "san francisco", Map.of("temp", "65°F (18°C)", "condition", "Foggy", + "summary", "San Francisco is foggy at 65°F. Classic!"), + "paris", Map.of("temp", "70°F (21°C)", "condition", "Sunny", + "summary", "Paris is sunny and beautiful at 70°F."), + "sydney", Map.of("temp", "77°F (25°C)", "condition", "Sunny", + "summary", "Sydney is sunny at 77°F. Great day!")); + + String key = location.toLowerCase().trim(); + Map data = weatherData.get(key); + + if (data != null) { + return data; + } + return Map.of( + "temp", "N/A", + "condition", "Unknown", + "summary", "I don't have weather data for " + location + + ". Try: New York, London, Tokyo, Mumbai, Bangalore, San Francisco, Paris, or Sydney."); + } + + /** Gets the current time. */ + public static Map getCurrentTime() { + LocalDateTime now = LocalDateTime.now(); + return Map.of( + "time", now.format(DateTimeFormatter.ofPattern("h:mm a")), + "date", now.format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")), + "summary", + "It's " + now.format(DateTimeFormatter.ofPattern("h:mm a")) + + " on " + now.format(DateTimeFormatter.ofPattern("EEEE, MMMM d"))); + } + + // ============================================================ + // Main — starts the ADK Dev UI with both agents + // ============================================================ + + public static void main(String[] args) { + System.out.println("╔══════════════════════════════════════════════════════════════╗"); + System.out.println("║ Live Audio + TTS Voice Agent Demo (ADK-Java) ║"); + System.out.println("╠══════════════════════════════════════════════════════════════╣"); + System.out.println("║ Starting Dev UI at http://localhost:8080 ║"); + System.out.println("║ ║"); + System.out.println("║ Agents available: ║"); + System.out.println("║ 1. live_voice_agent → Gemini Live bidi audio (use mic) ║"); + System.out.println("║ 2. tts_voice_agent → Gemini + TTS routing (type/speak) ║"); + System.out.println("║ ║"); + System.out.println("║ For full TTS, also set: ║"); + System.out.println("║ export ADK_TTS_ENDPOINT=http://localhost:8001 ║"); + System.out.println("║ export ADK_STT_ENDPOINT=http://localhost:8000 ║"); + System.out.println("╚══════════════════════════════════════════════════════════════╝"); + + // Start the Dev UI with both agents + // The Dev UI will show them in a dropdown — user can switch between them + AdkWebServer.start(LIVE_VOICE_AGENT, TTS_VOICE_AGENT); + } +} diff --git a/contrib/samples/voice-agent-demo/README.md b/contrib/samples/voice-agent-demo/README.md new file mode 100644 index 000000000..736d7282e --- /dev/null +++ b/contrib/samples/voice-agent-demo/README.md @@ -0,0 +1,81 @@ +# Voice Agent Demo (Gemini + ADK-Java) + +End-to-end demo of the VoiceAgent system showing: +- **Navigation commands** handled instantly (no LLM call) — `help`, `next`, `back`, `stop`, `repeat`, `pause` +- **Complex queries** routed to Gemini for full reasoning + +## Prerequisites + +1. A Gemini API key from [AI Studio](https://aistudio.google.com/apikey) +2. Java 17+ + +## Quick Start + +```bash +# 1. Set your API key +export GOOGLE_API_KEY=your-gemini-api-key + +# 2. Build the core module first (since we have local changes) +cd /path/to/adk-java +./mvnw install -pl core -DskipTests -q + +# 3. Run the demo (auto mode — shows routing in action) +./mvnw compile exec:java -pl contrib/samples/voice-agent-demo + +# 4. Or run interactive chat mode +./mvnw compile exec:java -pl contrib/samples/voice-agent-demo -Dexec.args="--interactive" +``` + +## What Happens + +``` +--- Navigation Commands (handled instantly, no LLM call) --- +You> help +Assistant> Available commands: say 'next' to continue, 'back' to go back, ... + +You> next +Assistant> Moving to the next item. What would you like to know? + +--- Complex Queries (routed to Gemini) --- +You> What is the speed of light? +Assistant> The speed of light is approximately 299,792,458 meters per second... +``` + +## Architecture + +``` +User Input (text) + ↓ +IntentClassifier (keyword matching) + ↓ +┌─────────────────────────────────────┐ +│ VOICE_NAVIGATION? │ → VoiceNavigationHandler → instant response +│ VOICE_FULL? │ → Gemini LLM → response (+ TTS if configured) +└─────────────────────────────────────┘ +``` + +## Adding TTS/STT (Optional) + +To add actual voice I/O, set these environment variables: + +```bash +# STT - any OpenAI-compatible /v1/audio/transcriptions endpoint +export ADK_STT_ENDPOINT=http://localhost:8000 + +# TTS - any OpenAI-compatible /v1/audio/speech endpoint +export ADK_TTS_ENDPOINT=http://localhost:8001 +``` + +Local server options: +- **STT**: `docker run -p 8000:8000 fedirz/faster-whisper-server` +- **TTS**: Piper, AllTalk, or Kokoro with OpenAI-compatible API + +Then update `VoiceConfig` in the demo to include the endpoints: +```java +VoiceConfig voiceConfig = VoiceConfig.builder() + .voiceMode(VoiceMode.AUTO) + .sttEndpoint("http://localhost:8000") + .ttsEndpoint("http://localhost:8001") + .ttsVoice("alloy") + .build(); +``` diff --git a/contrib/samples/voice-agent-demo/VoiceAgentDemo.java b/contrib/samples/voice-agent-demo/VoiceAgentDemo.java new file mode 100644 index 000000000..dae1691f7 --- /dev/null +++ b/contrib/samples/voice-agent-demo/VoiceAgentDemo.java @@ -0,0 +1,242 @@ +/* + * Copyright 2025 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. + */ + +package com.example.voiceagentdemo; + +import com.google.adk.agents.IntentClassifier; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.agents.VoiceAgent; +import com.google.adk.agents.VoiceConfig; +import com.google.adk.agents.VoiceMode; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.events.Event; +import com.google.adk.memory.InMemoryMemoryService; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.InMemorySessionService; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * End-to-end demo of the VoiceAgent system using Gemini as the LLM backend. + * + *

This demo runs in TEXT mode (simulating voice) so you don't need actual TTS/STT servers. It + * demonstrates the full routing logic: + * + *

    + *
  • Navigation commands (next, back, help, stop, repeat) → handled instantly without LLM + *
  • Complex queries → routed to Gemini for full reasoning + *
+ * + *

How to run:

+ * + *
+ * export GOOGLE_API_KEY=your-gemini-api-key
+ * cd contrib/samples/voice-agent-demo
+ * ../../../mvnw compile exec:java -pl contrib/samples/voice-agent-demo
+ * 
+ * + *

Or for interactive mode: + * + *

+ * ../../../mvnw compile exec:java -pl contrib/samples/voice-agent-demo -Dexec.args="--interactive"
+ * 
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class VoiceAgentDemo { + + private final Runner runner; + private final String userId; + private final String sessionId; + + private VoiceAgentDemo() { + String appName = "voice-agent-demo"; + this.userId = "demo-user"; + this.sessionId = UUID.randomUUID().toString(); + + // 1. Build the inner Gemini-based reasoning agent + LlmAgent reasoningAgent = + LlmAgent.builder() + .name("gemini_reasoner") + .description("A Gemini-powered reasoning agent for complex queries.") + .model("gemini-2.5-flash") + .instruction( + """ + You are a helpful voice assistant. Keep your responses concise and conversational + since they will be spoken aloud. Aim for 1-3 sentences unless the user asks for + detailed information. + + You can help with: + - Answering questions on any topic + - Providing explanations + - Creative tasks (stories, poems, ideas) + - General knowledge and trivia + - Simple calculations and reasoning + + Always respond naturally as if you're speaking to someone. + """) + .build(); + + // 2. Define navigation commands (handled without hitting the LLM) + Map navCommands = new HashMap<>(); + navCommands.put("help", "Available commands: say 'next' to continue, 'back' to go back, " + + "'stop' to end, 'repeat' to hear the last response again, or ask me anything."); + navCommands.put("next", "Moving to the next item. What would you like to know?"); + navCommands.put("back", "Going back. What would you like to revisit?"); + navCommands.put("stop", "Stopping. Goodbye!"); + navCommands.put("repeat", "I'll repeat my last response for you."); + navCommands.put("pause", "Paused. Say 'next' or ask me something when you're ready."); + navCommands.put("hello", "Hello! How can I help you today?"); + navCommands.put("hi", "Hi there! What can I do for you?"); + + // 3. Configure voice settings + // In a real setup, you'd set sttEndpoint and ttsEndpoint to local servers. + // For this demo, we run without them (text-only mode with routing logic). + VoiceConfig voiceConfig = + VoiceConfig.builder() + .voiceMode(VoiceMode.AUTO) // Automatically classify input + .language("en") + .llmModel("gemini-2.5-flash") + .navigationCommands(List.of("help", "next", "back", "stop", "repeat", "pause")) + .build(); + + // 4. Build the VoiceAgent + VoiceAgent voiceAgent = + VoiceAgent.builder() + .name("voice_assistant") + .description("Voice-enabled assistant with navigation and full Gemini reasoning") + .voiceConfig(voiceConfig) + .delegate(reasoningAgent) + .intentClassifier(IntentClassifier.keyword(voiceConfig.getNavigationCommands())) + .navigationCommands(navCommands) + .build(); + + // 5. Create the runner + InMemorySessionService sessionService = new InMemorySessionService(); + this.runner = + new Runner( + voiceAgent, + appName, + new InMemoryArtifactService(), + sessionService, + new InMemoryMemoryService()); + + ConcurrentMap initialState = new ConcurrentHashMap<>(); + var unused = + sessionService.createSession(appName, userId, initialState, sessionId).blockingGet(); + } + + private void run(String prompt) { + System.out.println("\n\033[36mYou>\033[0m " + prompt); + + Content userMessage = + Content.builder() + .role("user") + .parts(ImmutableList.of(Part.builder().text(prompt).build())) + .build(); + + RunConfig runConfig = RunConfig.builder().build(); + Flowable eventStream = + this.runner.runAsync(this.userId, this.sessionId, userMessage, runConfig); + List agentEvents = Lists.newArrayList(eventStream.blockingIterable()); + + StringBuilder sb = new StringBuilder(); + for (Event event : agentEvents) { + String content = event.stringifyContent().stripTrailing(); + if (!content.isEmpty()) { + sb.append(content); + } + } + + String response = sb.toString().trim(); + if (!response.isEmpty()) { + System.out.println("\033[33mAssistant>\033[0m " + response); + } + } + + private void runInteractive() { + System.out.println("╔══════════════════════════════════════════════════════════════╗"); + System.out.println("║ Voice Agent Demo (Gemini + ADK-Java) ║"); + System.out.println("╠══════════════════════════════════════════════════════════════╣"); + System.out.println("║ Navigation commands: help, next, back, stop, repeat, pause ║"); + System.out.println("║ Complex queries: anything else goes to Gemini ║"); + System.out.println("║ Type 'quit' or 'exit' to end ║"); + System.out.println("╚══════════════════════════════════════════════════════════════╝"); + System.out.println(); + + Scanner scanner = new Scanner(System.in); + while (true) { + System.out.print("\033[36mYou>\033[0m "); + String input = scanner.nextLine().trim(); + if (input.isEmpty()) continue; + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + System.out.println("\033[33mAssistant>\033[0m Goodbye! 👋"); + break; + } + run(input); + } + scanner.close(); + } + + public static void main(String[] args) { + System.out.println("Initializing Voice Agent Demo..."); + + // Check for API key + String apiKey = System.getenv("GOOGLE_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + System.err.println( + "ERROR: GOOGLE_API_KEY environment variable is not set.\n" + + "Get one from https://aistudio.google.com/apikey\n" + + "Then run: export GOOGLE_API_KEY=your-key-here"); + System.exit(1); + } + + VoiceAgentDemo demo = new VoiceAgentDemo(); + + if (args.length > 0 && args[0].equals("--interactive")) { + demo.runInteractive(); + } else { + // Demo mode: run a few examples showing routing behavior + System.out.println("\n--- Navigation Commands (handled instantly, no LLM call) ---"); + demo.run("help"); + demo.run("next"); + demo.run("hello"); + + System.out.println("\n--- Complex Queries (routed to Gemini) ---"); + demo.run("What is the speed of light and why can nothing travel faster?"); + demo.run("Write me a haiku about Java programming"); + demo.run("What's 127 times 43?"); + + System.out.println("\n--- Mixed Usage ---"); + demo.run("stop"); + demo.run("Explain quantum entanglement in simple terms"); + + System.out.println("\n\nDone! Run with --interactive for a chat session."); + } + } +} diff --git a/contrib/samples/voice-agent-demo/pom.xml b/contrib/samples/voice-agent-demo/pom.xml new file mode 100644 index 000000000..f1d71377c --- /dev/null +++ b/contrib/samples/voice-agent-demo/pom.xml @@ -0,0 +1,124 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-samples + 1.7.1-SNAPSHOT + .. + + + com.google.adk.samples + google-adk-sample-voice-agent-demo + Google ADK - Sample - Voice Agent Demo + + End-to-end demo of VoiceAgent with Gemini: navigation commands handled instantly, + complex queries routed to Gemini LLM. Demonstrates TTS/STT routing architecture. + + jar + + + UTF-8 + 17 + com.example.voiceagentdemo.VoiceAgentDemo + ${project.version} + + + + + com.google.adk + google-adk + ${google-adk.version} + + + com.google.adk + google-adk-dev + ${google-adk.version} + + + ch.qos.logback + logback-classic + + + + + org.slf4j + slf4j-simple + 2.0.9 + + + commons-logging + commons-logging + 1.2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${java.version} + true + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-source + generate-sources + + add-source + + + + . + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + **/*.jar + target/** + + + + + org.codehaus.mojo + exec-maven-plugin + 3.2.0 + + ${exec.mainClass} + runtime + + + + + diff --git a/core/pom.xml b/core/pom.xml index c069739e8..3281c645d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -307,8 +307,7 @@ com.squareup.okhttp3 - mockwebserver - 4.12.0 + mockwebserver3-junit5 test diff --git a/core/src/main/java/com/google/adk/agents/IntentClassifier.java b/core/src/main/java/com/google/adk/agents/IntentClassifier.java new file mode 100644 index 000000000..def4c085f --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/IntentClassifier.java @@ -0,0 +1,283 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Classifies user input into a {@link VoiceMode} to determine how the voice agent should process + * the request. + * + *

Three strategies are provided: + * + *

    + *
  • Keyword: fast, rule-based classification using substring matching against known + * navigation keywords. + *
  • LLM: sends the input to a small language model with a classification prompt. + *
  • Hybrid: tries keyword matching first (fast path), then falls back to LLM + * classification if no keyword match is found. + *
+ * + *

Use the static factory methods {@link #keyword(List)}, {@link #llm(BaseLlm)}, and {@link + * #hybrid(List, BaseLlm)} to create instances. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public abstract class IntentClassifier { + + private static final Logger logger = LoggerFactory.getLogger(IntentClassifier.class); + + /** + * Classifies the user's text input into a {@link VoiceMode}. + * + * @param userText the transcribed or typed user input + * @param config the current voice configuration + * @return the classified voice mode (either {@link VoiceMode#VOICE_NAVIGATION} or {@link + * VoiceMode#VOICE_FULL}) + */ + public abstract VoiceMode classify(String userText, VoiceConfig config); + + /** + * Asynchronously classifies the user's text input into a {@link VoiceMode}. + * + * @param userText the transcribed or typed user input + * @param config the current voice configuration + * @return a Single emitting the classified voice mode + */ + public Single classifyAsync(String userText, VoiceConfig config) { + return Single.fromCallable(() -> classify(userText, config)).subscribeOn(Schedulers.io()); + } + + /** + * Creates a keyword-based intent classifier that matches input against known navigation + * keywords/phrases. + * + *

If the user's input (lowercased) contains any of the provided navigation keywords, it is + * classified as {@link VoiceMode#VOICE_NAVIGATION}. Otherwise, it is classified as {@link + * VoiceMode#VOICE_FULL}. + * + * @param navKeywords list of navigation command keywords (e.g., "go back", "next page", "scroll + * down") + * @return a keyword-based IntentClassifier + */ + public static IntentClassifier keyword(List navKeywords) { + return new KeywordClassifier(navKeywords); + } + + /** + * Creates an LLM-based intent classifier that uses a language model to classify input. + * + *

Sends the user input to the model with a classification prompt and interprets the response + * as either NAVIGATION or REASONING. + * + * @param model the BaseLlm model to use for classification (e.g., a small Ollama model) + * @return an LLM-based IntentClassifier + */ + public static IntentClassifier llm(BaseLlm model) { + return new LlmClassifier(model); + } + + /** + * Creates a hybrid intent classifier that first tries keyword matching (fast path), then falls + * back to LLM classification if no keyword match is found. + * + *

This provides the best of both worlds: instant classification for known navigation commands, + * and intelligent LLM-based classification for ambiguous inputs. + * + * @param navKeywords list of navigation command keywords for fast matching + * @param model the BaseLlm model to use as fallback for classification + * @return a hybrid IntentClassifier + */ + public static IntentClassifier hybrid(List navKeywords, BaseLlm model) { + return new HybridClassifier(navKeywords, model); + } + + // ---- Keyword-based classifier implementation ---- + + private static final class KeywordClassifier extends IntentClassifier { + + private final ImmutableList navKeywords; + + KeywordClassifier(List navKeywords) { + this.navKeywords = + navKeywords.stream().map(String::toLowerCase).collect(ImmutableList.toImmutableList()); + } + + @Override + public VoiceMode classify(String userText, VoiceConfig config) { + if (userText == null || userText.isEmpty()) { + return VoiceMode.VOICE_FULL; + } + + String normalizedInput = userText.toLowerCase().trim(); + + // Check against provided navigation keywords + for (String keyword : navKeywords) { + if (normalizedInput.contains(keyword)) { + logger.debug("Keyword match '{}' for input '{}' → VOICE_NAVIGATION", keyword, userText); + return VoiceMode.VOICE_NAVIGATION; + } + } + + // Also check against VoiceConfig navigation commands if available + ImmutableList configCommands = config.getNavigationCommands(); + for (String command : configCommands) { + if (normalizedInput.contains(command.toLowerCase())) { + logger.debug( + "Config command match '{}' for input '{}' → VOICE_NAVIGATION", command, userText); + return VoiceMode.VOICE_NAVIGATION; + } + } + + logger.debug("No keyword match for input '{}' → VOICE_FULL", userText); + return VoiceMode.VOICE_FULL; + } + } + + // ---- LLM-based classifier implementation ---- + + private static final class LlmClassifier extends IntentClassifier { + + private static final String CLASSIFICATION_PROMPT = + "Classify the following user input as either NAVIGATION (simple commands like go back, " + + "next, stop, help, repeat) or REASONING (complex questions needing detailed " + + "answers).\n\nUser input: '%s'\n\nRespond with ONLY one word: NAVIGATION or " + + "REASONING"; + + private final BaseLlm model; + + LlmClassifier(BaseLlm model) { + if (model == null) { + throw new IllegalArgumentException("Model cannot be null for LLM classifier"); + } + this.model = model; + } + + @Override + public VoiceMode classify(String userText, VoiceConfig config) { + if (userText == null || userText.isEmpty()) { + return VoiceMode.VOICE_FULL; + } + + try { + String prompt = String.format(CLASSIFICATION_PROMPT, userText); + Content promptContent = + Content.builder().role("user").parts(ImmutableList.of(Part.fromText(prompt))).build(); + + LlmRequest request = + LlmRequest.builder() + .model(model.model()) + .contents(ImmutableList.of(promptContent)) + .build(); + + // Use blocking call for sync classification + LlmResponse response = model.generateContent(request, false).blockingFirst(); + + String responseText = extractText(response); + if (responseText != null && responseText.trim().toUpperCase().contains("NAVIGATION")) { + logger.debug("LLM classified '{}' → VOICE_NAVIGATION", userText); + return VoiceMode.VOICE_NAVIGATION; + } + + logger.debug("LLM classified '{}' → VOICE_FULL", userText); + return VoiceMode.VOICE_FULL; + } catch (Exception e) { + logger.warn( + "LLM classification failed for '{}', defaulting to VOICE_FULL: {}", + userText, + e.getMessage()); + return VoiceMode.VOICE_FULL; + } + } + + @Override + public Single classifyAsync(String userText, VoiceConfig config) { + if (userText == null || userText.isEmpty()) { + return Single.just(VoiceMode.VOICE_FULL); + } + + return Single.fromCallable(() -> classify(userText, config)).subscribeOn(Schedulers.io()); + } + + private String extractText(LlmResponse response) { + if (response == null || response.content().isEmpty()) { + return null; + } + Content content = response.content().get(); + if (content.parts().isEmpty() || content.parts().get().isEmpty()) { + return null; + } + return content.parts().get().get(0).text().orElse(null); + } + } + + // ---- Hybrid classifier implementation ---- + + private static final class HybridClassifier extends IntentClassifier { + + private final KeywordClassifier keywordClassifier; + private final LlmClassifier llmClassifier; + + HybridClassifier(List navKeywords, BaseLlm model) { + this.keywordClassifier = new KeywordClassifier(navKeywords); + this.llmClassifier = new LlmClassifier(model); + } + + @Override + public VoiceMode classify(String userText, VoiceConfig config) { + // Fast path: try keyword matching first + VoiceMode keywordResult = keywordClassifier.classify(userText, config); + if (keywordResult == VoiceMode.VOICE_NAVIGATION) { + logger.debug("Hybrid: keyword match for '{}' → VOICE_NAVIGATION", userText); + return VoiceMode.VOICE_NAVIGATION; + } + + // Slow path: fall back to LLM classification + logger.debug("Hybrid: no keyword match for '{}', falling back to LLM", userText); + return llmClassifier.classify(userText, config); + } + + @Override + public Single classifyAsync(String userText, VoiceConfig config) { + if (userText == null || userText.isEmpty()) { + return Single.just(VoiceMode.VOICE_FULL); + } + + // Fast path: try keyword matching first (synchronous, cheap) + VoiceMode keywordResult = keywordClassifier.classify(userText, config); + if (keywordResult == VoiceMode.VOICE_NAVIGATION) { + logger.debug("Hybrid async: keyword match for '{}' → VOICE_NAVIGATION", userText); + return Single.just(VoiceMode.VOICE_NAVIGATION); + } + + // Slow path: fall back to LLM classification on IO scheduler + logger.debug("Hybrid async: no keyword match for '{}', falling back to LLM", userText); + return llmClassifier.classifyAsync(userText, config); + } + } +} diff --git a/core/src/main/java/com/google/adk/agents/VoiceAgent.java b/core/src/main/java/com/google/adk/agents/VoiceAgent.java new file mode 100644 index 000000000..d4068d481 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/VoiceAgent.java @@ -0,0 +1,544 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import com.google.adk.events.Event; +import com.google.adk.transcription.TranscriptionConfig; +import com.google.adk.transcription.metrics.VoiceMetrics; +import com.google.adk.transcription.strategy.OllamaWhisperSttService; +import com.google.adk.transcription.tts.OpenAiCompatibleTtsService; +import com.google.adk.transcription.tts.TtsConfig; +import com.google.adk.transcription.tts.TtsService; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Voice-enabled agent orchestrator that extends {@link BaseAgent} and wraps a delegate {@link + * LlmAgent} for full reasoning capabilities. + * + *

The VoiceAgent handles the complete voice interaction pipeline: + * + *

    + *
  1. Receives audio input (inline data) or text from the invocation context + *
  2. Transcribes audio via STT (OllamaWhisperSttService) + *
  3. Classifies intent using the configured {@link IntentClassifier} + *
  4. Routes to the appropriate handler: + *
      + *
    • {@link VoiceMode#VOICE_NAVIGATION}: fast path through {@link VoiceNavigationHandler} + *
    • {@link VoiceMode#VOICE_FULL}: delegates to the inner LlmAgent, then TTS + *
    • {@link VoiceMode#TEXT_ONLY}: delegates to the inner LlmAgent without TTS + *
    + *
  5. Synthesizes TTS audio for voice responses + *
  6. Emits events with audio content (Part.fromBytes) + *
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class VoiceAgent extends BaseAgent { + + private static final Logger logger = LoggerFactory.getLogger(VoiceAgent.class); + + private static final String AUDIO_MIME_PREFIX = "audio/"; + private static final String DEFAULT_AUDIO_MIME_TYPE = "audio/wav"; + + private final VoiceConfig voiceConfig; + private final LlmAgent delegate; + private final IntentClassifier intentClassifier; + private final VoiceNavigationHandler navigationHandler; + private final OllamaWhisperSttService sttService; + private final TtsService ttsService; + private final TtsConfig ttsConfig; + + /** + * Creates a VoiceAgent via the builder. + * + * @param builder the builder containing all configuration + */ + private VoiceAgent(Builder builder) { + super( + builder.name, + builder.description, + builder.subAgents, + builder.beforeAgentCallback, + builder.afterAgentCallback); + this.voiceConfig = builder.voiceConfig; + this.delegate = builder.delegate; + this.intentClassifier = builder.intentClassifier; + this.navigationHandler = builder.navigationHandler; + + // Initialize STT service + String sttEndpoint = voiceConfig.getSttEndpoint(); + if (sttEndpoint != null && !sttEndpoint.isEmpty()) { + this.sttService = new OllamaWhisperSttService(sttEndpoint, voiceConfig.getSttModel(), null); + } else { + this.sttService = null; + } + + // Initialize TTS service + String ttsEndpoint = voiceConfig.getTtsEndpoint(); + if (ttsEndpoint != null && !ttsEndpoint.isEmpty()) { + this.ttsService = new OpenAiCompatibleTtsService(ttsEndpoint, null); + this.ttsConfig = + TtsConfig.builder() + .endpoint(ttsEndpoint) + .voice(voiceConfig.getTtsVoice()) + .model(voiceConfig.getTtsModel()) + .language(voiceConfig.getLanguage()) + .speed(voiceConfig.getTtsSpeed()) + .build(); + } else { + this.ttsService = null; + this.ttsConfig = null; + } + + logger.info( + "VoiceAgent '{}' initialized with mode={}, delegate='{}'", + name(), + voiceConfig.getVoiceMode(), + delegate.name()); + } + + /** + * Creates a new builder for VoiceAgent. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** Returns the voice configuration. */ + public VoiceConfig voiceConfig() { + return voiceConfig; + } + + /** Returns the delegate LlmAgent. */ + public LlmAgent delegate() { + return delegate; + } + + /** Returns the intent classifier. */ + public IntentClassifier intentClassifier() { + return intentClassifier; + } + + @Override + protected Flowable runAsyncImpl(InvocationContext invocationContext) { + return Flowable.defer(() -> processInput(invocationContext)); + } + + @Override + protected Flowable runLiveImpl(InvocationContext invocationContext) { + // Live mode delegates to the same logic as async for now + return runAsyncImpl(invocationContext); + } + + /** + * Main processing pipeline: extract input → STT (if audio) → classify → route → TTS (if voice). + */ + private Flowable processInput(InvocationContext invocationContext) { + Optional userContentOpt = invocationContext.userContent(); + if (userContentOpt.isEmpty()) { + logger.debug("No user content in invocation context"); + return Flowable.empty(); + } + + Content userContent = userContentOpt.get(); + + // Extract text or audio from user content + Optional audioData = extractAudioData(userContent); + Optional textData = extractTextData(userContent); + + if (audioData.isPresent()) { + // Audio input path: STT → classify → route + return transcribeAndRoute(audioData.get(), invocationContext); + } else if (textData.isPresent()) { + // Text input path: classify → route (no STT needed) + return classifyAndRoute(textData.get(), invocationContext); + } else { + logger.debug("No audio or text content found in user content"); + return Flowable.empty(); + } + } + + /** Transcribes audio to text, then classifies and routes. */ + private Flowable transcribeAndRoute( + byte[] audioData, InvocationContext invocationContext) { + if (sttService == null) { + logger.warn("STT service not configured, cannot process audio input"); + return Flowable.empty(); + } + + TranscriptionConfig sttConfig = + TranscriptionConfig.builder() + .endpoint(voiceConfig.getSttEndpoint()) + .language(voiceConfig.getLanguage()) + .build(); + + long sttStartNanos = System.nanoTime(); + + return sttService + .transcribeAsync(audioData, sttConfig) + .doOnSuccess( + result -> { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - sttStartNanos); + VoiceMetrics.getInstance().recordSttCall(latencyMs, true); + }) + .doOnError( + error -> { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - sttStartNanos); + VoiceMetrics.getInstance().recordSttCall(latencyMs, false); + }) + .flatMapPublisher( + result -> { + String transcribedText = result.getText(); + logger.debug("STT transcription result: '{}'", transcribedText); + + if (transcribedText == null || transcribedText.isEmpty()) { + logger.debug("Empty transcription, returning empty"); + return Flowable.empty(); + } + + return classifyAndRoute(transcribedText, invocationContext); + }); + } + + /** Classifies intent and routes to the appropriate handler. */ + private Flowable classifyAndRoute(String userText, InvocationContext invocationContext) { + VoiceMode effectiveMode = resolveVoiceMode(userText); + + logger.debug("Resolved voice mode for '{}': {}", userText, effectiveMode); + + switch (effectiveMode) { + case VOICE_NAVIGATION: + return handleNavigation(userText, invocationContext); + case VOICE_FULL: + return handleFullVoice(userText, invocationContext); + case TEXT_ONLY: + return handleTextOnly(invocationContext); + default: + // AUTO should have been resolved already, fallback to VOICE_FULL + return handleFullVoice(userText, invocationContext); + } + } + + /** Resolves the effective voice mode, running intent classification for AUTO mode. */ + private VoiceMode resolveVoiceMode(String userText) { + VoiceMode configuredMode = voiceConfig.getVoiceMode(); + + if (configuredMode != VoiceMode.AUTO) { + return configuredMode; + } + + // AUTO mode: use the intent classifier to determine routing + long classifierStartNanos = System.nanoTime(); + VoiceMode result = intentClassifier.classify(userText, voiceConfig); + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - classifierStartNanos); + VoiceMetrics.getInstance().recordIntentClassification(latencyMs, result); + return result; + } + + /** Handles VOICE_NAVIGATION: lookup command → TTS → emit audio event. */ + private Flowable handleNavigation(String userText, InvocationContext invocationContext) { + Optional responseText = navigationHandler.handle(userText); + + if (responseText.isEmpty()) { + // No matching command found, fall through to full voice + logger.debug("No navigation match for '{}', falling through to VOICE_FULL", userText); + return handleFullVoice(userText, invocationContext); + } + + String response = responseText.get(); + logger.debug("Navigation response for '{}': '{}'", userText, response); + + // Synthesize TTS and emit audio event + return synthesizeAndEmit(response, invocationContext); + } + + /** Handles VOICE_FULL: delegate to LlmAgent → extract text → TTS → emit audio event. */ + private Flowable handleFullVoice(String userText, InvocationContext invocationContext) { + // Delegate to the inner LlmAgent + return delegate + .runAsync(invocationContext) + .toList() + .flatMapPublisher( + events -> { + // Extract text from the delegate's response events + String responseText = extractResponseText(events); + + if (responseText == null || responseText.isEmpty()) { + // No text response, just forward the events as-is + return Flowable.fromIterable(events); + } + + // Synthesize TTS audio and append to the last event + if (ttsService != null && ttsConfig != null) { + return Flowable.fromIterable(events) + .concatWith(synthesizeAndEmit(responseText, invocationContext)); + } else { + return Flowable.fromIterable(events); + } + }); + } + + /** Handles TEXT_ONLY: simply delegates to the inner LlmAgent without any TTS. */ + private Flowable handleTextOnly(InvocationContext invocationContext) { + return delegate.runAsync(invocationContext); + } + + /** Synthesizes text to audio and emits an event with the audio content. */ + private Flowable synthesizeAndEmit(String text, InvocationContext invocationContext) { + if (ttsService == null || ttsConfig == null) { + // No TTS available, emit text-only event + return emitTextEvent(text, invocationContext); + } + + long ttsStartNanos = System.nanoTime(); + + return ttsService + .synthesizeAsync(text, ttsConfig) + .doOnSuccess( + bytes -> { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ttsStartNanos); + VoiceMetrics.getInstance().recordTtsCall(latencyMs, true, text.length()); + }) + .doOnError( + error -> { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ttsStartNanos); + VoiceMetrics.getInstance().recordTtsCall(latencyMs, false, text.length()); + }) + .flatMapPublisher( + audioBytes -> { + logger.debug("TTS synthesized {} bytes for text: '{}'", audioBytes.length, text); + return emitAudioEvent(audioBytes, text, invocationContext); + }) + .onErrorResumeNext( + error -> { + logger.warn( + "TTS synthesis failed, falling back to text event: {}", error.getMessage()); + return emitTextEvent(text, invocationContext); + }); + } + + /** Creates and emits an event containing audio content. */ + private Flowable emitAudioEvent( + byte[] audioBytes, String text, InvocationContext invocationContext) { + Part audioPart = Part.fromBytes(audioBytes, DEFAULT_AUDIO_MIME_TYPE); + Part textPart = Part.fromText(text); + + Content content = + Content.builder().role("model").parts(ImmutableList.of(textPart, audioPart)).build(); + + Event event = + Event.builder() + .id(Event.generateEventId()) + .invocationId(invocationContext.invocationId()) + .author(name()) + .branch(invocationContext.branch().orElse(null)) + .content(content) + .turnComplete(true) + .build(); + + return Flowable.just(event); + } + + /** Creates and emits a text-only event (fallback when TTS is unavailable). */ + private Flowable emitTextEvent(String text, InvocationContext invocationContext) { + Content content = + Content.builder().role("model").parts(ImmutableList.of(Part.fromText(text))).build(); + + Event event = + Event.builder() + .id(Event.generateEventId()) + .invocationId(invocationContext.invocationId()) + .author(name()) + .branch(invocationContext.branch().orElse(null)) + .content(content) + .turnComplete(true) + .build(); + + return Flowable.just(event); + } + + // ---- Helper methods ---- + + /** Extracts audio byte data from user content, if any part contains inline audio data. */ + private Optional extractAudioData(Content content) { + if (content.parts().isEmpty() || content.parts().get().isEmpty()) { + return Optional.empty(); + } + + for (Part part : content.parts().get()) { + if (part.inlineData().isPresent()) { + Blob blob = part.inlineData().get(); + if (blob.mimeType().isPresent() + && blob.mimeType().get().startsWith(AUDIO_MIME_PREFIX) + && blob.data().isPresent()) { + return Optional.of(blob.data().get()); + } + } + } + + return Optional.empty(); + } + + /** Extracts text from user content. */ + private Optional extractTextData(Content content) { + if (content.parts().isEmpty() || content.parts().get().isEmpty()) { + return Optional.empty(); + } + + StringBuilder sb = new StringBuilder(); + for (Part part : content.parts().get()) { + part.text().ifPresent(sb::append); + } + + String text = sb.toString().trim(); + return text.isEmpty() ? Optional.empty() : Optional.of(text); + } + + /** Extracts the text response from a list of delegate events. */ + private String extractResponseText(List events) { + StringBuilder sb = new StringBuilder(); + for (Event event : events) { + event + .content() + .ifPresent( + content -> { + if (content.parts().isPresent()) { + for (Part part : content.parts().get()) { + part.text().ifPresent(sb::append); + } + } + }); + } + return sb.toString().trim(); + } + + // ---- Builder ---- + + /** Builder for {@link VoiceAgent}. */ + public static class Builder extends BaseAgent.Builder { + + private VoiceConfig voiceConfig; + private LlmAgent delegate; + private IntentClassifier intentClassifier; + private VoiceNavigationHandler navigationHandler = new VoiceNavigationHandler(); + private Map navigationCommands; + + @Override + protected Builder self() { + return this; + } + + /** + * Sets the voice configuration. + * + * @param voiceConfig the voice configuration + * @return this builder + */ + @CanIgnoreReturnValue + public Builder voiceConfig(VoiceConfig voiceConfig) { + this.voiceConfig = voiceConfig; + return this; + } + + /** + * Sets the delegate LlmAgent that handles full reasoning requests. + * + * @param delegate the LlmAgent to delegate to + * @return this builder + */ + @CanIgnoreReturnValue + public Builder delegate(LlmAgent delegate) { + this.delegate = delegate; + return this; + } + + /** + * Sets the intent classifier for routing decisions. + * + * @param intentClassifier the classifier instance + * @return this builder + */ + @CanIgnoreReturnValue + public Builder intentClassifier(IntentClassifier intentClassifier) { + this.intentClassifier = intentClassifier; + return this; + } + + /** + * Sets the navigation handler for voice navigation commands. + * + * @param navigationHandler the handler instance + * @return this builder + */ + @CanIgnoreReturnValue + public Builder navigationHandler(VoiceNavigationHandler navigationHandler) { + this.navigationHandler = navigationHandler; + return this; + } + + /** + * Sets navigation commands as a map of patterns to responses. This is a convenience method that + * creates a {@link VoiceNavigationHandler} from the map. + * + * @param commands map of command patterns to response text + * @return this builder + */ + @CanIgnoreReturnValue + public Builder navigationCommands(Map commands) { + this.navigationCommands = commands; + return this; + } + + @Override + public VoiceAgent build() { + if (voiceConfig == null) { + throw new IllegalArgumentException("VoiceConfig is required"); + } + if (delegate == null) { + throw new IllegalArgumentException("Delegate LlmAgent is required"); + } + if (intentClassifier == null) { + // Default to keyword classifier using config's navigation commands + intentClassifier = IntentClassifier.keyword(voiceConfig.getNavigationCommands()); + } + if (navigationCommands != null) { + this.navigationHandler = new VoiceNavigationHandler(navigationCommands); + } + if (name == null || name.isEmpty()) { + name = "voice_agent"; + } + if (description == null) { + description = "Voice-enabled agent with STT/TTS pipeline"; + } + return new VoiceAgent(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/agents/VoiceConfig.java b/core/src/main/java/com/google/adk/agents/VoiceConfig.java new file mode 100644 index 000000000..0e8cc6b05 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/VoiceConfig.java @@ -0,0 +1,378 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import com.google.common.collect.ImmutableList; +import java.util.Arrays; +import java.util.List; + +/** + * Configuration for voice-enabled agent interaction. Uses Builder Pattern for flexible + * configuration. + * + *

All fields are immutable once built. Use the builder to create instances, or use {@link + * #fromEnvironment()} to load configuration from environment variables. + * + *

Supported environment variables: + * + *

    + *
  • {@code ADK_VOICE_MODE} - Voice mode (TEXT_ONLY, VOICE_NAVIGATION, VOICE_FULL, AUTO) + *
  • {@code ADK_STT_ENDPOINT} - Speech-to-text service endpoint + *
  • {@code ADK_TTS_ENDPOINT} - Text-to-speech service endpoint + *
  • {@code ADK_STT_MODEL} - STT model name + *
  • {@code ADK_TTS_MODEL} - TTS model name + *
  • {@code ADK_TTS_VOICE} - TTS voice name + *
  • {@code ADK_VOICE_LANGUAGE} - Language code + *
  • {@code ADK_TTS_SPEED} - TTS playback speed + *
  • {@code ADK_LLM_MODEL} - LLM model for reasoning + *
  • {@code ADK_CLASSIFIER_MODEL} - Model for intent classification + *
  • {@code ADK_NAVIGATION_COMMANDS} - Comma-separated navigation commands + *
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class VoiceConfig { + private final VoiceMode voiceMode; + private final String sttEndpoint; + private final String ttsEndpoint; + private final String sttModel; + private final String ttsModel; + private final String ttsVoice; + private final String language; + private final double ttsSpeed; + private final String llmModel; + private final String classifierModel; + private final ImmutableList navigationCommands; + + private VoiceConfig(Builder builder) { + this.voiceMode = builder.voiceMode; + this.sttEndpoint = builder.sttEndpoint; + this.ttsEndpoint = builder.ttsEndpoint; + this.sttModel = builder.sttModel; + this.ttsModel = builder.ttsModel; + this.ttsVoice = builder.ttsVoice; + this.language = builder.language; + this.ttsSpeed = builder.ttsSpeed; + this.llmModel = builder.llmModel; + this.classifierModel = + builder.classifierModel != null ? builder.classifierModel : builder.llmModel; + this.navigationCommands = ImmutableList.copyOf(builder.navigationCommands); + } + + /** + * Creates a new {@link Builder} instance. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a {@link VoiceConfig} from environment variables. + * + *

Reads all configuration from environment variables with sensible defaults for values that + * are not set. + * + * @return a VoiceConfig populated from environment variables + */ + public static VoiceConfig fromEnvironment() { + Builder builder = new Builder(); + + String mode = System.getenv("ADK_VOICE_MODE"); + if (mode != null && !mode.isEmpty()) { + builder.voiceMode(VoiceMode.valueOf(mode.toUpperCase())); + } + + String sttEndpoint = System.getenv("ADK_STT_ENDPOINT"); + if (sttEndpoint != null && !sttEndpoint.isEmpty()) { + builder.sttEndpoint(sttEndpoint); + } + + String ttsEndpoint = System.getenv("ADK_TTS_ENDPOINT"); + if (ttsEndpoint != null && !ttsEndpoint.isEmpty()) { + builder.ttsEndpoint(ttsEndpoint); + } + + String sttModel = System.getenv("ADK_STT_MODEL"); + if (sttModel != null && !sttModel.isEmpty()) { + builder.sttModel(sttModel); + } + + String ttsModel = System.getenv("ADK_TTS_MODEL"); + if (ttsModel != null && !ttsModel.isEmpty()) { + builder.ttsModel(ttsModel); + } + + String ttsVoice = System.getenv("ADK_TTS_VOICE"); + if (ttsVoice != null && !ttsVoice.isEmpty()) { + builder.ttsVoice(ttsVoice); + } + + String language = System.getenv("ADK_VOICE_LANGUAGE"); + if (language != null && !language.isEmpty()) { + builder.language(language); + } + + String ttsSpeed = System.getenv("ADK_TTS_SPEED"); + if (ttsSpeed != null && !ttsSpeed.isEmpty()) { + builder.ttsSpeed(Double.parseDouble(ttsSpeed)); + } + + String llmModel = System.getenv("ADK_LLM_MODEL"); + if (llmModel != null && !llmModel.isEmpty()) { + builder.llmModel(llmModel); + } + + String classifierModel = System.getenv("ADK_CLASSIFIER_MODEL"); + if (classifierModel != null && !classifierModel.isEmpty()) { + builder.classifierModel(classifierModel); + } + + String navCommands = System.getenv("ADK_NAVIGATION_COMMANDS"); + if (navCommands != null && !navCommands.isEmpty()) { + builder.navigationCommands(Arrays.asList(navCommands.split(","))); + } + + return builder.build(); + } + + /** Returns the voice interaction mode. */ + public VoiceMode getVoiceMode() { + return voiceMode; + } + + /** Returns the speech-to-text service endpoint. */ + public String getSttEndpoint() { + return sttEndpoint; + } + + /** Returns the text-to-speech service endpoint. */ + public String getTtsEndpoint() { + return ttsEndpoint; + } + + /** Returns the STT model name. */ + public String getSttModel() { + return sttModel; + } + + /** Returns the TTS model name. */ + public String getTtsModel() { + return ttsModel; + } + + /** Returns the TTS voice name. */ + public String getTtsVoice() { + return ttsVoice; + } + + /** Returns the language code. */ + public String getLanguage() { + return language; + } + + /** Returns the TTS playback speed multiplier. */ + public double getTtsSpeed() { + return ttsSpeed; + } + + /** Returns the LLM model name used for reasoning. */ + public String getLlmModel() { + return llmModel; + } + + /** Returns the classifier model name used for intent classification. */ + public String getClassifierModel() { + return classifierModel; + } + + /** Returns the list of navigation commands that trigger voice-nav mode. */ + public ImmutableList getNavigationCommands() { + return navigationCommands; + } + + /** Builder for {@link VoiceConfig}. */ + public static class Builder { + private VoiceMode voiceMode = VoiceMode.AUTO; + private String sttEndpoint = System.getenv("ADK_STT_ENDPOINT"); + private String ttsEndpoint = System.getenv("ADK_TTS_ENDPOINT"); + private String sttModel = "whisper-1"; + private String ttsModel = "tts-1"; + private String ttsVoice = "alloy"; + private String language = "en"; + private double ttsSpeed = 1.0; + private String llmModel; + private String classifierModel; + private List navigationCommands = List.of(); + + /** + * Sets the voice interaction mode. + * + * @param voiceMode the voice mode + * @return this builder + */ + public Builder voiceMode(VoiceMode voiceMode) { + this.voiceMode = voiceMode; + return this; + } + + /** + * Sets the speech-to-text service endpoint. + * + * @param sttEndpoint the STT endpoint URL + * @return this builder + */ + public Builder sttEndpoint(String sttEndpoint) { + this.sttEndpoint = sttEndpoint; + return this; + } + + /** + * Sets the text-to-speech service endpoint. + * + * @param ttsEndpoint the TTS endpoint URL + * @return this builder + */ + public Builder ttsEndpoint(String ttsEndpoint) { + this.ttsEndpoint = ttsEndpoint; + return this; + } + + /** + * Sets the STT model name. + * + * @param sttModel the STT model name + * @return this builder + */ + public Builder sttModel(String sttModel) { + this.sttModel = sttModel; + return this; + } + + /** + * Sets the TTS model name. + * + * @param ttsModel the TTS model name + * @return this builder + */ + public Builder ttsModel(String ttsModel) { + this.ttsModel = ttsModel; + return this; + } + + /** + * Sets the TTS voice name. + * + * @param ttsVoice the voice name + * @return this builder + */ + public Builder ttsVoice(String ttsVoice) { + this.ttsVoice = ttsVoice; + return this; + } + + /** + * Sets the language code. + * + * @param language the language code (e.g., "en", "es", "fr") + * @return this builder + */ + public Builder language(String language) { + this.language = language; + return this; + } + + /** + * Sets the TTS playback speed multiplier. + * + * @param ttsSpeed the speed multiplier (must be greater than 0) + * @return this builder + * @throws IllegalArgumentException if speed is not greater than 0 + */ + public Builder ttsSpeed(double ttsSpeed) { + if (ttsSpeed <= 0) { + throw new IllegalArgumentException("TTS speed must be > 0"); + } + this.ttsSpeed = ttsSpeed; + return this; + } + + /** + * Sets the LLM model name for reasoning. + * + * @param llmModel the LLM model name (e.g., "llama3") + * @return this builder + */ + public Builder llmModel(String llmModel) { + this.llmModel = llmModel; + return this; + } + + /** + * Sets the classifier model name for intent classification. If not set, defaults to the LLM + * model. + * + * @param classifierModel the classifier model name + * @return this builder + */ + public Builder classifierModel(String classifierModel) { + this.classifierModel = classifierModel; + return this; + } + + /** + * Sets the navigation commands that trigger voice-nav mode. + * + * @param navigationCommands list of command keywords + * @return this builder + */ + public Builder navigationCommands(List navigationCommands) { + this.navigationCommands = List.copyOf(navigationCommands); + return this; + } + + /** + * Builds the {@link VoiceConfig} instance. + * + * @return the immutable VoiceConfig + */ + public VoiceConfig build() { + return new VoiceConfig(this); + } + } + + @Override + public String toString() { + return String.format( + "VoiceConfig{mode=%s, sttEndpoint='%s', ttsEndpoint='%s', sttModel='%s', ttsModel='%s'," + + " voice='%s', language='%s', speed=%.1f, llmModel='%s', classifierModel='%s'," + + " navCommands=%s}", + voiceMode, + sttEndpoint, + ttsEndpoint, + sttModel, + ttsModel, + ttsVoice, + language, + ttsSpeed, + llmModel, + classifierModel, + navigationCommands); + } +} diff --git a/core/src/main/java/com/google/adk/agents/VoiceMode.java b/core/src/main/java/com/google/adk/agents/VoiceMode.java new file mode 100644 index 000000000..63f376940 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/VoiceMode.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +/** + * Defines the voice interaction mode for an agent. + * + *

Controls how audio input/output is handled relative to the LLM reasoning pipeline. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public enum VoiceMode { + + /** + * Normal text-based agent interaction. No speech-to-text or text-to-speech processing is applied. + */ + TEXT_ONLY, + + /** + * Voice navigation mode: STT → simple command matching → TTS. Bypasses heavy LLM reasoning for + * faster response to known navigation commands and keywords. + */ + VOICE_NAVIGATION, + + /** + * Full voice mode: STT → full LLM reasoning → TTS response. All audio input is transcribed, + * processed through the complete LLM pipeline, and the response is synthesized back to speech. + */ + VOICE_FULL, + + /** + * Automatic mode: classifies the input dynamically and picks the appropriate voice mode. Simple + * navigation commands are routed through {@link #VOICE_NAVIGATION}, while complex queries are + * routed through {@link #VOICE_FULL}. + */ + AUTO +} diff --git a/core/src/main/java/com/google/adk/agents/VoiceNavigationHandler.java b/core/src/main/java/com/google/adk/agents/VoiceNavigationHandler.java new file mode 100644 index 000000000..c57a14172 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/VoiceNavigationHandler.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Simple handler for voice navigation commands. Matches user input against a set of known command + * patterns and returns the corresponding response text. + * + *

Matching uses case-insensitive substring containment: if the normalized input contains a + * registered pattern keyword, the associated response is returned. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class VoiceNavigationHandler { + + private final Map commands; + + /** + * Creates a new handler with the given command mappings. + * + * @param commands map of command patterns (lowercase keywords) to response text + */ + public VoiceNavigationHandler(Map commands) { + this.commands = new LinkedHashMap<>(); + if (commands != null) { + commands.forEach((pattern, response) -> this.commands.put(pattern.toLowerCase(), response)); + } + } + + /** Creates a new empty handler. */ + public VoiceNavigationHandler() { + this.commands = new LinkedHashMap<>(); + } + + /** + * Attempts to match the input against registered navigation commands. + * + *

Matching is case-insensitive and uses substring containment. The first matching pattern (in + * insertion order) wins. + * + * @param input the user's text input + * @return the response text if a command matches, or empty if no match + */ + public Optional handle(String input) { + if (input == null || input.isEmpty()) { + return Optional.empty(); + } + + String normalizedInput = input.toLowerCase().trim(); + + for (Map.Entry entry : commands.entrySet()) { + if (normalizedInput.contains(entry.getKey())) { + return Optional.of(entry.getValue()); + } + } + + return Optional.empty(); + } + + /** + * Adds a new navigation command pattern and its response. + * + * @param pattern the command keyword pattern (will be lowercased) + * @param response the response text to return when the pattern matches + */ + public void addCommand(String pattern, String response) { + if (pattern == null || pattern.isEmpty()) { + throw new IllegalArgumentException("Pattern cannot be null or empty"); + } + if (response == null || response.isEmpty()) { + throw new IllegalArgumentException("Response cannot be null or empty"); + } + commands.put(pattern.toLowerCase(), response); + } + + /** + * Returns the number of registered commands. + * + * @return command count + */ + public int size() { + return commands.size(); + } + + /** + * Returns an unmodifiable view of the registered commands. + * + * @return command map + */ + public Map getCommands() { + return Map.copyOf(commands); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/ServiceType.java b/core/src/main/java/com/google/adk/transcription/ServiceType.java index 98203eee4..864686a71 100644 --- a/core/src/main/java/com/google/adk/transcription/ServiceType.java +++ b/core/src/main/java/com/google/adk/transcription/ServiceType.java @@ -36,7 +36,10 @@ public enum ServiceType { AWS_TRANSCRIBE("aws_transcribe"), /** Sarvam AI transcription. */ - SARVAM("sarvam"); + SARVAM("sarvam"), + + /** Ollama/Whisper-compatible STT (OpenAI-compatible /v1/audio/transcriptions endpoint). */ + OLLAMA_WHISPER("ollama_whisper"); private final String value; diff --git a/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetrics.java b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetrics.java new file mode 100644 index 000000000..dd2334847 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetrics.java @@ -0,0 +1,199 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.metrics; + +import com.google.adk.agents.VoiceMode; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Singleton class that tracks metrics for the voice pipeline. + * + *

Tracks per-service (STT/TTS) statistics including total calls, successful calls, failed calls, + * total latency, average latency, and approximate p99 latency (using max tracking). + * + *

All operations are thread-safe using atomic variables. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class VoiceMetrics { + + private static final VoiceMetrics INSTANCE = new VoiceMetrics(); + + // STT metrics + private final AtomicLong sttTotalCalls = new AtomicLong(0); + private final AtomicLong sttSuccessCalls = new AtomicLong(0); + private final AtomicLong sttFailedCalls = new AtomicLong(0); + private final AtomicLong sttTotalLatencyMs = new AtomicLong(0); + private final AtomicLong sttMaxLatencyMs = new AtomicLong(0); + + // TTS metrics + private final AtomicLong ttsTotalCalls = new AtomicLong(0); + private final AtomicLong ttsSuccessCalls = new AtomicLong(0); + private final AtomicLong ttsFailedCalls = new AtomicLong(0); + private final AtomicLong ttsTotalLatencyMs = new AtomicLong(0); + private final AtomicLong ttsMaxLatencyMs = new AtomicLong(0); + private final AtomicLong ttsTotalTextLength = new AtomicLong(0); + + // Intent classification metrics + private final AtomicLong classifierCalls = new AtomicLong(0); + private final AtomicLong classifierTotalLatencyMs = new AtomicLong(0); + private final Map classifierResults = new EnumMap<>(VoiceMode.class); + + private VoiceMetrics() { + for (VoiceMode mode : VoiceMode.values()) { + classifierResults.put(mode, new AtomicLong(0)); + } + } + + /** + * Returns the singleton instance of VoiceMetrics. + * + * @return the VoiceMetrics instance + */ + public static VoiceMetrics getInstance() { + return INSTANCE; + } + + /** + * Records a speech-to-text call with its latency and outcome. + * + * @param latencyMs the call latency in milliseconds + * @param success true if the call succeeded, false otherwise + */ + public void recordSttCall(long latencyMs, boolean success) { + sttTotalCalls.incrementAndGet(); + sttTotalLatencyMs.addAndGet(latencyMs); + updateMax(sttMaxLatencyMs, latencyMs); + + if (success) { + sttSuccessCalls.incrementAndGet(); + } else { + sttFailedCalls.incrementAndGet(); + } + } + + /** + * Records a text-to-speech call with its latency, outcome, and input text length. + * + * @param latencyMs the call latency in milliseconds + * @param success true if the call succeeded, false otherwise + * @param textLength the length of the input text in characters + */ + public void recordTtsCall(long latencyMs, boolean success, int textLength) { + ttsTotalCalls.incrementAndGet(); + ttsTotalLatencyMs.addAndGet(latencyMs); + ttsTotalTextLength.addAndGet(textLength); + updateMax(ttsMaxLatencyMs, latencyMs); + + if (success) { + ttsSuccessCalls.incrementAndGet(); + } else { + ttsFailedCalls.incrementAndGet(); + } + } + + /** + * Records an intent classification call with its latency and result. + * + * @param latencyMs the call latency in milliseconds + * @param result the classified VoiceMode result + */ + public void recordIntentClassification(long latencyMs, VoiceMode result) { + classifierCalls.incrementAndGet(); + classifierTotalLatencyMs.addAndGet(latencyMs); + classifierResults.get(result).incrementAndGet(); + } + + /** + * Returns an immutable snapshot of the current metrics state. + * + * @return a VoiceMetricsSnapshot capturing the current metrics + */ + public VoiceMetricsSnapshot getSnapshot() { + long sttTotal = sttTotalCalls.get(); + long ttsTotal = ttsTotalCalls.get(); + + long sttAvg = sttTotal > 0 ? sttTotalLatencyMs.get() / sttTotal : 0; + long ttsAvg = ttsTotal > 0 ? ttsTotalLatencyMs.get() / ttsTotal : 0; + + Map classifierResultsSnapshot = new EnumMap<>(VoiceMode.class); + for (Map.Entry entry : classifierResults.entrySet()) { + classifierResultsSnapshot.put(entry.getKey(), entry.getValue().get()); + } + + return VoiceMetricsSnapshot.builder() + .sttTotalCalls(sttTotal) + .sttSuccessCalls(sttSuccessCalls.get()) + .sttFailedCalls(sttFailedCalls.get()) + .sttAvgLatencyMs(sttAvg) + .sttMaxLatencyMs(sttMaxLatencyMs.get()) + .ttsTotalCalls(ttsTotal) + .ttsSuccessCalls(ttsSuccessCalls.get()) + .ttsFailedCalls(ttsFailedCalls.get()) + .ttsAvgLatencyMs(ttsAvg) + .ttsMaxLatencyMs(ttsMaxLatencyMs.get()) + .classifierCalls(classifierCalls.get()) + .classifierResults(classifierResultsSnapshot) + .build(); + } + + /** + * Resets all metrics counters to zero. + * + *

Note: this is not strictly atomic across all counters, but each individual counter reset is + * atomic. For precise point-in-time data, use {@link #getSnapshot()} before resetting. + */ + public void reset() { + sttTotalCalls.set(0); + sttSuccessCalls.set(0); + sttFailedCalls.set(0); + sttTotalLatencyMs.set(0); + sttMaxLatencyMs.set(0); + + ttsTotalCalls.set(0); + ttsSuccessCalls.set(0); + ttsFailedCalls.set(0); + ttsTotalLatencyMs.set(0); + ttsMaxLatencyMs.set(0); + ttsTotalTextLength.set(0); + + classifierCalls.set(0); + classifierTotalLatencyMs.set(0); + for (AtomicLong counter : classifierResults.values()) { + counter.set(0); + } + } + + /** + * Atomically updates the max value if the new value is greater. + * + * @param maxHolder the AtomicLong holding the current max + * @param newValue the new value to compare + */ + private void updateMax(AtomicLong maxHolder, long newValue) { + long currentMax; + do { + currentMax = maxHolder.get(); + if (newValue <= currentMax) { + return; + } + } while (!maxHolder.compareAndSet(currentMax, newValue)); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsInterceptor.java b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsInterceptor.java new file mode 100644 index 000000000..886af59e1 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsInterceptor.java @@ -0,0 +1,231 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.metrics; + +import com.google.adk.transcription.ServiceHealth; +import com.google.adk.transcription.ServiceType; +import com.google.adk.transcription.TranscriptionConfig; +import com.google.adk.transcription.TranscriptionEvent; +import com.google.adk.transcription.TranscriptionException; +import com.google.adk.transcription.TranscriptionResult; +import com.google.adk.transcription.TranscriptionService; +import com.google.adk.transcription.tts.TtsConfig; +import com.google.adk.transcription.tts.TtsException; +import com.google.adk.transcription.tts.TtsService; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import java.util.concurrent.TimeUnit; + +/** + * Decorator that adds metrics tracking to any {@link TtsService} or {@link TranscriptionService}. + * + *

Wraps delegate service calls to automatically record timing and success/failure metrics via + * {@link VoiceMetrics}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class VoiceMetricsInterceptor { + + private VoiceMetricsInterceptor() {} + + /** + * Wraps a TtsService with metrics instrumentation. + * + * @param delegate the TtsService to wrap + * @return a TtsService that records metrics for every call + */ + public static TtsService wrapTts(TtsService delegate) { + return new InstrumentedTtsService(delegate); + } + + /** + * Wraps a TranscriptionService with metrics instrumentation. + * + * @param delegate the TranscriptionService to wrap + * @return a TranscriptionService that records metrics for every call + */ + public static TranscriptionService wrapStt(TranscriptionService delegate) { + return new InstrumentedTranscriptionService(delegate); + } + + /** TTS service wrapper that records metrics on every call. */ + private static final class InstrumentedTtsService implements TtsService { + + private final TtsService delegate; + private final VoiceMetrics metrics = VoiceMetrics.getInstance(); + + InstrumentedTtsService(TtsService delegate) { + this.delegate = delegate; + } + + @Override + public byte[] synthesize(String text, TtsConfig config) throws TtsException { + long startNanos = System.nanoTime(); + boolean success = false; + try { + byte[] result = delegate.synthesize(text, config); + success = true; + return result; + } finally { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordTtsCall(latencyMs, success, text != null ? text.length() : 0); + } + } + + @Override + public Single synthesizeAsync(String text, TtsConfig config) { + return Single.defer( + () -> { + long startNanos = System.nanoTime(); + return delegate + .synthesizeAsync(text, config) + .doOnSuccess( + bytes -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordTtsCall(latencyMs, true, text != null ? text.length() : 0); + }) + .doOnError( + error -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordTtsCall(latencyMs, false, text != null ? text.length() : 0); + }); + }); + } + + @Override + public Flowable synthesizeStream(String text, TtsConfig config) { + return Flowable.defer( + () -> { + long startNanos = System.nanoTime(); + return delegate + .synthesizeStream(text, config) + .doOnComplete( + () -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordTtsCall(latencyMs, true, text != null ? text.length() : 0); + }) + .doOnError( + error -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordTtsCall(latencyMs, false, text != null ? text.length() : 0); + }); + }); + } + + @Override + public boolean isAvailable() { + return delegate.isAvailable(); + } + + @Override + public ServiceHealth getHealth() { + return delegate.getHealth(); + } + } + + /** Transcription service wrapper that records metrics on every call. */ + private static final class InstrumentedTranscriptionService implements TranscriptionService { + + private final TranscriptionService delegate; + private final VoiceMetrics metrics = VoiceMetrics.getInstance(); + + InstrumentedTranscriptionService(TranscriptionService delegate) { + this.delegate = delegate; + } + + @Override + public TranscriptionResult transcribe(byte[] audioData, TranscriptionConfig config) + throws TranscriptionException { + long startNanos = System.nanoTime(); + boolean success = false; + try { + TranscriptionResult result = delegate.transcribe(audioData, config); + success = true; + return result; + } finally { + long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordSttCall(latencyMs, success); + } + } + + @Override + public Single transcribeAsync( + byte[] audioData, TranscriptionConfig config) { + return Single.defer( + () -> { + long startNanos = System.nanoTime(); + return delegate + .transcribeAsync(audioData, config) + .doOnSuccess( + result -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordSttCall(latencyMs, true); + }) + .doOnError( + error -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordSttCall(latencyMs, false); + }); + }); + } + + @Override + public Flowable transcribeStream( + Flowable audioStream, TranscriptionConfig config) { + return Flowable.defer( + () -> { + long startNanos = System.nanoTime(); + return delegate + .transcribeStream(audioStream, config) + .doOnComplete( + () -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordSttCall(latencyMs, true); + }) + .doOnError( + error -> { + long latencyMs = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + metrics.recordSttCall(latencyMs, false); + }); + }); + } + + @Override + public boolean isAvailable() { + return delegate.isAvailable(); + } + + @Override + public ServiceType getServiceType() { + return delegate.getServiceType(); + } + + @Override + public ServiceHealth getHealth() { + return delegate.getHealth(); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsSnapshot.java b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsSnapshot.java new file mode 100644 index 000000000..4f13cb15d --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/metrics/VoiceMetricsSnapshot.java @@ -0,0 +1,224 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.metrics; + +import com.google.adk.agents.VoiceMode; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** + * Immutable snapshot of voice pipeline metrics at a point in time. + * + *

Captures cumulative statistics for STT, TTS, and intent classification services including call + * counts, latency averages, and maximum latencies. + * + *

Instances are created via the {@link Builder} pattern. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class VoiceMetricsSnapshot { + + private final long sttTotalCalls; + private final long sttSuccessCalls; + private final long sttFailedCalls; + private final long sttAvgLatencyMs; + private final long sttMaxLatencyMs; + + private final long ttsTotalCalls; + private final long ttsSuccessCalls; + private final long ttsFailedCalls; + private final long ttsAvgLatencyMs; + private final long ttsMaxLatencyMs; + + private final long classifierCalls; + private final Map classifierResults; + + private VoiceMetricsSnapshot(Builder builder) { + this.sttTotalCalls = builder.sttTotalCalls; + this.sttSuccessCalls = builder.sttSuccessCalls; + this.sttFailedCalls = builder.sttFailedCalls; + this.sttAvgLatencyMs = builder.sttAvgLatencyMs; + this.sttMaxLatencyMs = builder.sttMaxLatencyMs; + this.ttsTotalCalls = builder.ttsTotalCalls; + this.ttsSuccessCalls = builder.ttsSuccessCalls; + this.ttsFailedCalls = builder.ttsFailedCalls; + this.ttsAvgLatencyMs = builder.ttsAvgLatencyMs; + this.ttsMaxLatencyMs = builder.ttsMaxLatencyMs; + this.classifierCalls = builder.classifierCalls; + this.classifierResults = Collections.unmodifiableMap(new EnumMap<>(builder.classifierResults)); + } + + /** Returns a new builder for constructing a snapshot. */ + public static Builder builder() { + return new Builder(); + } + + public long sttTotalCalls() { + return sttTotalCalls; + } + + public long sttSuccessCalls() { + return sttSuccessCalls; + } + + public long sttFailedCalls() { + return sttFailedCalls; + } + + public long sttAvgLatencyMs() { + return sttAvgLatencyMs; + } + + public long sttMaxLatencyMs() { + return sttMaxLatencyMs; + } + + public long ttsTotalCalls() { + return ttsTotalCalls; + } + + public long ttsSuccessCalls() { + return ttsSuccessCalls; + } + + public long ttsFailedCalls() { + return ttsFailedCalls; + } + + public long ttsAvgLatencyMs() { + return ttsAvgLatencyMs; + } + + public long ttsMaxLatencyMs() { + return ttsMaxLatencyMs; + } + + public long classifierCalls() { + return classifierCalls; + } + + /** Returns an unmodifiable map of classifier results by VoiceMode. */ + public Map classifierResults() { + return classifierResults; + } + + @Override + public String toString() { + return String.format( + "VoiceMetricsSnapshot{%n" + + " STT: total=%d, success=%d, failed=%d, avgLatency=%dms, maxLatency=%dms%n" + + " TTS: total=%d, success=%d, failed=%d, avgLatency=%dms, maxLatency=%dms%n" + + " Classifier: total=%d, results=%s%n" + + "}", + sttTotalCalls, + sttSuccessCalls, + sttFailedCalls, + sttAvgLatencyMs, + sttMaxLatencyMs, + ttsTotalCalls, + ttsSuccessCalls, + ttsFailedCalls, + ttsAvgLatencyMs, + ttsMaxLatencyMs, + classifierCalls, + classifierResults); + } + + /** Builder for {@link VoiceMetricsSnapshot}. */ + public static final class Builder { + private long sttTotalCalls; + private long sttSuccessCalls; + private long sttFailedCalls; + private long sttAvgLatencyMs; + private long sttMaxLatencyMs; + private long ttsTotalCalls; + private long ttsSuccessCalls; + private long ttsFailedCalls; + private long ttsAvgLatencyMs; + private long ttsMaxLatencyMs; + private long classifierCalls; + private Map classifierResults = new EnumMap<>(VoiceMode.class); + + private Builder() {} + + public Builder sttTotalCalls(long sttTotalCalls) { + this.sttTotalCalls = sttTotalCalls; + return this; + } + + public Builder sttSuccessCalls(long sttSuccessCalls) { + this.sttSuccessCalls = sttSuccessCalls; + return this; + } + + public Builder sttFailedCalls(long sttFailedCalls) { + this.sttFailedCalls = sttFailedCalls; + return this; + } + + public Builder sttAvgLatencyMs(long sttAvgLatencyMs) { + this.sttAvgLatencyMs = sttAvgLatencyMs; + return this; + } + + public Builder sttMaxLatencyMs(long sttMaxLatencyMs) { + this.sttMaxLatencyMs = sttMaxLatencyMs; + return this; + } + + public Builder ttsTotalCalls(long ttsTotalCalls) { + this.ttsTotalCalls = ttsTotalCalls; + return this; + } + + public Builder ttsSuccessCalls(long ttsSuccessCalls) { + this.ttsSuccessCalls = ttsSuccessCalls; + return this; + } + + public Builder ttsFailedCalls(long ttsFailedCalls) { + this.ttsFailedCalls = ttsFailedCalls; + return this; + } + + public Builder ttsAvgLatencyMs(long ttsAvgLatencyMs) { + this.ttsAvgLatencyMs = ttsAvgLatencyMs; + return this; + } + + public Builder ttsMaxLatencyMs(long ttsMaxLatencyMs) { + this.ttsMaxLatencyMs = ttsMaxLatencyMs; + return this; + } + + public Builder classifierCalls(long classifierCalls) { + this.classifierCalls = classifierCalls; + return this; + } + + public Builder classifierResults(Map classifierResults) { + this.classifierResults = new EnumMap<>(classifierResults); + return this; + } + + public VoiceMetricsSnapshot build() { + return new VoiceMetricsSnapshot(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreaker.java b/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreaker.java new file mode 100644 index 000000000..5558ae636 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreaker.java @@ -0,0 +1,292 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A thread-safe circuit breaker implementation that protects downstream services from cascading + * failures. The circuit breaker transitions between three states: + * + *

    + *
  • CLOSED — Normal operation. Calls pass through and failures are counted. + *
  • OPEN — Failure threshold exceeded. Calls are immediately rejected with {@link + * CircuitBreakerOpenException}. + *
  • HALF_OPEN — After the open duration elapses, a limited number of test calls are + * allowed. If they succeed, the circuit closes. If they fail, it opens again. + *
+ * + *

Usage example: + * + *

{@code
+ * CircuitBreaker breaker = CircuitBreaker.builder()
+ *     .failureThreshold(5)
+ *     .openDurationMs(30000)
+ *     .halfOpenMaxAttempts(2)
+ *     .build();
+ *
+ * String result = breaker.execute(() -> callRemoteService());
+ * }
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class CircuitBreaker { + + private static final Logger logger = LoggerFactory.getLogger(CircuitBreaker.class); + + /** Represents the state of the circuit breaker. */ + public enum State { + CLOSED, + OPEN, + HALF_OPEN + } + + private final int failureThreshold; + private final long openDurationMs; + private final int halfOpenMaxAttempts; + + private final AtomicReference state = new AtomicReference<>(State.CLOSED); + private final AtomicInteger failureCount = new AtomicInteger(0); + private final AtomicLong lastFailureTime = new AtomicLong(0); + private final AtomicInteger halfOpenAttempts = new AtomicInteger(0); + private final AtomicInteger halfOpenSuccesses = new AtomicInteger(0); + + private CircuitBreaker(Builder builder) { + this.failureThreshold = builder.failureThreshold; + this.openDurationMs = builder.openDurationMs; + this.halfOpenMaxAttempts = builder.halfOpenMaxAttempts; + } + + /** + * Executes the given operation through the circuit breaker. If the circuit is OPEN, the call is + * rejected immediately. If HALF_OPEN, limited test calls are allowed. + * + * @param the return type of the operation + * @param operation the operation to execute + * @return the result of the operation + * @throws Exception if the operation fails or the circuit is open + */ + public T execute(Callable operation) throws Exception { + State currentState = evaluateState(); + + switch (currentState) { + case OPEN: + logger.debug("Circuit breaker is OPEN, rejecting call"); + throw new CircuitBreakerOpenException(); + + case HALF_OPEN: + return executeInHalfOpen(operation); + + case CLOSED: + default: + return executeInClosed(operation); + } + } + + /** + * Returns the current state of the circuit breaker after evaluating time-based transitions. + * + * @return the current circuit breaker state + */ + public State getState() { + return evaluateState(); + } + + /** Resets the circuit breaker to its initial CLOSED state. */ + public void reset() { + state.set(State.CLOSED); + failureCount.set(0); + lastFailureTime.set(0); + halfOpenAttempts.set(0); + halfOpenSuccesses.set(0); + logger.info("Circuit breaker has been reset to CLOSED state"); + } + + /** Returns the configured failure threshold. */ + public int getFailureThreshold() { + return failureThreshold; + } + + /** Returns the configured open duration in milliseconds. */ + public long getOpenDurationMs() { + return openDurationMs; + } + + /** Returns the configured maximum number of half-open attempts. */ + public int getHalfOpenMaxAttempts() { + return halfOpenMaxAttempts; + } + + /** + * Creates a new builder for CircuitBreaker. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + // ---- Private methods ---- + + private State evaluateState() { + State current = state.get(); + if (current == State.OPEN) { + long elapsed = System.currentTimeMillis() - lastFailureTime.get(); + if (elapsed >= openDurationMs) { + if (state.compareAndSet(State.OPEN, State.HALF_OPEN)) { + halfOpenAttempts.set(0); + halfOpenSuccesses.set(0); + logger.info("Circuit breaker transitioning from OPEN to HALF_OPEN after {}ms", elapsed); + } + return State.HALF_OPEN; + } + } + return state.get(); + } + + private T executeInClosed(Callable operation) throws Exception { + try { + T result = operation.call(); + onSuccess(); + return result; + } catch (Exception e) { + onFailure(); + throw e; + } + } + + private T executeInHalfOpen(Callable operation) throws Exception { + int attempts = halfOpenAttempts.incrementAndGet(); + if (attempts > halfOpenMaxAttempts) { + logger.debug("Circuit breaker HALF_OPEN: max test attempts reached, rejecting call"); + throw new CircuitBreakerOpenException( + "Circuit breaker is HALF_OPEN but max test attempts reached"); + } + + try { + T result = operation.call(); + int successes = halfOpenSuccesses.incrementAndGet(); + logger.debug( + "Circuit breaker HALF_OPEN: test call succeeded ({}/{})", successes, halfOpenMaxAttempts); + if (successes >= halfOpenMaxAttempts) { + transitionToClosed(); + } + return result; + } catch (Exception e) { + logger.warn("Circuit breaker HALF_OPEN: test call failed, transitioning back to OPEN"); + transitionToOpen(); + throw e; + } + } + + private void onSuccess() { + failureCount.set(0); + } + + private void onFailure() { + int failures = failureCount.incrementAndGet(); + lastFailureTime.set(System.currentTimeMillis()); + logger.debug("Circuit breaker failure count: {}/{}", failures, failureThreshold); + + if (failures >= failureThreshold) { + transitionToOpen(); + } + } + + private void transitionToOpen() { + State previous = state.getAndSet(State.OPEN); + lastFailureTime.set(System.currentTimeMillis()); + if (previous != State.OPEN) { + logger.warn( + "Circuit breaker transitioning to OPEN (failures: {}/{})", + failureCount.get(), + failureThreshold); + } + } + + private void transitionToClosed() { + state.set(State.CLOSED); + failureCount.set(0); + halfOpenAttempts.set(0); + halfOpenSuccesses.set(0); + logger.info("Circuit breaker transitioning to CLOSED — service recovered"); + } + + /** Builder for {@link CircuitBreaker}. */ + public static class Builder { + private int failureThreshold = 5; + private long openDurationMs = 30000; + private int halfOpenMaxAttempts = 2; + + /** + * Sets the number of consecutive failures before the circuit opens. + * + * @param failureThreshold failure count threshold, must be at least 1 + * @return this builder + */ + public Builder failureThreshold(int failureThreshold) { + if (failureThreshold < 1) { + throw new IllegalArgumentException("failureThreshold must be at least 1"); + } + this.failureThreshold = failureThreshold; + return this; + } + + /** + * Sets the duration the circuit remains open before transitioning to half-open. + * + * @param openDurationMs duration in milliseconds, must be positive + * @return this builder + */ + public Builder openDurationMs(long openDurationMs) { + if (openDurationMs <= 0) { + throw new IllegalArgumentException("openDurationMs must be positive"); + } + this.openDurationMs = openDurationMs; + return this; + } + + /** + * Sets the maximum number of test calls allowed in the HALF_OPEN state. + * + * @param halfOpenMaxAttempts max attempts, must be at least 1 + * @return this builder + */ + public Builder halfOpenMaxAttempts(int halfOpenMaxAttempts) { + if (halfOpenMaxAttempts < 1) { + throw new IllegalArgumentException("halfOpenMaxAttempts must be at least 1"); + } + this.halfOpenMaxAttempts = halfOpenMaxAttempts; + return this; + } + + /** + * Builds the CircuitBreaker. + * + * @return a new CircuitBreaker instance + */ + public CircuitBreaker build() { + return new CircuitBreaker(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreakerOpenException.java b/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreakerOpenException.java new file mode 100644 index 000000000..de210de73 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/resilience/CircuitBreakerOpenException.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +/** + * Exception thrown when a circuit breaker is in the OPEN state and rejecting calls. This indicates + * that the downstream service has experienced too many failures and the circuit breaker is + * protecting the system from further load. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class CircuitBreakerOpenException extends RuntimeException { + + /** Creates a new CircuitBreakerOpenException with a default message. */ + public CircuitBreakerOpenException() { + super("Circuit breaker is OPEN — calls are being rejected to protect the downstream service"); + } + + /** + * Creates a new CircuitBreakerOpenException with a custom message. + * + * @param message the detail message + */ + public CircuitBreakerOpenException(String message) { + super(message); + } + + /** + * Creates a new CircuitBreakerOpenException with a message and cause. + * + * @param message the detail message + * @param cause the underlying cause + */ + public CircuitBreakerOpenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/resilience/ResilientService.java b/core/src/main/java/com/google/adk/transcription/resilience/ResilientService.java new file mode 100644 index 000000000..ad02958fd --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/resilience/ResilientService.java @@ -0,0 +1,154 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.util.concurrent.Callable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Composes a {@link RetryPolicy} and a {@link CircuitBreaker} into a single resilient execution + * wrapper. The circuit breaker wraps the retry policy, which wraps the actual operation: + * + *
+ *   CircuitBreaker → RetryPolicy → Operation
+ * 
+ * + *

This ensures that: + * + *

    + *
  • Transient failures are retried within the retry policy's limits. + *
  • Persistent failures trip the circuit breaker to prevent cascading failures. + *
  • When the circuit is open, calls are rejected immediately without retries. + *
+ * + *

Usage example: + * + *

{@code
+ * ResilientService resilient = ResilientService.builder()
+ *     .retryPolicy(RetryPolicy.builder().maxAttempts(3).build())
+ *     .circuitBreaker(CircuitBreaker.builder().failureThreshold(5).build())
+ *     .build();
+ *
+ * byte[] result = resilient.execute(() -> callRemoteService());
+ * }
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class ResilientService { + + private static final Logger logger = LoggerFactory.getLogger(ResilientService.class); + + private final RetryPolicy retryPolicy; + private final CircuitBreaker circuitBreaker; + + private ResilientService(Builder builder) { + this.retryPolicy = builder.retryPolicy; + this.circuitBreaker = builder.circuitBreaker; + } + + /** + * Executes the given operation with circuit breaker protection and retry logic. The circuit + * breaker evaluates whether the call should proceed, and if so, the retry policy handles + * transient failures. + * + * @param the return type of the operation + * @param operation the operation to execute + * @return the result of the operation + * @throws Exception if the circuit is open or all retries are exhausted + */ + public T execute(Callable operation) throws Exception { + return circuitBreaker.execute(() -> retryPolicy.execute(operation)); + } + + /** + * Executes the given operation asynchronously with circuit breaker protection and retry logic, + * wrapped in an RxJava Single. + * + * @param the return type of the operation + * @param operation the operation to execute + * @return a Single that emits the result or an error + */ + public Single executeAsync(Callable operation) { + return Single.fromCallable(() -> execute(operation)).subscribeOn(Schedulers.io()); + } + + /** Returns the configured retry policy. */ + public RetryPolicy getRetryPolicy() { + return retryPolicy; + } + + /** Returns the configured circuit breaker. */ + public CircuitBreaker getCircuitBreaker() { + return circuitBreaker; + } + + /** + * Creates a new builder for ResilientService. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link ResilientService}. */ + public static class Builder { + private RetryPolicy retryPolicy = RetryPolicy.builder().build(); + private CircuitBreaker circuitBreaker = CircuitBreaker.builder().build(); + + /** + * Sets the retry policy. + * + * @param retryPolicy the retry policy to use + * @return this builder + */ + public Builder retryPolicy(RetryPolicy retryPolicy) { + if (retryPolicy == null) { + throw new IllegalArgumentException("retryPolicy must not be null"); + } + this.retryPolicy = retryPolicy; + return this; + } + + /** + * Sets the circuit breaker. + * + * @param circuitBreaker the circuit breaker to use + * @return this builder + */ + public Builder circuitBreaker(CircuitBreaker circuitBreaker) { + if (circuitBreaker == null) { + throw new IllegalArgumentException("circuitBreaker must not be null"); + } + this.circuitBreaker = circuitBreaker; + return this; + } + + /** + * Builds the ResilientService. + * + * @return a new ResilientService instance + */ + public ResilientService build() { + return new ResilientService(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/resilience/RetryPolicy.java b/core/src/main/java/com/google/adk/transcription/resilience/RetryPolicy.java new file mode 100644 index 000000000..4b714eb98 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/resilience/RetryPolicy.java @@ -0,0 +1,224 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.util.concurrent.Callable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configurable retry policy with exponential backoff. Provides both synchronous and asynchronous + * (RxJava-based) retry execution for transient failure recovery. + * + *

Usage example: + * + *

{@code
+ * RetryPolicy policy = RetryPolicy.builder()
+ *     .maxAttempts(5)
+ *     .initialDelayMs(1000)
+ *     .backoffMultiplier(2.0)
+ *     .maxDelayMs(10000)
+ *     .build();
+ *
+ * String result = policy.execute(() -> callRemoteService());
+ * }
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class RetryPolicy { + + private static final Logger logger = LoggerFactory.getLogger(RetryPolicy.class); + + private final int maxAttempts; + private final long initialDelayMs; + private final double backoffMultiplier; + private final long maxDelayMs; + + private RetryPolicy(Builder builder) { + this.maxAttempts = builder.maxAttempts; + this.initialDelayMs = builder.initialDelayMs; + this.backoffMultiplier = builder.backoffMultiplier; + this.maxDelayMs = builder.maxDelayMs; + } + + /** + * Executes the given operation with retry logic and exponential backoff. + * + * @param the return type of the operation + * @param operation the operation to execute + * @return the result of the operation + * @throws Exception if all retry attempts are exhausted + */ + public T execute(Callable operation) throws Exception { + Exception lastException = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return operation.call(); + } catch (Exception e) { + lastException = e; + if (attempt < maxAttempts) { + long delay = calculateDelay(attempt); + logger.warn( + "Retry attempt {}/{} failed, retrying in {}ms. Error: {}", + attempt, + maxAttempts, + delay, + e.getMessage()); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new Exception("Retry interrupted", ie); + } + } else { + logger.error( + "All {} retry attempts exhausted. Last error: {}", maxAttempts, e.getMessage()); + } + } + } + + throw lastException; + } + + /** + * Executes the given operation asynchronously with retry logic, wrapped in an RxJava Single. + * + * @param the return type of the operation + * @param operation the operation to execute + * @return a Single that emits the result or an error after all retries are exhausted + */ + public Single executeAsync(Callable operation) { + return Single.fromCallable(() -> execute(operation)).subscribeOn(Schedulers.io()); + } + + /** + * Calculates the delay for the given attempt using exponential backoff. + * + * @param attempt the current attempt number (1-based) + * @return the delay in milliseconds, capped at maxDelayMs + */ + long calculateDelay(int attempt) { + long delay = (long) (initialDelayMs * Math.pow(backoffMultiplier, attempt - 1)); + return Math.min(delay, maxDelayMs); + } + + /** Returns the maximum number of attempts. */ + public int getMaxAttempts() { + return maxAttempts; + } + + /** Returns the initial delay in milliseconds. */ + public long getInitialDelayMs() { + return initialDelayMs; + } + + /** Returns the backoff multiplier. */ + public double getBackoffMultiplier() { + return backoffMultiplier; + } + + /** Returns the maximum delay in milliseconds. */ + public long getMaxDelayMs() { + return maxDelayMs; + } + + /** + * Creates a new builder for RetryPolicy. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link RetryPolicy}. */ + public static class Builder { + private int maxAttempts = 3; + private long initialDelayMs = 500; + private double backoffMultiplier = 2.0; + private long maxDelayMs = 5000; + + /** + * Sets the maximum number of attempts (including the initial attempt). + * + * @param maxAttempts maximum attempts, must be at least 1 + * @return this builder + */ + public Builder maxAttempts(int maxAttempts) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Sets the initial delay before the first retry. + * + * @param initialDelayMs initial delay in milliseconds, must be non-negative + * @return this builder + */ + public Builder initialDelayMs(long initialDelayMs) { + if (initialDelayMs < 0) { + throw new IllegalArgumentException("initialDelayMs must be non-negative"); + } + this.initialDelayMs = initialDelayMs; + return this; + } + + /** + * Sets the backoff multiplier applied to the delay after each retry. + * + * @param backoffMultiplier multiplier, must be at least 1.0 + * @return this builder + */ + public Builder backoffMultiplier(double backoffMultiplier) { + if (backoffMultiplier < 1.0) { + throw new IllegalArgumentException("backoffMultiplier must be at least 1.0"); + } + this.backoffMultiplier = backoffMultiplier; + return this; + } + + /** + * Sets the maximum delay between retries. + * + * @param maxDelayMs maximum delay in milliseconds, must be non-negative + * @return this builder + */ + public Builder maxDelayMs(long maxDelayMs) { + if (maxDelayMs < 0) { + throw new IllegalArgumentException("maxDelayMs must be non-negative"); + } + this.maxDelayMs = maxDelayMs; + return this; + } + + /** + * Builds the RetryPolicy. + * + * @return a new RetryPolicy instance + */ + public RetryPolicy build() { + return new RetryPolicy(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/strategy/OllamaWhisperSttService.java b/core/src/main/java/com/google/adk/transcription/strategy/OllamaWhisperSttService.java new file mode 100644 index 000000000..da5b8a46c --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/strategy/OllamaWhisperSttService.java @@ -0,0 +1,435 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.strategy; + +import com.google.adk.transcription.ServiceHealth; +import com.google.adk.transcription.ServiceType; +import com.google.adk.transcription.TranscriptionConfig; +import com.google.adk.transcription.TranscriptionEvent; +import com.google.adk.transcription.TranscriptionException; +import com.google.adk.transcription.TranscriptionResult; +import com.google.adk.transcription.TranscriptionService; +import com.google.adk.transcription.processor.AudioChunkAggregator; +import com.google.adk.transcription.resilience.CircuitBreaker; +import com.google.adk.transcription.resilience.ResilientService; +import com.google.adk.transcription.resilience.RetryPolicy; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Ollama/Whisper-compatible STT service that calls any server exposing the OpenAI-compatible {@code + * /v1/audio/transcriptions} endpoint. + * + *

Compatible with: + * + *

    + *
  • faster-whisper-server + *
  • whisper.cpp HTTP server + *
  • Ollama with whisper support + *
  • Any OpenAI-compatible audio transcription API + *
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class OllamaWhisperSttService implements TranscriptionService { + + private static final Logger logger = LoggerFactory.getLogger(OllamaWhisperSttService.class); + + private static final String DEFAULT_MODEL = "whisper-1"; + private static final String TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"; + private static final String BOUNDARY = "----ADKMultipartBoundary" + System.currentTimeMillis(); + private static final String CRLF = "\r\n"; + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 60000; + + private final String endpoint; + private final String model; + private final Optional apiKey; + private volatile ResilientService resilientService; + + /** + * Creates a new OllamaWhisperSttService. + * + * @param endpoint Base URL of the transcription server (e.g., "http://localhost:8080") + * @param model Model name to use (e.g., "whisper-1"), or null for default + * @param apiKey Optional API key for authentication, or null if not required + */ + public OllamaWhisperSttService(String endpoint, String model, String apiKey) { + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException("Endpoint is required"); + } + // Strip trailing slash + this.endpoint = + endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + this.model = (model != null && !model.isEmpty()) ? model : DEFAULT_MODEL; + this.apiKey = Optional.ofNullable(apiKey); + logger.info( + "Initialized OllamaWhisperSttService with endpoint={}, model={}", + this.endpoint, + this.model); + } + + /** + * Creates a new OllamaWhisperSttService with default model and no API key. + * + * @param endpoint Base URL of the transcription server + */ + public OllamaWhisperSttService(String endpoint) { + this(endpoint, null, null); + } + + @Override + public TranscriptionResult transcribe(byte[] audioData, TranscriptionConfig config) + throws TranscriptionException { + if (audioData == null || audioData.length == 0) { + throw new TranscriptionException("Audio data is null or empty"); + } + + if (resilientService != null) { + try { + return resilientService.execute(() -> doTranscribe(audioData, config)); + } catch (TranscriptionException e) { + throw e; + } catch (Exception e) { + throw new TranscriptionException("Resilient transcription failed: " + e.getMessage(), e); + } + } + + return doTranscribe(audioData, config); + } + + private TranscriptionResult doTranscribe(byte[] audioData, TranscriptionConfig config) + throws TranscriptionException { + try { + String transcriptionUrl = endpoint + TRANSCRIPTIONS_PATH; + logger.debug( + "Sending {} bytes to {} with model={}", audioData.length, transcriptionUrl, model); + + HttpURLConnection connection = createMultipartConnection(transcriptionUrl); + writeMultipartBody(connection, audioData, config); + + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + String errorBody = readErrorResponse(connection); + logger.error("Transcription request failed with status {}: {}", responseCode, errorBody); + throw new TranscriptionException( + String.format("Transcription failed with HTTP %d: %s", responseCode, errorBody)); + } + + String responseBody = readResponse(connection); + logger.debug("Transcription response: {}", responseBody); + + return parseTranscriptionResponse(responseBody, config); + } catch (TranscriptionException e) { + throw e; + } catch (Exception e) { + logger.error("Error during transcription", e); + throw new TranscriptionException("Transcription failed: " + e.getMessage(), e); + } + } + + @Override + public Single transcribeAsync(byte[] audioData, TranscriptionConfig config) { + return Single.fromCallable(() -> transcribe(audioData, config)).subscribeOn(Schedulers.io()); + } + + @Override + public Flowable transcribeStream( + Flowable audioStream, TranscriptionConfig config) { + AudioChunkAggregator aggregator = + new AudioChunkAggregator( + config.getAudioFormat(), Duration.ofMillis(config.getChunkSizeMs())); + + return audioStream + .buffer(config.getChunkSizeMs(), TimeUnit.MILLISECONDS) + .map( + chunks -> { + byte[] aggregated = aggregator.aggregate(chunks); + try { + return transcribe(aggregated, config); + } catch (TranscriptionException e) { + logger.error("Stream transcription error", e); + throw new RuntimeException(e); + } + }) + .map(this::mapToTranscriptionEvent); + } + + @Override + public boolean isAvailable() { + try { + URL url = new URL(endpoint); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(CONNECT_TIMEOUT_MS); + apiKey.ifPresent(key -> connection.setRequestProperty("Authorization", "Bearer " + key)); + + int responseCode = connection.getResponseCode(); + connection.disconnect(); + + // Consider any non-5xx response as available (server is reachable) + boolean available = responseCode < 500; + logger.debug("Availability check for {}: {} (HTTP {})", endpoint, available, responseCode); + return available; + } catch (Exception e) { + logger.debug("Availability check failed for {}: {}", endpoint, e.getMessage()); + return false; + } + } + + @Override + public ServiceType getServiceType() { + return ServiceType.OLLAMA_WHISPER; + } + + @Override + public ServiceHealth getHealth() { + long startTime = System.currentTimeMillis(); + boolean available = isAvailable(); + long responseTime = System.currentTimeMillis() - startTime; + + return ServiceHealth.builder() + .available(available) + .serviceType(ServiceType.OLLAMA_WHISPER) + .responseTimeMs(responseTime) + .message(available ? "Service reachable" : "Service unreachable") + .build(); + } + + /** + * Configures this service with resilience support (retry and circuit breaker). Returns this + * instance for fluent configuration. If either parameter is null, the corresponding default + * policy is used. + * + * @param retry the retry policy, or null for default + * @param cb the circuit breaker, or null for default + * @return this service instance configured with resilience + */ + public OllamaWhisperSttService withResilience(RetryPolicy retry, CircuitBreaker cb) { + ResilientService.Builder builder = ResilientService.builder(); + if (retry != null) { + builder.retryPolicy(retry); + } + if (cb != null) { + builder.circuitBreaker(cb); + } + this.resilientService = builder.build(); + logger.info("Resilience configured for STT service at endpoint: {}", endpoint); + return this; + } + + // ---- Private helper methods ---- + + private HttpURLConnection createMultipartConnection(String url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY); + apiKey.ifPresent(key -> connection.setRequestProperty("Authorization", "Bearer " + key)); + return connection; + } + + private void writeMultipartBody( + HttpURLConnection connection, byte[] audioData, TranscriptionConfig config) + throws IOException { + try (OutputStream outputStream = connection.getOutputStream()) { + // File field + writeMultipartFileField(outputStream, "file", "audio.wav", "audio/wav", audioData); + + // Model field + writeMultipartTextField(outputStream, "model", model); + + // Response format field + writeMultipartTextField(outputStream, "response_format", "json"); + + // Language field (optional) + String language = config.getLanguage(); + if (language != null && !language.isEmpty() && !"auto".equalsIgnoreCase(language)) { + writeMultipartTextField(outputStream, "language", language); + } + + // End boundary + outputStream.write(("--" + BOUNDARY + "--" + CRLF).getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } + } + + private void writeMultipartTextField(OutputStream out, String fieldName, String value) + throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("--").append(BOUNDARY).append(CRLF); + sb.append("Content-Disposition: form-data; name=\"") + .append(fieldName) + .append("\"") + .append(CRLF); + sb.append(CRLF); + sb.append(value).append(CRLF); + out.write(sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + private void writeMultipartFileField( + OutputStream out, String fieldName, String fileName, String mimeType, byte[] fileData) + throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("--").append(BOUNDARY).append(CRLF); + sb.append("Content-Disposition: form-data; name=\"") + .append(fieldName) + .append("\"; filename=\"") + .append(fileName) + .append("\"") + .append(CRLF); + sb.append("Content-Type: ").append(mimeType).append(CRLF); + sb.append(CRLF); + out.write(sb.toString().getBytes(StandardCharsets.UTF_8)); + out.write(fileData); + out.write(CRLF.getBytes(StandardCharsets.UTF_8)); + } + + private String readResponse(HttpURLConnection connection) throws IOException { + try (InputStream inputStream = connection.getInputStream()) { + return readStream(inputStream); + } + } + + private String readErrorResponse(HttpURLConnection connection) { + try { + InputStream errorStream = connection.getErrorStream(); + if (errorStream != null) { + return readStream(errorStream); + } + } catch (IOException e) { + logger.debug("Could not read error stream", e); + } + return "No error body"; + } + + private String readStream(InputStream inputStream) throws IOException { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + return result.toString(StandardCharsets.UTF_8.name()); + } + + private TranscriptionResult parseTranscriptionResponse( + String responseBody, TranscriptionConfig config) throws TranscriptionException { + // Parse minimal JSON: {"text": "..."} or {"text": "...", "segments": [...]} + // Using simple parsing to avoid external JSON dependency + String text = extractJsonStringField(responseBody, "text"); + if (text == null) { + throw new TranscriptionException( + "Failed to parse transcription response: no 'text' field found in: " + responseBody); + } + + TranscriptionResult.Builder builder = TranscriptionResult.builder().text(text); + + // Set language if available from config + String language = config.getLanguage(); + if (language != null && !language.isEmpty() && !"auto".equalsIgnoreCase(language)) { + builder.language(language); + } + + // Try to extract language from response (some APIs return it) + String responseLanguage = extractJsonStringField(responseBody, "language"); + if (responseLanguage != null && !responseLanguage.isEmpty()) { + builder.language(responseLanguage); + } + + return builder.build(); + } + + /** + * Extracts a string field value from a JSON string using simple parsing. This avoids requiring an + * external JSON library for this single use case. + * + * @param json JSON string + * @param fieldName Field name to extract + * @return Field value or null if not found + */ + private String extractJsonStringField(String json, String fieldName) { + // Look for "fieldName": "value" or "fieldName":"value" + String searchKey = "\"" + fieldName + "\""; + int keyIndex = json.indexOf(searchKey); + if (keyIndex == -1) { + return null; + } + + int colonIndex = json.indexOf(':', keyIndex + searchKey.length()); + if (colonIndex == -1) { + return null; + } + + // Find the opening quote of the value + int valueStart = json.indexOf('"', colonIndex + 1); + if (valueStart == -1) { + return null; + } + + // Find the closing quote, handling escaped quotes + int valueEnd = valueStart + 1; + while (valueEnd < json.length()) { + char c = json.charAt(valueEnd); + if (c == '\\') { + valueEnd += 2; // Skip escaped character + } else if (c == '"') { + break; + } else { + valueEnd++; + } + } + + if (valueEnd >= json.length()) { + return null; + } + + // Unescape basic JSON escape sequences + String raw = json.substring(valueStart + 1, valueEnd); + return raw.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } + + private TranscriptionEvent mapToTranscriptionEvent(TranscriptionResult result) { + return TranscriptionEvent.builder() + .text(result.getText()) + .finished(true) + .timestamp(result.getTimestamp()) + .language(result.getLanguage().orElse(null)) + .build(); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/strategy/StreamingWhisperSttService.java b/core/src/main/java/com/google/adk/transcription/strategy/StreamingWhisperSttService.java new file mode 100644 index 000000000..8993d8af9 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/strategy/StreamingWhisperSttService.java @@ -0,0 +1,592 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.strategy; + +import com.google.adk.transcription.TranscriptionConfig; +import com.google.adk.transcription.TranscriptionEvent; +import com.google.adk.transcription.TranscriptionException; +import com.google.adk.transcription.TranscriptionResult; +import io.reactivex.rxjava3.core.BackpressureStrategy; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.FlowableEmitter; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.ConnectException; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WebSocket-based streaming STT service that extends {@link OllamaWhisperSttService} with + * real-time, low-latency transcription via WebSocket connections. + * + *

Connects to a streaming transcription endpoint at {@code + * {endpoint}/v1/audio/transcriptions/stream} using WebSocket protocol: + * + *

    + *
  • Sends binary audio chunks as WebSocket binary frames + *
  • Receives JSON text frames with format: {@code {"text": "...", "is_final": true/false}} + *
  • Emits {@link TranscriptionEvent} for each partial/final result + *
+ * + *

Features: + * + *

    + *
  • Automatic reconnection with buffering on WebSocket disconnect + *
  • 10-second inactivity timeout with error event emission + *
  • Graceful fallback to batch mode if WebSocket endpoint is unavailable (404, connection + * refused) + *
  • Thread-safe, supports concurrent streams + *
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class StreamingWhisperSttService extends OllamaWhisperSttService { + + private static final Logger logger = LoggerFactory.getLogger(StreamingWhisperSttService.class); + + private static final String STREAMING_PATH = "/v1/audio/transcriptions/stream"; + private static final long RESPONSE_TIMEOUT_MS = 10_000; + private static final int MAX_RECONNECT_ATTEMPTS = 3; + private static final long RECONNECT_DELAY_MS = 1_000; + + private final OkHttpClient client; + private final String wsEndpoint; + + /** + * Creates a new StreamingWhisperSttService. + * + * @param endpoint Base URL of the transcription server (e.g., "http://localhost:8080") + * @param model Model name to use (e.g., "whisper-1"), or null for default + * @param apiKey Optional API key for authentication, or null if not required + * @param client OkHttpClient instance for WebSocket connections + */ + public StreamingWhisperSttService( + String endpoint, String model, String apiKey, OkHttpClient client) { + super(endpoint, model, apiKey); + if (client == null) { + throw new IllegalArgumentException("OkHttpClient is required"); + } + this.client = client; + + // Convert http(s) to ws(s) for WebSocket endpoint + String normalizedEndpoint = + endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + if (normalizedEndpoint.startsWith("https://")) { + this.wsEndpoint = "wss://" + normalizedEndpoint.substring(8) + STREAMING_PATH; + } else if (normalizedEndpoint.startsWith("http://")) { + this.wsEndpoint = "ws://" + normalizedEndpoint.substring(7) + STREAMING_PATH; + } else { + this.wsEndpoint = "ws://" + normalizedEndpoint + STREAMING_PATH; + } + + logger.info( + "Initialized StreamingWhisperSttService with WebSocket endpoint={}", this.wsEndpoint); + } + + /** + * Streams transcription results via WebSocket for real-time, low-latency transcription. Opens a + * WebSocket connection, sends binary audio chunks as they arrive, and emits transcription events + * for each partial/final result. + * + *

If the WebSocket endpoint is unavailable (404, connection refused), falls back to batch mode + * using the parent class's transcribe() method. + * + * @param audioStream Flowable of audio chunks + * @param config Transcription configuration + * @return Flowable of transcription events + */ + @Override + public Flowable transcribeStream( + Flowable audioStream, TranscriptionConfig config) { + return Flowable.create( + emitter -> { + StreamingSession session = new StreamingSession(emitter, config); + session.start(audioStream); + }, + BackpressureStrategy.BUFFER) + .subscribeOn(Schedulers.io()); + } + + /** + * Internal session managing a single streaming transcription lifecycle. Thread-safe and handles + * reconnection, buffering, timeouts, and fallback. + */ + private class StreamingSession { + private final FlowableEmitter emitter; + private final TranscriptionConfig config; + private final AtomicBoolean completed = new AtomicBoolean(false); + private final AtomicBoolean fallbackMode = new AtomicBoolean(false); + private final AtomicReference activeWebSocket = new AtomicReference<>(); + private final AtomicLong lastResponseTime = new AtomicLong(System.currentTimeMillis()); + private final ConcurrentLinkedQueue reconnectBuffer = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean wsConnected = new AtomicBoolean(false); + private final CountDownLatch connectionLatch = new CountDownLatch(1); + private final AtomicBoolean connectionFailed = new AtomicBoolean(false); + + StreamingSession(FlowableEmitter emitter, TranscriptionConfig config) { + this.emitter = emitter; + this.config = config; + } + + void start(Flowable audioStream) { + // Attempt initial WebSocket connection + boolean connected = attemptConnection(0); + + if (!connected || connectionFailed.get()) { + // Fallback to batch mode + logger.warn("WebSocket connection failed, falling back to batch transcription mode"); + fallbackMode.set(true); + runBatchFallback(audioStream); + return; + } + + // Start timeout monitor + startTimeoutMonitor(); + + // Subscribe to audio stream and send chunks + audioStream + .subscribeOn(Schedulers.io()) + .subscribe(this::handleAudioChunk, this::handleStreamError, this::handleStreamComplete); + } + + private boolean attemptConnection(int attempt) { + if (attempt >= MAX_RECONNECT_ATTEMPTS) { + return false; + } + + try { + Request.Builder requestBuilder = new Request.Builder().url(wsEndpoint); + // Note: API key is set via parent's Optional apiKey field which is private. + // We reconstruct the header from the constructor parameter. + Request request = requestBuilder.build(); + + WebSocket ws = client.newWebSocket(request, new StreamingWebSocketListener()); + activeWebSocket.set(ws); + + // Wait for connection to be established or fail + boolean opened = connectionLatch.await(5, TimeUnit.SECONDS); + if (!opened || connectionFailed.get()) { + if (attempt < MAX_RECONNECT_ATTEMPTS - 1) { + Thread.sleep(RECONNECT_DELAY_MS); + return attemptConnection(attempt + 1); + } + return false; + } + + return wsConnected.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + private void handleAudioChunk(byte[] chunk) { + if (emitter.isCancelled() || completed.get()) { + return; + } + + if (!wsConnected.get()) { + // Buffer chunks during reconnection + reconnectBuffer.offer(chunk); + return; + } + + WebSocket ws = activeWebSocket.get(); + if (ws != null) { + // Send binary audio chunk + boolean sent = ws.send(ByteString.of(chunk, 0, chunk.length)); + if (!sent) { + // WebSocket send failed, buffer for reconnect + reconnectBuffer.offer(chunk); + attemptReconnection(); + } + } + } + + private void handleStreamError(Throwable error) { + if (completed.compareAndSet(false, true)) { + logger.error("Audio stream error", error); + closeWebSocket(); + if (!emitter.isCancelled()) { + emitter.onError(error); + } + } + } + + private void handleStreamComplete() { + if (completed.compareAndSet(false, true)) { + logger.debug("Audio stream completed, closing WebSocket"); + closeWebSocket(); + if (!emitter.isCancelled()) { + emitter.onComplete(); + } + } + } + + private void closeWebSocket() { + WebSocket ws = activeWebSocket.getAndSet(null); + if (ws != null) { + ws.close(1000, "Stream completed"); + } + } + + private void attemptReconnection() { + if (completed.get() || emitter.isCancelled()) { + return; + } + + wsConnected.set(false); + logger.info("WebSocket disconnected, attempting reconnection..."); + + Schedulers.io() + .scheduleDirect( + () -> { + for (int attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) { + if (completed.get() || emitter.isCancelled()) { + return; + } + + try { + Thread.sleep(RECONNECT_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + Request request = new Request.Builder().url(wsEndpoint).build(); + CountDownLatch reconnectLatch = new CountDownLatch(1); + AtomicBoolean reconnected = new AtomicBoolean(false); + + WebSocket ws = + client.newWebSocket( + request, + new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + reconnected.set(true); + reconnectLatch.countDown(); + } + + @Override + public void onFailure( + WebSocket webSocket, Throwable t, Response response) { + reconnectLatch.countDown(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + processTextFrame(text); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + webSocket.close(code, reason); + wsConnected.set(false); + if (!completed.get()) { + attemptReconnection(); + } + } + }); + + try { + reconnectLatch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + if (reconnected.get()) { + activeWebSocket.set(ws); + wsConnected.set(true); + logger.info("WebSocket reconnected successfully"); + + // Flush buffered chunks + byte[] buffered; + while ((buffered = reconnectBuffer.poll()) != null) { + ws.send(ByteString.of(buffered, 0, buffered.length)); + } + return; + } + } + + // All reconnection attempts failed + logger.error( + "WebSocket reconnection failed after {} attempts", MAX_RECONNECT_ATTEMPTS); + if (!completed.get() && !emitter.isCancelled()) { + emitter.onError( + new TranscriptionException( + "WebSocket reconnection failed after " + + MAX_RECONNECT_ATTEMPTS + + " attempts")); + completed.set(true); + } + }); + } + + private void startTimeoutMonitor() { + Schedulers.io() + .scheduleDirect( + () -> { + while (!completed.get() && !emitter.isCancelled()) { + try { + Thread.sleep(1_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + long elapsed = System.currentTimeMillis() - lastResponseTime.get(); + if (elapsed > RESPONSE_TIMEOUT_MS && wsConnected.get()) { + logger.warn("No response received for {} ms, emitting timeout error", elapsed); + if (!emitter.isCancelled()) { + emitter.onNext( + TranscriptionEvent.builder() + .text( + "[TIMEOUT] No transcription response for " + + (RESPONSE_TIMEOUT_MS / 1000) + + " seconds") + .finished(false) + .build()); + } + // Reset the timer to avoid spamming error events + lastResponseTime.set(System.currentTimeMillis()); + } + } + }); + } + + private void processTextFrame(String text) { + lastResponseTime.set(System.currentTimeMillis()); + + if (emitter.isCancelled() || completed.get()) { + return; + } + + try { + // Parse JSON: {"text": "...", "is_final": true/false} + String transcribedText = extractJsonStringField(text, "text"); + boolean isFinal = extractJsonBooleanField(text, "is_final"); + + if (transcribedText != null && !transcribedText.isEmpty()) { + TranscriptionEvent event = + TranscriptionEvent.builder().text(transcribedText).finished(isFinal).build(); + emitter.onNext(event); + } + } catch (Exception e) { + logger.warn("Failed to parse WebSocket text frame: {}", text, e); + } + } + + private void runBatchFallback(Flowable audioStream) { + ByteArrayOutputStream accumulator = new ByteArrayOutputStream(); + + audioStream + .subscribeOn(Schedulers.io()) + .subscribe( + chunk -> { + try { + accumulator.write(chunk); + } catch (IOException e) { + logger.error("Error accumulating audio chunks", e); + } + }, + error -> { + if (!emitter.isCancelled()) { + emitter.onError(error); + } + }, + () -> { + // Stream complete - do batch transcription with parent + try { + byte[] allAudio = accumulator.toByteArray(); + if (allAudio.length > 0) { + TranscriptionResult result = transcribe(allAudio, config); + emitter.onNext( + TranscriptionEvent.builder() + .text(result.getText()) + .finished(true) + .language(result.getLanguage().orElse(null)) + .build()); + } + emitter.onComplete(); + } catch (TranscriptionException e) { + logger.error("Batch fallback transcription failed", e); + if (!emitter.isCancelled()) { + emitter.onError(e); + } + } + }); + } + + /** WebSocket listener for the streaming session. */ + private class StreamingWebSocketListener extends WebSocketListener { + + @Override + public void onOpen(WebSocket webSocket, Response response) { + logger.debug("WebSocket connected to {}", wsEndpoint); + wsConnected.set(true); + connectionLatch.countDown(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + processTextFrame(text); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + logger.debug("WebSocket closing: code={}, reason={}", code, reason); + webSocket.close(code, reason); + wsConnected.set(false); + + if (!completed.get() && !emitter.isCancelled()) { + attemptReconnection(); + } + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + logger.debug("WebSocket closed: code={}, reason={}", code, reason); + wsConnected.set(false); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + int responseCode = (response != null) ? response.code() : -1; + logger.warn("WebSocket failure: {} (HTTP {})", t.getMessage(), responseCode); + + wsConnected.set(false); + + // Check if this is an initial connection failure that should trigger fallback + if (connectionLatch.getCount() > 0) { + // Connection was never established + boolean isFallbackScenario = + (t instanceof ConnectException) + || responseCode == 404 + || responseCode == 502 + || responseCode == 503; + + if (isFallbackScenario) { + connectionFailed.set(true); + } + connectionLatch.countDown(); + } else if (!completed.get() && !emitter.isCancelled()) { + // Connection was previously established; attempt reconnection + attemptReconnection(); + } + } + } + } + + // ---- JSON utility methods ---- + + /** + * Extracts a string field value from a JSON string using simple parsing. + * + * @param json JSON string + * @param fieldName Field name to extract + * @return Field value or null if not found + */ + private static String extractJsonStringField(String json, String fieldName) { + String searchKey = "\"" + fieldName + "\""; + int keyIndex = json.indexOf(searchKey); + if (keyIndex == -1) { + return null; + } + + int colonIndex = json.indexOf(':', keyIndex + searchKey.length()); + if (colonIndex == -1) { + return null; + } + + int valueStart = json.indexOf('"', colonIndex + 1); + if (valueStart == -1) { + return null; + } + + int valueEnd = valueStart + 1; + while (valueEnd < json.length()) { + char c = json.charAt(valueEnd); + if (c == '\\') { + valueEnd += 2; + } else if (c == '"') { + break; + } else { + valueEnd++; + } + } + + if (valueEnd >= json.length()) { + return null; + } + + String raw = json.substring(valueStart + 1, valueEnd); + return raw.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } + + /** + * Extracts a boolean field value from a JSON string using simple parsing. + * + * @param json JSON string + * @param fieldName Field name to extract + * @return Field value (defaults to false if not found) + */ + private static boolean extractJsonBooleanField(String json, String fieldName) { + String searchKey = "\"" + fieldName + "\""; + int keyIndex = json.indexOf(searchKey); + if (keyIndex == -1) { + return false; + } + + int colonIndex = json.indexOf(':', keyIndex + searchKey.length()); + if (colonIndex == -1) { + return false; + } + + // Skip whitespace after colon + int valueStart = colonIndex + 1; + while (valueStart < json.length() && json.charAt(valueStart) == ' ') { + valueStart++; + } + + if (valueStart >= json.length()) { + return false; + } + + // Check for true/false + String remaining = json.substring(valueStart).trim(); + return remaining.startsWith("true"); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/strategy/TranscriptionServiceFactory.java b/core/src/main/java/com/google/adk/transcription/strategy/TranscriptionServiceFactory.java index 9260e7849..0e1414f27 100644 --- a/core/src/main/java/com/google/adk/transcription/strategy/TranscriptionServiceFactory.java +++ b/core/src/main/java/com/google/adk/transcription/strategy/TranscriptionServiceFactory.java @@ -22,6 +22,7 @@ import com.google.adk.transcription.client.WhisperApiClient; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +import okhttp3.OkHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -92,6 +93,9 @@ private static TranscriptionService createService(TranscriptionConfig config) { case WHISPER: return createWhisperService(config); + case OLLAMA_WHISPER: + return createOllamaWhisperService(config); + case GEMINI: throw new UnsupportedOperationException("Gemini transcription not yet implemented"); @@ -111,6 +115,9 @@ private static ServiceType determineServiceType(TranscriptionConfig config) { String endpoint = config.getEndpoint(); if (endpoint != null) { String lowerEndpoint = endpoint.toLowerCase(); + if (lowerEndpoint.contains("ollama")) { + return ServiceType.OLLAMA_WHISPER; + } if (lowerEndpoint.contains("whisper") || lowerEndpoint.contains("transcribe")) { return ServiceType.WHISPER; } @@ -131,6 +138,25 @@ private static TranscriptionService createWhisperService(TranscriptionConfig con return new WhisperTranscriptionService(client, config); } + private static TranscriptionService createOllamaWhisperService(TranscriptionConfig config) { + String endpoint = config.getEndpoint(); + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException("Ollama/Whisper endpoint is required"); + } + + String apiKey = config.getApiKey().orElse(null); + + // Check if streaming mode is enabled via environment variable + String streamingEnabled = System.getenv("ADK_TRANSCRIPTION_STREAMING"); + if ("true".equalsIgnoreCase(streamingEnabled)) { + logger.info("Streaming mode enabled, creating StreamingWhisperSttService"); + OkHttpClient httpClient = new OkHttpClient(); + return new StreamingWhisperSttService(endpoint, null, apiKey, httpClient); + } + + return new OllamaWhisperSttService(endpoint, null, apiKey); + } + private static String generateCacheKey(TranscriptionConfig config) { return String.format( "%s:%s:%s", determineServiceType(config), config.getEndpoint(), config.getLanguage()); diff --git a/core/src/main/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsService.java b/core/src/main/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsService.java new file mode 100644 index 000000000..24e7e9e72 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsService.java @@ -0,0 +1,579 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import com.google.adk.transcription.ServiceHealth; +import com.google.adk.transcription.ServiceType; +import com.google.adk.transcription.resilience.CircuitBreaker; +import com.google.adk.transcription.resilience.ResilientService; +import com.google.adk.transcription.resilience.RetryPolicy; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TTS service implementation compatible with the OpenAI /v1/audio/speech API. Works with any server + * exposing the OpenAI TTS endpoint, including Piper, AllTalk, Kokoro, and actual OpenAI. + * + *

Uses OkHttpClient for HTTP communication. Supports synchronous, asynchronous (via RxJava + * Single), and streaming (via RxJava Flowable) synthesis modes. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class OpenAiCompatibleTtsService implements TtsService { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiCompatibleTtsService.class); + private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json; charset=utf-8"); + private static final String SPEECH_PATH = "/v1/audio/speech"; + private static final String CAPABILITIES_PATH = "/v1/audio/speech/capabilities"; + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; + private static final int DEFAULT_READ_TIMEOUT_SECONDS = 60; + private static final int DEFAULT_WRITE_TIMEOUT_SECONDS = 30; + private static final int STREAM_CHUNK_SIZE = 4096; + private static final int HEALTH_CHECK_TIMEOUT_SECONDS = 5; + private static final int CAPABILITIES_TIMEOUT_SECONDS = 5; + + /** Preferred format fallback order when the requested format is not supported. */ + private static final List FORMAT_FALLBACK_ORDER = + Arrays.asList(TtsAudioFormat.MP3, TtsAudioFormat.WAV, TtsAudioFormat.PCM, TtsAudioFormat.OGG); + + private final String endpoint; + private final Optional apiKey; + private final OkHttpClient httpClient; + private final AtomicReference cachedCapabilities = new AtomicReference<>(); + private volatile ResilientService resilientService; + + /** + * Creates an OpenAI-compatible TTS service. + * + * @param endpoint the base URL of the TTS server (e.g., "http://localhost:8000") + */ + public OpenAiCompatibleTtsService(String endpoint) { + this(endpoint, null); + } + + /** + * Creates an OpenAI-compatible TTS service with API key authentication. + * + * @param endpoint the base URL of the TTS server (e.g., "https://api.openai.com") + * @param apiKey the API key for authentication, or null if not required + */ + public OpenAiCompatibleTtsService(String endpoint, String apiKey) { + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException("Endpoint must not be null or empty"); + } + // Strip trailing slash for consistent URL building + this.endpoint = + endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + this.apiKey = Optional.ofNullable(apiKey); + this.httpClient = + new OkHttpClient.Builder() + .connectTimeout(DEFAULT_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + + logger.info( + "Initialized OpenAI-compatible TTS service at endpoint: {}, apiKey present: {}", + this.endpoint, + this.apiKey.isPresent()); + } + + @Override + public byte[] synthesize(String text, TtsConfig config) throws TtsException { + if (text == null || text.isEmpty()) { + throw new TtsException("Text must not be null or empty", "INVALID_INPUT"); + } + + if (resilientService != null) { + try { + return resilientService.execute(() -> doSynthesize(text, config)); + } catch (TtsException e) { + throw e; + } catch (Exception e) { + throw new TtsException("Resilient synthesis failed: " + e.getMessage(), e); + } + } + + return doSynthesize(text, config); + } + + private byte[] doSynthesize(String text, TtsConfig config) throws TtsException { + TtsCapabilities caps = capabilities(); + TtsAudioFormat negotiatedFormat = negotiateFormat(config, caps); + + String speechUrl = endpoint + SPEECH_PATH; + String jsonBody = buildRequestBody(text, config, negotiatedFormat); + + logger.debug( + "Synthesizing text ({} chars) via {} with format {}", + text.length(), + speechUrl, + negotiatedFormat.getValue()); + + Request request = buildHttpRequest(speechUrl, jsonBody); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorBody = extractErrorBody(response); + throw new TtsException( + String.format("TTS synthesis failed with HTTP %d: %s", response.code(), errorBody), + "HTTP_" + response.code()); + } + + ResponseBody body = response.body(); + if (body == null) { + throw new TtsException("Empty response body from TTS service", "EMPTY_RESPONSE"); + } + + byte[] audioData = body.bytes(); + logger.debug("Synthesis complete, received {} bytes", audioData.length); + return audioData; + + } catch (IOException e) { + throw new TtsException("Failed to communicate with TTS service: " + e.getMessage(), e); + } + } + + @Override + public Single synthesizeAsync(String text, TtsConfig config) { + return Single.fromCallable(() -> synthesize(text, config)).subscribeOn(Schedulers.io()); + } + + @Override + public Flowable synthesizeStream(String text, TtsConfig config) { + return Flowable.create( + emitter -> { + if (text == null || text.isEmpty()) { + emitter.onError( + new TtsException("Text must not be null or empty", "INVALID_INPUT")); + return; + } + + String speechUrl = endpoint + SPEECH_PATH; + String jsonBody = buildRequestBody(text, config); + Request request = buildHttpRequest(speechUrl, jsonBody); + + logger.debug( + "Starting streaming synthesis ({} chars) via {}", text.length(), speechUrl); + + Response response = null; + try { + response = httpClient.newCall(request).execute(); + + if (!response.isSuccessful()) { + String errorBody = extractErrorBody(response); + emitter.onError( + new TtsException( + String.format( + "TTS streaming failed with HTTP %d: %s", response.code(), errorBody), + "HTTP_" + response.code())); + return; + } + + ResponseBody body = response.body(); + if (body == null) { + emitter.onError( + new TtsException("Empty response body from TTS service", "EMPTY_RESPONSE")); + return; + } + + try (InputStream inputStream = body.byteStream()) { + byte[] buffer = new byte[STREAM_CHUNK_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + if (emitter.isCancelled()) { + logger.debug("Streaming synthesis cancelled by subscriber"); + break; + } + byte[] chunk = new byte[bytesRead]; + System.arraycopy(buffer, 0, chunk, 0, bytesRead); + emitter.onNext(chunk); + } + } + + if (!emitter.isCancelled()) { + emitter.onComplete(); + } + + } catch (IOException e) { + if (!emitter.isCancelled()) { + emitter.onError( + new TtsException("Failed to stream from TTS service: " + e.getMessage(), e)); + } + } finally { + if (response != null) { + response.close(); + } + } + }, + io.reactivex.rxjava3.core.BackpressureStrategy.BUFFER) + .subscribeOn(Schedulers.io()); + } + + @Override + public boolean isAvailable() { + try { + Request request = new Request.Builder().url(endpoint).head().build(); + + OkHttpClient healthClient = + httpClient + .newBuilder() + .connectTimeout(HEALTH_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(HEALTH_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + + try (Response response = healthClient.newCall(request).execute()) { + boolean available = response.isSuccessful(); + logger.debug("TTS service availability check: {} (HTTP {})", available, response.code()); + return available; + } + } catch (IOException e) { + logger.debug("TTS service unavailable: {}", e.getMessage()); + return false; + } + } + + @Override + public ServiceHealth getHealth() { + long startTime = System.currentTimeMillis(); + try { + Request request = new Request.Builder().url(endpoint).head().build(); + + OkHttpClient healthClient = + httpClient + .newBuilder() + .connectTimeout(HEALTH_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(HEALTH_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + + try (Response response = healthClient.newCall(request).execute()) { + long latency = System.currentTimeMillis() - startTime; + boolean available = response.isSuccessful(); + + return ServiceHealth.builder() + .available(available) + .serviceType(ServiceType.WHISPER) // Closest match for generic TTS + .responseTimeMs(latency) + .message( + available + ? "OpenAI-compatible TTS service is healthy" + : String.format("Service returned HTTP %d", response.code())) + .build(); + } + } catch (IOException e) { + long latency = System.currentTimeMillis() - startTime; + return ServiceHealth.builder() + .available(false) + .serviceType(ServiceType.WHISPER) + .responseTimeMs(latency) + .message("Health check failed: " + e.getMessage()) + .build(); + } + } + + /** + * Probes the TTS server for its capabilities by querying the capabilities endpoint. Results are + * cached after the first successful probe. + * + *

If the probe fails (404, timeout, or other error), returns default capabilities assuming all + * formats are supported with a maximum text length of 4096. + * + * @return the server's capabilities, possibly cached + */ + @Override + public TtsCapabilities capabilities() { + TtsCapabilities cached = cachedCapabilities.get(); + if (cached != null) { + return cached; + } + + TtsCapabilities probed = probeCapabilities(); + cachedCapabilities.compareAndSet(null, probed); + return cachedCapabilities.get(); + } + + /** + * Negotiates the audio output format based on the requested config and server capabilities. + * + *

If the requested format is supported, it is returned directly. Otherwise, formats are tried + * in the preferred fallback order: MP3, WAV, PCM, OGG. If no fallback is supported, the + * originally requested format is returned as a last resort. + * + * @param config the TTS configuration with the requested format + * @param caps the server's capabilities + * @return the negotiated audio format + */ + private TtsAudioFormat negotiateFormat(TtsConfig config, TtsCapabilities caps) { + TtsAudioFormat requested = config.getOutputFormat(); + + if (caps.isFormatSupported(requested)) { + logger.debug("Requested format {} is supported", requested.getValue()); + return requested; + } + + logger.info( + "Requested format {} not supported by server, attempting fallback", requested.getValue()); + + for (TtsAudioFormat fallback : FORMAT_FALLBACK_ORDER) { + if (caps.isFormatSupported(fallback)) { + logger.info("Falling back to supported format: {}", fallback.getValue()); + return fallback; + } + } + + // Last resort: return the requested format and let the server handle it + logger.warn( + "No fallback format found in server capabilities, using requested format: {}", + requested.getValue()); + return requested; + } + + /** + * Probes the server's capabilities endpoint via GET /v1/audio/speech/capabilities. Falls back to + * a HEAD request if the GET fails. Returns default capabilities if probing is not possible. + * + * @return the probed or default capabilities + */ + private TtsCapabilities probeCapabilities() { + String capabilitiesUrl = endpoint + CAPABILITIES_PATH; + + OkHttpClient probeClient = + httpClient + .newBuilder() + .connectTimeout(CAPABILITIES_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(CAPABILITIES_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + + // Try GET request first + Request.Builder requestBuilder = new Request.Builder().url(capabilitiesUrl).get(); + apiKey.ifPresent(key -> requestBuilder.addHeader("Authorization", "Bearer " + key)); + Request request = requestBuilder.build(); + + try (Response response = probeClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + String responseBody = response.body().string(); + TtsCapabilities parsed = parseCapabilitiesResponse(responseBody); + logger.info("Successfully probed TTS capabilities from {}", capabilitiesUrl); + return parsed; + } + + logger.debug("Capabilities GET returned HTTP {}, trying HEAD request", response.code()); + } catch (IOException e) { + logger.debug("Capabilities GET failed: {}, trying HEAD request", e.getMessage()); + } + + // Fallback: try HEAD request to at least confirm the endpoint exists + Request headRequest = new Request.Builder().url(capabilitiesUrl).head().build(); + try (Response response = probeClient.newCall(headRequest).execute()) { + if (response.isSuccessful()) { + logger.info("Capabilities HEAD succeeded, returning default capabilities"); + } else { + logger.debug("Capabilities HEAD returned HTTP {}", response.code()); + } + } catch (IOException e) { + logger.debug("Capabilities HEAD failed: {}", e.getMessage()); + } + + // Return default capabilities (all formats supported, 4096 max text) + logger.info( + "Could not probe server capabilities, returning defaults (all formats, maxText=4096)"); + return TtsCapabilities.builder().build(); + } + + /** + * Parses the JSON response from the capabilities endpoint. + * + * @param responseBody the JSON response body + * @return parsed TtsCapabilities + */ + private TtsCapabilities parseCapabilitiesResponse(String responseBody) { + TtsCapabilities.Builder builder = TtsCapabilities.builder(); + + try { + JSONObject json = new JSONObject(responseBody); + + if (json.has("supported_formats")) { + JSONArray formatsArray = json.getJSONArray("supported_formats"); + List formats = new ArrayList<>(); + for (int i = 0; i < formatsArray.length(); i++) { + try { + formats.add(TtsAudioFormat.fromString(formatsArray.getString(i))); + } catch (IllegalArgumentException e) { + logger.debug("Ignoring unknown format: {}", formatsArray.getString(i)); + } + } + if (!formats.isEmpty()) { + builder.supportedFormats(formats); + } + } + + if (json.has("supported_voices")) { + JSONArray voicesArray = json.getJSONArray("supported_voices"); + List voices = new ArrayList<>(); + for (int i = 0; i < voicesArray.length(); i++) { + voices.add(voicesArray.getString(i)); + } + builder.supportedVoices(voices); + } + + if (json.has("supported_models")) { + JSONArray modelsArray = json.getJSONArray("supported_models"); + List models = new ArrayList<>(); + for (int i = 0; i < modelsArray.length(); i++) { + models.add(modelsArray.getString(i)); + } + builder.supportedModels(models); + } + + if (json.has("max_text_length")) { + builder.maxTextLength(json.getInt("max_text_length")); + } + + if (json.has("supports_streaming")) { + builder.supportsStreaming(json.getBoolean("supports_streaming")); + } + + } catch (Exception e) { + logger.warn("Failed to parse capabilities response, using defaults: {}", e.getMessage()); + return TtsCapabilities.builder().build(); + } + + return builder.build(); + } + + /** + * Gets the configured endpoint. + * + * @return the TTS service endpoint URL + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Configures this service with resilience support (retry and circuit breaker). Returns this + * instance for fluent configuration. If either parameter is null, the corresponding default + * policy is used. + * + * @param retry the retry policy, or null for default + * @param cb the circuit breaker, or null for default + * @return this service instance configured with resilience + */ + public OpenAiCompatibleTtsService withResilience(RetryPolicy retry, CircuitBreaker cb) { + ResilientService.Builder builder = ResilientService.builder(); + if (retry != null) { + builder.retryPolicy(retry); + } + if (cb != null) { + builder.circuitBreaker(cb); + } + this.resilientService = builder.build(); + logger.info("Resilience configured for TTS service at endpoint: {}", endpoint); + return this; + } + + /** + * Builds the JSON request body for the OpenAI /v1/audio/speech API. + * + * @param text the text to synthesize + * @param config TTS configuration + * @return JSON string + */ + private String buildRequestBody(String text, TtsConfig config) { + return buildRequestBody(text, config, config.getOutputFormat()); + } + + /** + * Builds the JSON request body for the OpenAI /v1/audio/speech API with a specific output format. + * + * @param text the text to synthesize + * @param config TTS configuration + * @param format the negotiated audio output format + * @return JSON string + */ + private String buildRequestBody(String text, TtsConfig config, TtsAudioFormat format) { + JSONObject body = new JSONObject(); + body.put("input", text); + body.put("voice", config.getVoice()); + body.put("response_format", format.getValue()); + body.put("speed", config.getSpeed()); + + // Use model from config, default to "tts-1" if not specified + String model = config.getModel(); + body.put("model", (model != null && !model.isEmpty()) ? model : "tts-1"); + + return body.toString(); + } + + /** + * Builds the HTTP request with appropriate headers. + * + * @param url the target URL + * @param jsonBody the JSON request body + * @return configured OkHttp Request + */ + private Request buildHttpRequest(String url, String jsonBody) { + Request.Builder builder = + new Request.Builder() + .url(url) + .post(RequestBody.create(jsonBody, JSON_MEDIA_TYPE)) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/octet-stream"); + + apiKey.ifPresent(key -> builder.addHeader("Authorization", "Bearer " + key)); + + return builder.build(); + } + + /** + * Extracts error body from a failed response. + * + * @param response the failed HTTP response + * @return error message string + */ + private String extractErrorBody(Response response) { + try { + ResponseBody body = response.body(); + if (body != null) { + return body.string(); + } + } catch (IOException e) { + logger.debug("Could not read error response body", e); + } + return "No error body"; + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsAudioFormat.java b/core/src/main/java/com/google/adk/transcription/tts/TtsAudioFormat.java new file mode 100644 index 000000000..47b46428c --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsAudioFormat.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +/** + * Audio output format specifications for text-to-speech synthesis. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public enum TtsAudioFormat { + /** WAV format. */ + WAV("wav"), + + /** MP3 format. */ + MP3("mp3"), + + /** OGG Vorbis format. */ + OGG("ogg"), + + /** Raw PCM format. */ + PCM("pcm"), + + /** FLAC lossless format. */ + FLAC("flac"); + + private final String value; + + TtsAudioFormat(String value) { + this.value = value; + } + + /** + * Gets the string value of the audio format. + * + * @return format string value + */ + public String getValue() { + return value; + } + + /** + * Parses a string value into a {@link TtsAudioFormat}. + * + * @param value the string representation of the format + * @return the matching {@link TtsAudioFormat} + * @throws IllegalArgumentException if the value does not match any format + */ + public static TtsAudioFormat fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Audio format value cannot be null"); + } + for (TtsAudioFormat format : values()) { + if (format.value.equalsIgnoreCase(value)) { + return format; + } + } + throw new IllegalArgumentException("Unknown audio format: " + value); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsCapabilities.java b/core/src/main/java/com/google/adk/transcription/tts/TtsCapabilities.java new file mode 100644 index 000000000..cbfb073dd --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsCapabilities.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Represents a TTS server's capabilities including supported audio formats, voices, models, and + * constraints. Instances are immutable once built. + * + *

Use the {@link #builder()} method to create instances via the Builder pattern. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class TtsCapabilities { + + private final List supportedFormats; + private final List supportedVoices; + private final List supportedModels; + private final int maxTextLength; + private final boolean supportsStreaming; + + private TtsCapabilities(Builder builder) { + this.supportedFormats = Collections.unmodifiableList(new ArrayList<>(builder.supportedFormats)); + this.supportedVoices = Collections.unmodifiableList(new ArrayList<>(builder.supportedVoices)); + this.supportedModels = Collections.unmodifiableList(new ArrayList<>(builder.supportedModels)); + this.maxTextLength = builder.maxTextLength; + this.supportsStreaming = builder.supportsStreaming; + } + + /** + * Creates a new builder for TtsCapabilities. + * + * @return a new Builder instance with default values + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the list of audio formats supported by the TTS server. + * + * @return unmodifiable list of supported audio formats + */ + public List getSupportedFormats() { + return supportedFormats; + } + + /** + * Gets the list of voices supported by the TTS server. + * + * @return unmodifiable list of supported voice identifiers + */ + public List getSupportedVoices() { + return supportedVoices; + } + + /** + * Gets the list of models supported by the TTS server. + * + * @return unmodifiable list of supported model identifiers + */ + public List getSupportedModels() { + return supportedModels; + } + + /** + * Gets the maximum text length the server can process in a single request. + * + * @return maximum text length in characters + */ + public int getMaxTextLength() { + return maxTextLength; + } + + /** + * Checks whether the server supports streaming audio output. + * + * @return true if streaming is supported + */ + public boolean isSupportsStreaming() { + return supportsStreaming; + } + + /** + * Checks if a given audio format is supported by the server. + * + * @param format the audio format to check + * @return true if the format is supported + */ + public boolean isFormatSupported(TtsAudioFormat format) { + return supportedFormats.contains(format); + } + + @Override + public String toString() { + return String.format( + "TtsCapabilities{formats=%s, voices=%d, models=%d, maxTextLength=%d, streaming=%s}", + supportedFormats, + supportedVoices.size(), + supportedModels.size(), + maxTextLength, + supportsStreaming); + } + + /** Builder for {@link TtsCapabilities}. */ + public static class Builder { + private List supportedFormats = + new ArrayList<>(Arrays.asList(TtsAudioFormat.values())); + private List supportedVoices = new ArrayList<>(); + private List supportedModels = new ArrayList<>(); + private int maxTextLength = 4096; + private boolean supportsStreaming = true; + + /** + * Sets the supported audio formats. + * + * @param supportedFormats list of supported formats + * @return this builder + */ + public Builder supportedFormats(List supportedFormats) { + this.supportedFormats = new ArrayList<>(supportedFormats); + return this; + } + + /** + * Sets the supported voices. + * + * @param supportedVoices list of supported voice identifiers + * @return this builder + */ + public Builder supportedVoices(List supportedVoices) { + this.supportedVoices = new ArrayList<>(supportedVoices); + return this; + } + + /** + * Sets the supported models. + * + * @param supportedModels list of supported model identifiers + * @return this builder + */ + public Builder supportedModels(List supportedModels) { + this.supportedModels = new ArrayList<>(supportedModels); + return this; + } + + /** + * Sets the maximum text length. + * + * @param maxTextLength maximum number of characters + * @return this builder + * @throws IllegalArgumentException if maxTextLength is not positive + */ + public Builder maxTextLength(int maxTextLength) { + if (maxTextLength <= 0) { + throw new IllegalArgumentException("maxTextLength must be > 0"); + } + this.maxTextLength = maxTextLength; + return this; + } + + /** + * Sets whether the server supports streaming. + * + * @param supportsStreaming true if streaming is supported + * @return this builder + */ + public Builder supportsStreaming(boolean supportsStreaming) { + this.supportsStreaming = supportsStreaming; + return this; + } + + /** + * Builds an immutable TtsCapabilities instance. + * + * @return a new TtsCapabilities instance + */ + public TtsCapabilities build() { + return new TtsCapabilities(this); + } + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsConfig.java b/core/src/main/java/com/google/adk/transcription/tts/TtsConfig.java new file mode 100644 index 000000000..6748e6346 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsConfig.java @@ -0,0 +1,159 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import java.util.Optional; + +/** + * Configuration for text-to-speech synthesis services. Uses Builder Pattern for flexible + * configuration. + * + *

All fields are immutable once built. Use the builder to create instances. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class TtsConfig { + private final String voice; + private final String language; + private final String model; + private final TtsAudioFormat outputFormat; + private final int sampleRate; + private final double speed; + private final String endpoint; + private final Optional apiKey; + + private TtsConfig(Builder builder) { + this.voice = builder.voice; + this.language = builder.language; + this.model = builder.model; + this.outputFormat = builder.outputFormat; + this.sampleRate = builder.sampleRate; + this.speed = builder.speed; + this.endpoint = builder.endpoint; + this.apiKey = Optional.ofNullable(builder.apiKey); + } + + public static Builder builder() { + return new Builder(); + } + + public String getVoice() { + return voice; + } + + public String getLanguage() { + return language; + } + + public String getModel() { + return model; + } + + public TtsAudioFormat getOutputFormat() { + return outputFormat; + } + + public int getSampleRate() { + return sampleRate; + } + + public double getSpeed() { + return speed; + } + + public String getEndpoint() { + return endpoint; + } + + public Optional getApiKey() { + return apiKey; + } + + /** Builder for TtsConfig. */ + public static class Builder { + private String voice = "default"; + private String language = "en-US"; + private String model; + private TtsAudioFormat outputFormat = TtsAudioFormat.WAV; + private int sampleRate = 24000; + private double speed = 1.0; + private String endpoint; + private String apiKey; + + public Builder voice(String voice) { + this.voice = voice; + return this; + } + + public Builder language(String language) { + this.language = language; + return this; + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder outputFormat(TtsAudioFormat outputFormat) { + this.outputFormat = outputFormat; + return this; + } + + public Builder sampleRate(int sampleRate) { + if (sampleRate <= 0) { + throw new IllegalArgumentException("Sample rate must be > 0"); + } + this.sampleRate = sampleRate; + return this; + } + + public Builder speed(double speed) { + if (speed <= 0) { + throw new IllegalArgumentException("Speed must be > 0"); + } + this.speed = speed; + return this; + } + + public Builder endpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public TtsConfig build() { + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException("Endpoint is required"); + } + return new TtsConfig(this); + } + } + + @Override + public String toString() { + return String.format( + "TtsConfig{endpoint='%s', voice='%s', language='%s', model='%s', format=%s, sampleRate=%d," + + " speed=%.1f}", + endpoint, voice, language, model, outputFormat, sampleRate, speed); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsException.java b/core/src/main/java/com/google/adk/transcription/tts/TtsException.java new file mode 100644 index 000000000..138c3d79e --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsException.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +/** + * Exception thrown when text-to-speech synthesis operations fail. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class TtsException extends Exception { + + private final String errorCode; + + public TtsException(String message) { + super(message); + this.errorCode = null; + } + + public TtsException(String message, Throwable cause) { + super(message, cause); + this.errorCode = null; + } + + public TtsException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public TtsException(String message, Throwable cause, String errorCode) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * Gets the error code associated with this exception. + * + * @return the error code, or null if not set + */ + public String getErrorCode() { + return errorCode; + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsResult.java b/core/src/main/java/com/google/adk/transcription/tts/TtsResult.java new file mode 100644 index 000000000..e5e68432b --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsResult.java @@ -0,0 +1,126 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import com.google.common.collect.ImmutableMap; +import java.util.Arrays; +import java.util.Map; + +/** + * Result of a text-to-speech synthesis operation containing the audio data and metadata. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public final class TtsResult { + private final byte[] audioData; + private final String mimeType; + private final int sampleRate; + private final long durationMs; + private final ImmutableMap metadata; + + private TtsResult(Builder builder) { + this.audioData = + builder.audioData != null + ? Arrays.copyOf(builder.audioData, builder.audioData.length) + : new byte[0]; + this.mimeType = builder.mimeType; + this.sampleRate = builder.sampleRate; + this.durationMs = builder.durationMs; + this.metadata = ImmutableMap.copyOf(builder.metadata); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the synthesized audio data. + * + * @return a copy of the audio byte array + */ + public byte[] getAudioData() { + return Arrays.copyOf(audioData, audioData.length); + } + + public String getMimeType() { + return mimeType; + } + + public int getSampleRate() { + return sampleRate; + } + + public long getDurationMs() { + return durationMs; + } + + public ImmutableMap getMetadata() { + return metadata; + } + + /** Builder for TtsResult. */ + public static class Builder { + private byte[] audioData; + private String mimeType; + private int sampleRate; + private long durationMs; + private Map metadata = Map.of(); + + public Builder audioData(byte[] audioData) { + this.audioData = audioData != null ? Arrays.copyOf(audioData, audioData.length) : null; + return this; + } + + public Builder mimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + public Builder sampleRate(int sampleRate) { + this.sampleRate = sampleRate; + return this; + } + + public Builder durationMs(long durationMs) { + this.durationMs = durationMs; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = Map.copyOf(metadata); + return this; + } + + public TtsResult build() { + if (audioData == null) { + throw new IllegalArgumentException("Audio data is required"); + } + if (mimeType == null || mimeType.isEmpty()) { + throw new IllegalArgumentException("MIME type is required"); + } + return new TtsResult(this); + } + } + + @Override + public String toString() { + return String.format( + "TtsResult{mimeType='%s', sampleRate=%d, durationMs=%d, audioSize=%d bytes}", + mimeType, sampleRate, durationMs, audioData.length); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsService.java b/core/src/main/java/com/google/adk/transcription/tts/TtsService.java new file mode 100644 index 000000000..dd908df31 --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsService.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import com.google.adk.transcription.ServiceHealth; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; + +/** + * Core interface for text-to-speech synthesis services. Implementations provide text-to-audio + * synthesis capabilities. + * + *

This interface follows the Strategy Pattern, allowing different TTS providers (Google Cloud + * TTS, ElevenLabs, Azure, etc.) to be used interchangeably. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public interface TtsService { + + /** + * Synthesizes text to audio synchronously. + * + * @param text the text to synthesize + * @param config TTS configuration + * @return synthesized audio bytes + * @throws TtsException if synthesis fails + */ + byte[] synthesize(String text, TtsConfig config) throws TtsException; + + /** + * Synthesizes text to audio asynchronously using RxJava Single. + * + * @param text the text to synthesize + * @param config TTS configuration + * @return Single containing synthesized audio bytes + */ + Single synthesizeAsync(String text, TtsConfig config); + + /** + * Streams synthesized audio chunks for real-time playback. Processes the text and returns audio + * chunks as they become available. + * + * @param text the text to synthesize + * @param config TTS configuration + * @return Flowable of audio chunks + */ + Flowable synthesizeStream(String text, TtsConfig config); + + /** + * Checks if the service is available and healthy. + * + * @return true if service is available + */ + boolean isAvailable(); + + /** + * Gets service health status with details. + * + * @return Health status information + */ + ServiceHealth getHealth(); + + /** + * Returns the capabilities of this TTS service, including supported formats, voices, models, and + * constraints. + * + *

The default implementation returns a capabilities object with all formats supported, no + * specific voices or models listed, a maximum text length of 4096, and streaming enabled. + * + * @return the TTS service capabilities + */ + default TtsCapabilities capabilities() { + return TtsCapabilities.builder().build(); + } +} diff --git a/core/src/main/java/com/google/adk/transcription/tts/TtsServiceFactory.java b/core/src/main/java/com/google/adk/transcription/tts/TtsServiceFactory.java new file mode 100644 index 000000000..acb20910f --- /dev/null +++ b/core/src/main/java/com/google/adk/transcription/tts/TtsServiceFactory.java @@ -0,0 +1,210 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory for creating and caching TTS service instances. Services are lazily created and cached by + * endpoint for reuse. + * + *

Supports environment variable overrides: + * + *

    + *
  • {@code ADK_TTS_ENDPOINT} - Default TTS service endpoint + *
  • {@code ADK_TTS_API_KEY} - Default API key for TTS service + *
+ * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +public class TtsServiceFactory { + + private static final Logger logger = LoggerFactory.getLogger(TtsServiceFactory.class); + + /** Environment variable for the default TTS endpoint. */ + public static final String ENV_TTS_ENDPOINT = "ADK_TTS_ENDPOINT"; + + /** Environment variable for the default TTS API key. */ + public static final String ENV_TTS_API_KEY = "ADK_TTS_API_KEY"; + + /** Supported TTS service types. */ + public enum TtsServiceType { + /** OpenAI-compatible TTS service (works with Piper, AllTalk, Kokoro, OpenAI). */ + OPENAI_COMPATIBLE + } + + // Cache for service instances (lazy loading) + private static final ConcurrentHashMap serviceCache = + new ConcurrentHashMap<>(); + + private static final ReentrantLock lock = new ReentrantLock(); + + private TtsServiceFactory() { + // Utility class - no instantiation + } + + /** + * Creates or retrieves a cached TTS service instance based on configuration. Uses lazy loading - + * the service is only created when first needed. + * + *

If the config does not specify an endpoint or apiKey, the factory checks the environment + * variables {@code ADK_TTS_ENDPOINT} and {@code ADK_TTS_API_KEY}. + * + * @param config TTS configuration + * @return TtsService instance (cached by endpoint) + * @throws IllegalArgumentException if no endpoint can be resolved + */ + public static TtsService getOrCreate(TtsConfig config) { + TtsConfig resolvedConfig = resolveConfig(config); + String cacheKey = generateCacheKey(resolvedConfig); + + // Double-check locking for thread safety + TtsService service = serviceCache.get(cacheKey); + if (service != null) { + return service; + } + + lock.lock(); + try { + // Check again after acquiring lock + service = serviceCache.get(cacheKey); + if (service != null) { + return service; + } + + // Create new service + service = createService(resolvedConfig); + serviceCache.put(cacheKey, service); + logger.info("Created TTS service for endpoint: {}", resolvedConfig.getEndpoint()); + return service; + } finally { + lock.unlock(); + } + } + + /** + * Creates a new TTS service instance without caching. Use {@link #getOrCreate(TtsConfig)} for + * normal usage. + * + * @param config TTS configuration + * @return new TtsService instance + * @throws IllegalArgumentException if no endpoint can be resolved + */ + public static TtsService create(TtsConfig config) { + TtsConfig resolvedConfig = resolveConfig(config); + return createService(resolvedConfig); + } + + /** + * Determines the TTS service type for a given config. Currently defaults to OPENAI_COMPATIBLE. + * + * @param config TTS configuration + * @return the determined service type + */ + public static TtsServiceType determineServiceType(TtsConfig config) { + // Future: infer from endpoint patterns or add explicit type to config + return TtsServiceType.OPENAI_COMPATIBLE; + } + + private static TtsService createService(TtsConfig config) { + TtsServiceType serviceType = determineServiceType(config); + + switch (serviceType) { + case OPENAI_COMPATIBLE: + return createOpenAiCompatibleService(config); + default: + throw new IllegalArgumentException("Unsupported TTS service type: " + serviceType); + } + } + + private static TtsService createOpenAiCompatibleService(TtsConfig config) { + String endpoint = config.getEndpoint(); + String apiKey = config.getApiKey().orElse(null); + + logger.debug( + "Creating OpenAI-compatible TTS service: endpoint={}, apiKey present={}", + endpoint, + apiKey != null); + + return new OpenAiCompatibleTtsService(endpoint, apiKey); + } + + /** + * Resolves the config by falling back to environment variables for endpoint and API key if not + * provided in the config. + */ + private static TtsConfig resolveConfig(TtsConfig config) { + String endpoint = config.getEndpoint(); + String apiKey = config.getApiKey().orElse(null); + + // Check environment variables if not set in config + if (endpoint == null || endpoint.isEmpty()) { + endpoint = System.getenv(ENV_TTS_ENDPOINT); + } + if (apiKey == null || apiKey.isEmpty()) { + String envApiKey = System.getenv(ENV_TTS_API_KEY); + if (envApiKey != null && !envApiKey.isEmpty()) { + apiKey = envApiKey; + } + } + + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException( + "TTS endpoint is required. Set it in TtsConfig or via environment variable " + + ENV_TTS_ENDPOINT); + } + + // Rebuild config with resolved values + TtsConfig.Builder builder = + TtsConfig.builder() + .endpoint(endpoint) + .voice(config.getVoice()) + .language(config.getLanguage()) + .outputFormat(config.getOutputFormat()) + .sampleRate(config.getSampleRate()) + .speed(config.getSpeed()); + + if (config.getModel() != null) { + builder.model(config.getModel()); + } + if (apiKey != null) { + builder.apiKey(apiKey); + } + + return builder.build(); + } + + private static String generateCacheKey(TtsConfig config) { + return String.format( + "%s:%s:%s", determineServiceType(config), config.getEndpoint(), config.getVoice()); + } + + /** Clears the service cache. Useful for testing. */ + public static void clearCache() { + lock.lock(); + try { + serviceCache.clear(); + logger.debug("TTS service cache cleared"); + } finally { + lock.unlock(); + } + } +} diff --git a/core/src/test/java/com/google/adk/agents/IntentClassifierLlmTest.java b/core/src/test/java/com/google/adk/agents/IntentClassifierLlmTest.java new file mode 100644 index 000000000..98d20a5e7 --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/IntentClassifierLlmTest.java @@ -0,0 +1,221 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.models.LlmResponse; +import com.google.adk.testing.TestLlm; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link IntentClassifier} LLM-based and hybrid classification. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("IntentClassifier LLM Tests") +class IntentClassifierLlmTest { + + private VoiceConfig config = VoiceConfig.builder().build(); + + private TestLlm createTestLlm(String response) { + LlmResponse llmResponse = + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(ImmutableList.of(Part.fromText(response))) + .build()) + .build(); + return new TestLlm(ImmutableList.of(llmResponse)); + } + + @Test + @DisplayName("hybrid classifier uses keyword first when keyword matches") + void testHybridUsesKeywordFirst() { + // Create an LLM that would classify as REASONING if called + TestLlm llm = createTestLlm("REASONING"); + + List keywords = Arrays.asList("go back", "next page", "stop"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + VoiceMode result = classifier.classify("please go back", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + // The LLM should NOT have been called since keyword matched + assertThat(llm.getRequests()).isEmpty(); + } + + @Test + @DisplayName("hybrid classifier uses keyword first for multiple keywords") + void testHybridKeywordMatchVariousCommands() { + TestLlm llm = createTestLlm("REASONING"); + + List keywords = Arrays.asList("go back", "next page", "stop", "scroll down"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + assertThat(classifier.classify("next page please", config)) + .isEqualTo(VoiceMode.VOICE_NAVIGATION); + assertThat(classifier.classify("stop now", config)).isEqualTo(VoiceMode.VOICE_NAVIGATION); + assertThat(classifier.classify("can you scroll down", config)) + .isEqualTo(VoiceMode.VOICE_NAVIGATION); + + // LLM should never have been called + assertThat(llm.getRequests()).isEmpty(); + } + + @Test + @DisplayName("hybrid falls through to LLM when no keyword match — NAVIGATION response") + void testHybridFallsToLlmNavigation() { + TestLlm llm = createTestLlm("NAVIGATION"); + + List keywords = Arrays.asList("go back", "next page"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + // "help me" doesn't match any keyword, so LLM is called + VoiceMode result = classifier.classify("help me", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + // The LLM should have been called + assertThat(llm.getRequests()).hasSize(1); + } + + @Test + @DisplayName("hybrid falls through to LLM when no keyword match — REASONING response") + void testHybridFallsToLlmReasoning() { + TestLlm llm = createTestLlm("REASONING"); + + List keywords = Arrays.asList("go back", "next page"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + VoiceMode result = classifier.classify("What is the meaning of life?", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(llm.getRequests()).hasSize(1); + } + + @Test + @DisplayName("LLM classifier returns VOICE_NAVIGATION for NAVIGATION response") + void testLlmClassifierNavigation() { + TestLlm llm = createTestLlm("NAVIGATION"); + IntentClassifier classifier = IntentClassifier.llm(llm); + + VoiceMode result = classifier.classify("repeat that", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + assertThat(llm.getRequests()).hasSize(1); + } + + @Test + @DisplayName("LLM classifier returns VOICE_FULL for REASONING response") + void testLlmClassifierReasoning() { + TestLlm llm = createTestLlm("REASONING"); + IntentClassifier classifier = IntentClassifier.llm(llm); + + VoiceMode result = classifier.classify("explain quantum physics", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(llm.getRequests()).hasSize(1); + } + + @Test + @DisplayName("LLM classifier defaults to VOICE_FULL on error") + void testLlmClassifierDefaultsOnError() { + // Create a TestLlm that throws an error + TestLlm llm = TestLlm.create(ImmutableList.of(), new RuntimeException("LLM unavailable")); + IntentClassifier classifier = IntentClassifier.llm(llm); + + VoiceMode result = classifier.classify("hello world", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("LLM classifier returns VOICE_FULL for null input") + void testLlmClassifierNullInput() { + TestLlm llm = createTestLlm("NAVIGATION"); + IntentClassifier classifier = IntentClassifier.llm(llm); + + VoiceMode result = classifier.classify(null, config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(llm.getRequests()).isEmpty(); + } + + @Test + @DisplayName("LLM classifier returns VOICE_FULL for empty input") + void testLlmClassifierEmptyInput() { + TestLlm llm = createTestLlm("NAVIGATION"); + IntentClassifier classifier = IntentClassifier.llm(llm); + + VoiceMode result = classifier.classify("", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(llm.getRequests()).isEmpty(); + } + + @Test + @DisplayName("hybrid classifyAsync uses keyword when matched") + void testHybridClassifyAsyncKeyword() { + TestLlm llm = createTestLlm("REASONING"); + List keywords = Arrays.asList("go back", "next page"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + VoiceMode result = classifier.classifyAsync("go back now", config).blockingGet(); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + assertThat(llm.getRequests()).isEmpty(); + } + + @Test + @DisplayName("hybrid classifyAsync falls to LLM when no keyword match") + void testHybridClassifyAsyncFallsToLlm() { + TestLlm llm = createTestLlm("REASONING"); + List keywords = Arrays.asList("go back", "next page"); + IntentClassifier classifier = IntentClassifier.hybrid(keywords, llm); + + VoiceMode result = + classifier.classifyAsync("explain the theory of relativity", config).blockingGet(); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(llm.getRequests()).hasSize(1); + } + + @Test + @DisplayName("LLM request contains classification prompt with user text") + void testLlmRequestContainsPrompt() { + TestLlm llm = createTestLlm("NAVIGATION"); + IntentClassifier classifier = IntentClassifier.llm(llm); + + classifier.classify("show me settings", config); + + assertThat(llm.getRequests()).hasSize(1); + // Verify the prompt was sent to the LLM + String promptText = + llm.getRequests().get(0).contents().get(0).parts().get().get(0).text().orElse(""); + assertThat(promptText).contains("show me settings"); + assertThat(promptText).contains("NAVIGATION"); + assertThat(promptText).contains("REASONING"); + } +} diff --git a/core/src/test/java/com/google/adk/agents/IntentClassifierTest.java b/core/src/test/java/com/google/adk/agents/IntentClassifierTest.java new file mode 100644 index 000000000..21ace7480 --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/IntentClassifierTest.java @@ -0,0 +1,175 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link IntentClassifier} keyword-based classification. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("IntentClassifier Tests") +class IntentClassifierTest { + + private IntentClassifier classifier; + private VoiceConfig config; + + @BeforeEach + void setUp() { + List navKeywords = + Arrays.asList("go back", "next page", "scroll down", "scroll up", "open menu"); + classifier = IntentClassifier.keyword(navKeywords); + config = VoiceConfig.builder().build(); + } + + @Test + @DisplayName("classify returns VOICE_NAVIGATION for matching keyword") + void testClassifyMatchingKeyword() { + VoiceMode result = classifier.classify("please go back", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify returns VOICE_NAVIGATION for next page command") + void testClassifyNextPageCommand() { + VoiceMode result = classifier.classify("next page please", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify returns VOICE_NAVIGATION for scroll down command") + void testClassifyScrollDownCommand() { + VoiceMode result = classifier.classify("can you scroll down", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify returns VOICE_FULL for complex query") + void testClassifyComplexQuery() { + VoiceMode result = classifier.classify("What is the capital of France?", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("classify returns VOICE_FULL for non-matching input") + void testClassifyNonMatching() { + VoiceMode result = classifier.classify("tell me a joke", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("classify is case-insensitive") + void testClassifyCaseInsensitive() { + VoiceMode result = classifier.classify("GO BACK NOW", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify handles mixed case input") + void testClassifyMixedCase() { + VoiceMode result = classifier.classify("Please Open Menu", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify returns VOICE_FULL for null input") + void testClassifyNullInput() { + VoiceMode result = classifier.classify(null, config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("classify returns VOICE_FULL for empty input") + void testClassifyEmptyInput() { + VoiceMode result = classifier.classify("", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("classify checks VoiceConfig navigation commands") + void testClassifyWithConfigCommands() { + VoiceConfig configWithCommands = + VoiceConfig.builder().navigationCommands(Arrays.asList("volume up", "pause")).build(); + + VoiceMode result = classifier.classify("volume up", configWithCommands); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classify returns VOICE_FULL when no config commands match") + void testClassifyNoConfigCommandMatch() { + VoiceConfig configWithCommands = + VoiceConfig.builder().navigationCommands(Arrays.asList("volume up", "pause")).build(); + + VoiceMode result = classifier.classify("explain quantum computing", configWithCommands); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("classifyAsync returns correct result") + void testClassifyAsync() { + VoiceMode result = classifier.classifyAsync("go back", config).blockingGet(); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } + + @Test + @DisplayName("classifyAsync returns VOICE_FULL for complex input") + void testClassifyAsyncComplex() { + VoiceMode result = + classifier.classifyAsync("What are best practices for Java?", config).blockingGet(); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("keyword classifier with empty list classifies as VOICE_FULL") + void testEmptyKeywordsList() { + IntentClassifier emptyClassifier = IntentClassifier.keyword(List.of()); + + VoiceMode result = emptyClassifier.classify("go back", VoiceConfig.builder().build()); + + assertThat(result).isEqualTo(VoiceMode.VOICE_FULL); + } + + @Test + @DisplayName("keyword classifier matches substring within longer text") + void testSubstringMatching() { + VoiceMode result = classifier.classify("I would like you to scroll down a bit", config); + + assertThat(result).isEqualTo(VoiceMode.VOICE_NAVIGATION); + } +} diff --git a/core/src/test/java/com/google/adk/agents/VoiceConfigTest.java b/core/src/test/java/com/google/adk/agents/VoiceConfigTest.java new file mode 100644 index 000000000..e68925d47 --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/VoiceConfigTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VoiceConfig}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("VoiceConfig Tests") +class VoiceConfigTest { + + @Test + @DisplayName("Builder creates config with default values") + void testBuilderDefaults() { + VoiceConfig config = VoiceConfig.builder().build(); + + assertThat(config.getVoiceMode()).isEqualTo(VoiceMode.AUTO); + assertThat(config.getSttModel()).isEqualTo("whisper-1"); + assertThat(config.getTtsModel()).isEqualTo("tts-1"); + assertThat(config.getTtsVoice()).isEqualTo("alloy"); + assertThat(config.getLanguage()).isEqualTo("en"); + assertThat(config.getTtsSpeed()).isEqualTo(1.0); + assertThat(config.getNavigationCommands()).isEmpty(); + } + + @Test + @DisplayName("Builder creates config with custom values") + void testBuilderCustomValues() { + VoiceConfig config = + VoiceConfig.builder() + .voiceMode(VoiceMode.VOICE_FULL) + .sttEndpoint("http://localhost:9000") + .ttsEndpoint("http://localhost:8000") + .sttModel("whisper-large-v3") + .ttsModel("tts-1-hd") + .ttsVoice("nova") + .language("fr") + .ttsSpeed(1.25) + .llmModel("llama3") + .classifierModel("phi3") + .navigationCommands(Arrays.asList("go back", "next")) + .build(); + + assertThat(config.getVoiceMode()).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(config.getSttEndpoint()).isEqualTo("http://localhost:9000"); + assertThat(config.getTtsEndpoint()).isEqualTo("http://localhost:8000"); + assertThat(config.getSttModel()).isEqualTo("whisper-large-v3"); + assertThat(config.getTtsModel()).isEqualTo("tts-1-hd"); + assertThat(config.getTtsVoice()).isEqualTo("nova"); + assertThat(config.getLanguage()).isEqualTo("fr"); + assertThat(config.getTtsSpeed()).isEqualTo(1.25); + assertThat(config.getLlmModel()).isEqualTo("llama3"); + assertThat(config.getClassifierModel()).isEqualTo("phi3"); + assertThat(config.getNavigationCommands()).containsExactly("go back", "next"); + } + + @Test + @DisplayName("Classifier model defaults to LLM model when not set") + void testClassifierModelDefaultsToLlmModel() { + VoiceConfig config = VoiceConfig.builder().llmModel("llama3").build(); + + assertThat(config.getClassifierModel()).isEqualTo("llama3"); + } + + @Test + @DisplayName("Classifier model is independent when explicitly set") + void testClassifierModelExplicitlySet() { + VoiceConfig config = VoiceConfig.builder().llmModel("llama3").classifierModel("phi3").build(); + + assertThat(config.getLlmModel()).isEqualTo("llama3"); + assertThat(config.getClassifierModel()).isEqualTo("phi3"); + } + + @Test + @DisplayName("Builder throws on invalid TTS speed") + void testBuilderInvalidTtsSpeed() { + assertThrows(IllegalArgumentException.class, () -> VoiceConfig.builder().ttsSpeed(0).build()); + assertThrows( + IllegalArgumentException.class, () -> VoiceConfig.builder().ttsSpeed(-1.0).build()); + } + + @Test + @DisplayName("Navigation commands are immutable") + void testNavigationCommandsImmutable() { + VoiceConfig config = + VoiceConfig.builder().navigationCommands(Arrays.asList("go back", "next")).build(); + + assertThrows( + UnsupportedOperationException.class, + () -> config.getNavigationCommands().add("new command")); + } + + @Test + @DisplayName("fromEnvironment returns config with defaults when no env vars set") + void testFromEnvironmentDefaults() { + // When no environment variables are set, fromEnvironment should return + // defaults without throwing + VoiceConfig config = VoiceConfig.fromEnvironment(); + + assertThat(config).isNotNull(); + assertThat(config.getVoiceMode()).isEqualTo(VoiceMode.AUTO); + assertThat(config.getSttModel()).isEqualTo("whisper-1"); + assertThat(config.getTtsModel()).isEqualTo("tts-1"); + assertThat(config.getTtsVoice()).isEqualTo("alloy"); + assertThat(config.getLanguage()).isEqualTo("en"); + assertThat(config.getTtsSpeed()).isEqualTo(1.0); + } + + @Test + @DisplayName("toString includes key fields") + void testToString() { + VoiceConfig config = + VoiceConfig.builder() + .voiceMode(VoiceMode.VOICE_FULL) + .ttsEndpoint("http://localhost:8000") + .ttsVoice("nova") + .build(); + + String str = config.toString(); + assertThat(str).contains("VOICE_FULL"); + assertThat(str).contains("http://localhost:8000"); + assertThat(str).contains("nova"); + } + + @Test + @DisplayName("All VoiceMode enum values are valid") + void testVoiceModeEnumValues() { + assertThat(VoiceMode.values()).hasLength(4); + assertThat(VoiceMode.valueOf("TEXT_ONLY")).isEqualTo(VoiceMode.TEXT_ONLY); + assertThat(VoiceMode.valueOf("VOICE_NAVIGATION")).isEqualTo(VoiceMode.VOICE_NAVIGATION); + assertThat(VoiceMode.valueOf("VOICE_FULL")).isEqualTo(VoiceMode.VOICE_FULL); + assertThat(VoiceMode.valueOf("AUTO")).isEqualTo(VoiceMode.AUTO); + } +} diff --git a/core/src/test/java/com/google/adk/agents/VoiceNavigationHandlerTest.java b/core/src/test/java/com/google/adk/agents/VoiceNavigationHandlerTest.java new file mode 100644 index 000000000..36faa8703 --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/VoiceNavigationHandlerTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.agents; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VoiceNavigationHandler}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("VoiceNavigationHandler Tests") +class VoiceNavigationHandlerTest { + + private VoiceNavigationHandler handler; + + @BeforeEach + void setUp() { + Map commands = new LinkedHashMap<>(); + commands.put("go back", "Navigating back"); + commands.put("next page", "Going to next page"); + commands.put("scroll down", "Scrolling down"); + commands.put("open menu", "Opening menu"); + handler = new VoiceNavigationHandler(commands); + } + + @Test + @DisplayName("handle returns response for matching command") + void testHandleMatchingCommand() { + Optional result = handler.handle("go back"); + + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Navigating back"); + } + + @Test + @DisplayName("handle returns response for matching substring") + void testHandleMatchingSubstring() { + Optional result = handler.handle("please go back now"); + + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Navigating back"); + } + + @Test + @DisplayName("handle returns empty for non-matching input") + void testHandleNoMatch() { + Optional result = handler.handle("tell me a joke"); + + assertThat(result.isPresent()).isFalse(); + } + + @Test + @DisplayName("handle is case-insensitive") + void testHandleCaseInsensitive() { + Optional result = handler.handle("GO BACK"); + + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Navigating back"); + } + + @Test + @DisplayName("handle is case-insensitive with mixed case") + void testHandleMixedCase() { + Optional result = handler.handle("Open Menu Please"); + + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Opening menu"); + } + + @Test + @DisplayName("handle returns empty for null input") + void testHandleNullInput() { + Optional result = handler.handle(null); + + assertThat(result.isPresent()).isFalse(); + } + + @Test + @DisplayName("handle returns empty for empty input") + void testHandleEmptyInput() { + Optional result = handler.handle(""); + + assertThat(result.isPresent()).isFalse(); + } + + @Test + @DisplayName("handle returns first match in insertion order") + void testHandleFirstMatchWins() { + Map commands = new LinkedHashMap<>(); + commands.put("scroll", "Scrolling generic"); + commands.put("scroll down", "Scrolling down specific"); + VoiceNavigationHandler multiHandler = new VoiceNavigationHandler(commands); + + Optional result = multiHandler.handle("scroll down please"); + + // "scroll" matches first due to insertion order + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Scrolling generic"); + } + + @Test + @DisplayName("addCommand registers new command dynamically") + void testAddCommand() { + handler.addCommand("volume up", "Increasing volume"); + + Optional result = handler.handle("volume up"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isEqualTo("Increasing volume"); + } + + @Test + @DisplayName("addCommand throws on null pattern") + void testAddCommandNullPattern() { + assertThrows(IllegalArgumentException.class, () -> handler.addCommand(null, "response")); + } + + @Test + @DisplayName("addCommand throws on empty pattern") + void testAddCommandEmptyPattern() { + assertThrows(IllegalArgumentException.class, () -> handler.addCommand("", "response")); + } + + @Test + @DisplayName("addCommand throws on null response") + void testAddCommandNullResponse() { + assertThrows(IllegalArgumentException.class, () -> handler.addCommand("test", null)); + } + + @Test + @DisplayName("addCommand throws on empty response") + void testAddCommandEmptyResponse() { + assertThrows(IllegalArgumentException.class, () -> handler.addCommand("test", "")); + } + + @Test + @DisplayName("size returns correct command count") + void testSize() { + assertThat(handler.size()).isEqualTo(4); + } + + @Test + @DisplayName("size updates after addCommand") + void testSizeAfterAdd() { + handler.addCommand("new command", "new response"); + assertThat(handler.size()).isEqualTo(5); + } + + @Test + @DisplayName("getCommands returns all registered commands") + void testGetCommands() { + Map commands = handler.getCommands(); + assertThat(commands).hasSize(4); + assertThat(commands).containsKey("go back"); + assertThat(commands).containsKey("next page"); + } + + @Test + @DisplayName("empty handler returns empty for any input") + void testEmptyHandler() { + VoiceNavigationHandler emptyHandler = new VoiceNavigationHandler(); + + Optional result = emptyHandler.handle("go back"); + assertThat(result.isPresent()).isFalse(); + assertThat(emptyHandler.size()).isEqualTo(0); + } + + @Test + @DisplayName("constructor handles null commands map") + void testConstructorNullMap() { + VoiceNavigationHandler nullHandler = new VoiceNavigationHandler(null); + + assertThat(nullHandler.size()).isEqualTo(0); + assertThat(nullHandler.handle("anything").isPresent()).isFalse(); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/metrics/VoiceMetricsTest.java b/core/src/test/java/com/google/adk/transcription/metrics/VoiceMetricsTest.java new file mode 100644 index 000000000..1d58a8d7c --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/metrics/VoiceMetricsTest.java @@ -0,0 +1,219 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.metrics; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.agents.VoiceMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VoiceMetrics}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("VoiceMetrics Tests") +class VoiceMetricsTest { + + private VoiceMetrics metrics; + + @BeforeEach + void setUp() { + metrics = VoiceMetrics.getInstance(); + metrics.reset(); + } + + @Test + @DisplayName("recordSttCall increments total and success counters") + void testRecordSttCallSuccess() { + metrics.recordSttCall(100, true); + metrics.recordSttCall(200, true); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.sttTotalCalls()).isEqualTo(2); + assertThat(snapshot.sttSuccessCalls()).isEqualTo(2); + assertThat(snapshot.sttFailedCalls()).isEqualTo(0); + } + + @Test + @DisplayName("recordSttCall increments total and failure counters") + void testRecordSttCallFailure() { + metrics.recordSttCall(150, false); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.sttTotalCalls()).isEqualTo(1); + assertThat(snapshot.sttSuccessCalls()).isEqualTo(0); + assertThat(snapshot.sttFailedCalls()).isEqualTo(1); + } + + @Test + @DisplayName("recordTtsCall increments total and success counters") + void testRecordTtsCallSuccess() { + metrics.recordTtsCall(50, true, 100); + metrics.recordTtsCall(75, true, 200); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.ttsTotalCalls()).isEqualTo(2); + assertThat(snapshot.ttsSuccessCalls()).isEqualTo(2); + assertThat(snapshot.ttsFailedCalls()).isEqualTo(0); + } + + @Test + @DisplayName("recordTtsCall increments total and failure counters") + void testRecordTtsCallFailure() { + metrics.recordTtsCall(30, false, 50); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.ttsTotalCalls()).isEqualTo(1); + assertThat(snapshot.ttsSuccessCalls()).isEqualTo(0); + assertThat(snapshot.ttsFailedCalls()).isEqualTo(1); + } + + @Test + @DisplayName("getSnapshot returns correct average and max latency") + void testGetSnapshotLatencyValues() { + metrics.recordSttCall(100, true); + metrics.recordSttCall(200, true); + metrics.recordSttCall(300, true); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.sttTotalCalls()).isEqualTo(3); + assertThat(snapshot.sttAvgLatencyMs()).isEqualTo(200); // (100+200+300)/3 + assertThat(snapshot.sttMaxLatencyMs()).isEqualTo(300); + } + + @Test + @DisplayName("getSnapshot returns correct TTS latency values") + void testGetSnapshotTtsLatencyValues() { + metrics.recordTtsCall(50, true, 10); + metrics.recordTtsCall(150, true, 20); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.ttsTotalCalls()).isEqualTo(2); + assertThat(snapshot.ttsAvgLatencyMs()).isEqualTo(100); // (50+150)/2 + assertThat(snapshot.ttsMaxLatencyMs()).isEqualTo(150); + } + + @Test + @DisplayName("getSnapshot returns classifier results") + void testGetSnapshotClassifierResults() { + metrics.recordIntentClassification(10, VoiceMode.VOICE_NAVIGATION); + metrics.recordIntentClassification(20, VoiceMode.VOICE_NAVIGATION); + metrics.recordIntentClassification(30, VoiceMode.VOICE_FULL); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.classifierCalls()).isEqualTo(3); + assertThat(snapshot.classifierResults().get(VoiceMode.VOICE_NAVIGATION)).isEqualTo(2); + assertThat(snapshot.classifierResults().get(VoiceMode.VOICE_FULL)).isEqualTo(1); + } + + @Test + @DisplayName("reset clears all counters to zero") + void testResetClearsEverything() { + metrics.recordSttCall(100, true); + metrics.recordSttCall(200, false); + metrics.recordTtsCall(50, true, 100); + metrics.recordTtsCall(75, false, 200); + metrics.recordIntentClassification(10, VoiceMode.VOICE_NAVIGATION); + + // Reset + metrics.reset(); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.sttTotalCalls()).isEqualTo(0); + assertThat(snapshot.sttSuccessCalls()).isEqualTo(0); + assertThat(snapshot.sttFailedCalls()).isEqualTo(0); + assertThat(snapshot.sttAvgLatencyMs()).isEqualTo(0); + assertThat(snapshot.sttMaxLatencyMs()).isEqualTo(0); + assertThat(snapshot.ttsTotalCalls()).isEqualTo(0); + assertThat(snapshot.ttsSuccessCalls()).isEqualTo(0); + assertThat(snapshot.ttsFailedCalls()).isEqualTo(0); + assertThat(snapshot.ttsAvgLatencyMs()).isEqualTo(0); + assertThat(snapshot.ttsMaxLatencyMs()).isEqualTo(0); + assertThat(snapshot.classifierCalls()).isEqualTo(0); + } + + @Test + @DisplayName("thread safety: concurrent recording does not lose counts") + void testThreadSafety() throws Exception { + int threadCount = 10; + int callsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + List errors = Collections.synchronizedList(new ArrayList<>()); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit( + () -> { + try { + for (int i = 0; i < callsPerThread; i++) { + if (threadId % 2 == 0) { + metrics.recordSttCall(50, true); + } else { + metrics.recordTtsCall(30, true, 10); + } + } + } catch (Throwable e) { + errors.add(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(errors).isEmpty(); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + // 5 threads recording STT (threadId 0,2,4,6,8), 5 recording TTS (threadId 1,3,5,7,9) + assertThat(snapshot.sttTotalCalls()).isEqualTo(5L * callsPerThread); + assertThat(snapshot.ttsTotalCalls()).isEqualTo(5L * callsPerThread); + } + + @Test + @DisplayName("singleton instance is consistent") + void testSingletonInstance() { + VoiceMetrics instance1 = VoiceMetrics.getInstance(); + VoiceMetrics instance2 = VoiceMetrics.getInstance(); + + assertThat(instance1).isSameInstanceAs(instance2); + } + + @Test + @DisplayName("max latency tracks the highest value") + void testMaxLatencyTracking() { + metrics.recordSttCall(100, true); + metrics.recordSttCall(500, true); + metrics.recordSttCall(200, true); + + VoiceMetricsSnapshot snapshot = metrics.getSnapshot(); + assertThat(snapshot.sttMaxLatencyMs()).isEqualTo(500); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/resilience/CircuitBreakerTest.java b/core/src/test/java/com/google/adk/transcription/resilience/CircuitBreakerTest.java new file mode 100644 index 000000000..2f2deec5b --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/resilience/CircuitBreakerTest.java @@ -0,0 +1,362 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CircuitBreaker}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("CircuitBreaker Tests") +class CircuitBreakerTest { + + @Test + @DisplayName("CLOSED state passes calls through successfully") + void testClosedStatePassesCalls() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(5).openDurationMs(1000).build(); + + String result = breaker.execute(() -> "hello"); + + assertThat(result).isEqualTo("hello"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("CLOSED state allows multiple successful calls") + void testClosedStateMultipleSuccesses() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(5).openDurationMs(1000).build(); + + for (int i = 0; i < 10; i++) { + String result = breaker.execute(() -> "ok"); + assertThat(result).isEqualTo("ok"); + } + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("transitions to OPEN after failureThreshold failures") + void testTransitionsToOpenAfterThreshold() { + CircuitBreaker breaker = + CircuitBreaker.builder() + .failureThreshold(3) + .openDurationMs(30000) // long enough that it won't transition + .build(); + + // Cause 3 failures + for (int i = 0; i < 3; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + + @Test + @DisplayName("OPEN state rejects calls with CircuitBreakerOpenException") + void testOpenStateRejects() { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(2).openDurationMs(30000).build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + // Now it should reject + CircuitBreakerOpenException ex = + assertThrows(CircuitBreakerOpenException.class, () -> breaker.execute(() -> "blocked")); + + assertThat(ex.getMessage()).contains("OPEN"); + } + + @Test + @DisplayName("transitions to HALF_OPEN after openDurationMs") + void testTransitionsToHalfOpenAfterDuration() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder() + .failureThreshold(2) + .openDurationMs(50) // very short for testing + .halfOpenMaxAttempts(1) + .build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + // Wait for open duration to elapse + Thread.sleep(100); + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN); + } + + @Test + @DisplayName("HALF_OPEN success transitions to CLOSED") + void testHalfOpenSuccessTransitionsToClosed() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder() + .failureThreshold(2) + .openDurationMs(50) + .halfOpenMaxAttempts(1) + .build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + // Wait for transition to HALF_OPEN + Thread.sleep(100); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN); + + // Successful call in HALF_OPEN should close the circuit + String result = breaker.execute(() -> "recovered"); + assertThat(result).isEqualTo("recovered"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("HALF_OPEN failure transitions back to OPEN") + void testHalfOpenFailureTransitionsToOpen() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder() + .failureThreshold(2) + .openDurationMs(50) + .halfOpenMaxAttempts(2) + .build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + // Wait for transition to HALF_OPEN + Thread.sleep(100); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN); + + // Failed call in HALF_OPEN should reopen the circuit + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("still failing"); + })); + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + + @Test + @DisplayName("reset() returns circuit breaker to CLOSED state") + void testResetReturnsToClosed() { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(2).openDurationMs(30000).build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + // Reset should bring it back to CLOSED + breaker.reset(); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("reset() allows calls again after being in OPEN state") + void testResetAllowsCallsAgain() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(2).openDurationMs(30000).build(); + + // Trip the breaker + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + // Reset and verify calls work + breaker.reset(); + String result = breaker.execute(() -> "back in action"); + assertThat(result).isEqualTo("back in action"); + } + + @Test + @DisplayName("thread safety: concurrent calls do not corrupt state") + void testThreadSafety() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(10).openDurationMs(30000).build(); + + int threadCount = 20; + int callsPerThread = 50; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + List errors = Collections.synchronizedList(new ArrayList<>()); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit( + () -> { + try { + for (int i = 0; i < callsPerThread; i++) { + try { + breaker.execute( + () -> { + // Alternate success/failure + if (threadId % 2 == 0) { + return "ok"; + } else { + throw new RuntimeException("fail"); + } + }); + successCount.incrementAndGet(); + } catch (CircuitBreakerOpenException e) { + // Expected once circuit opens + failureCount.incrementAndGet(); + } catch (RuntimeException e) { + // Expected from failing calls + failureCount.incrementAndGet(); + } catch (Exception e) { + errors.add(e); + } + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + // No unexpected errors + assertThat(errors).isEmpty(); + // Total calls should sum to expected count + assertThat(successCount.get() + failureCount.get()).isEqualTo(threadCount * callsPerThread); + // State should be valid (CLOSED, OPEN, or HALF_OPEN) + CircuitBreaker.State finalState = breaker.getState(); + assertThat(finalState) + .isAnyOf( + CircuitBreaker.State.CLOSED, CircuitBreaker.State.OPEN, CircuitBreaker.State.HALF_OPEN); + } + + @Test + @DisplayName("successful calls reset failure count") + void testSuccessResetsFailureCount() throws Exception { + CircuitBreaker breaker = + CircuitBreaker.builder().failureThreshold(3).openDurationMs(30000).build(); + + // 2 failures (below threshold) + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + // Success should reset failure count + breaker.execute(() -> "success"); + + // 2 more failures should NOT trip the breaker since count was reset + for (int i = 0; i < 2; i++) { + assertThrows( + RuntimeException.class, + () -> + breaker.execute( + () -> { + throw new RuntimeException("fail"); + })); + } + + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("builder defaults are sensible") + void testBuilderDefaults() { + CircuitBreaker breaker = CircuitBreaker.builder().build(); + + assertThat(breaker.getFailureThreshold()).isEqualTo(5); + assertThat(breaker.getOpenDurationMs()).isEqualTo(30000); + assertThat(breaker.getHalfOpenMaxAttempts()).isEqualTo(2); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/resilience/ResilientServiceTest.java b/core/src/test/java/com/google/adk/transcription/resilience/ResilientServiceTest.java new file mode 100644 index 000000000..d34a929e3 --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/resilience/ResilientServiceTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ResilientService}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("ResilientService Tests") +class ResilientServiceTest { + + @Test + @DisplayName("combined retry + circuit breaker: retries succeed before circuit opens") + void testCombinedRetryAndCircuitBreaker() throws Exception { + RetryPolicy retryPolicy = + RetryPolicy.builder().maxAttempts(3).initialDelayMs(10).backoffMultiplier(2.0).build(); + CircuitBreaker circuitBreaker = + CircuitBreaker.builder().failureThreshold(5).openDurationMs(30000).build(); + + ResilientService service = + ResilientService.builder().retryPolicy(retryPolicy).circuitBreaker(circuitBreaker).build(); + + AtomicInteger callCount = new AtomicInteger(0); + + // Fails once, succeeds on retry — circuit should stay closed + String result = + service.execute( + () -> { + int attempt = callCount.incrementAndGet(); + if (attempt == 1) { + throw new RuntimeException("transient failure"); + } + return "recovered"; + }); + + assertThat(result).isEqualTo("recovered"); + assertThat(callCount.get()).isEqualTo(2); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("circuit breaker opens after retries exhaust across multiple calls") + void testCircuitBreakerOpensAfterRetriesExhaust() { + RetryPolicy retryPolicy = + RetryPolicy.builder().maxAttempts(2).initialDelayMs(10).backoffMultiplier(2.0).build(); + CircuitBreaker circuitBreaker = + CircuitBreaker.builder().failureThreshold(3).openDurationMs(30000).build(); + + ResilientService service = + ResilientService.builder().retryPolicy(retryPolicy).circuitBreaker(circuitBreaker).build(); + + // Each call to execute will exhaust retries (2 attempts each) and then fail, + // recording 1 failure in the circuit breaker per execute() call. + for (int i = 0; i < 3; i++) { + assertThrows( + RuntimeException.class, + () -> + service.execute( + () -> { + throw new RuntimeException("always fails"); + })); + } + + // Circuit should now be open + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + // Further calls should be rejected immediately + CircuitBreakerOpenException ex = + assertThrows( + CircuitBreakerOpenException.class, () -> service.execute(() -> "should not execute")); + + assertThat(ex).isInstanceOf(CircuitBreakerOpenException.class); + } + + @Test + @DisplayName("successful call with combined retry + circuit breaker") + void testSuccessfulCallWithCombined() throws Exception { + ResilientService service = ResilientService.builder().build(); + + String result = service.execute(() -> "hello"); + + assertThat(result).isEqualTo("hello"); + assertThat(service.getCircuitBreaker().getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("async execution works with combined retry + circuit breaker") + void testAsyncExecution() { + RetryPolicy retryPolicy = + RetryPolicy.builder().maxAttempts(3).initialDelayMs(10).backoffMultiplier(2.0).build(); + CircuitBreaker circuitBreaker = + CircuitBreaker.builder().failureThreshold(5).openDurationMs(30000).build(); + + ResilientService service = + ResilientService.builder().retryPolicy(retryPolicy).circuitBreaker(circuitBreaker).build(); + + AtomicInteger callCount = new AtomicInteger(0); + + String result = + service + .executeAsync( + () -> { + int attempt = callCount.incrementAndGet(); + if (attempt == 1) { + throw new RuntimeException("transient"); + } + return "async-recovered"; + }) + .blockingGet(); + + assertThat(result).isEqualTo("async-recovered"); + assertThat(callCount.get()).isEqualTo(2); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/resilience/RetryPolicyTest.java b/core/src/test/java/com/google/adk/transcription/resilience/RetryPolicyTest.java new file mode 100644 index 000000000..d6638155e --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/resilience/RetryPolicyTest.java @@ -0,0 +1,235 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.resilience; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RetryPolicy}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("RetryPolicy Tests") +class RetryPolicyTest { + + @Test + @DisplayName("successful call returns immediately without retry") + void testSuccessfulCallNoRetry() throws Exception { + RetryPolicy policy = + RetryPolicy.builder().maxAttempts(3).initialDelayMs(100).backoffMultiplier(2.0).build(); + + AtomicInteger callCount = new AtomicInteger(0); + String result = + policy.execute( + () -> { + callCount.incrementAndGet(); + return "success"; + }); + + assertThat(result).isEqualTo("success"); + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + @DisplayName("retry on transient failure succeeds on 2nd attempt") + void testRetryOnTransientFailure() throws Exception { + RetryPolicy policy = + RetryPolicy.builder().maxAttempts(3).initialDelayMs(10).backoffMultiplier(2.0).build(); + + AtomicInteger callCount = new AtomicInteger(0); + String result = + policy.execute( + () -> { + int attempt = callCount.incrementAndGet(); + if (attempt == 1) { + throw new RuntimeException("transient failure"); + } + return "recovered"; + }); + + assertThat(result).isEqualTo("recovered"); + assertThat(callCount.get()).isEqualTo(2); + } + + @Test + @DisplayName("max retries exhausted throws the last exception") + void testMaxRetriesExhausted() { + RetryPolicy policy = + RetryPolicy.builder().maxAttempts(3).initialDelayMs(10).backoffMultiplier(2.0).build(); + + AtomicInteger callCount = new AtomicInteger(0); + Callable failingOp = + () -> { + callCount.incrementAndGet(); + throw new RuntimeException("persistent failure #" + callCount.get()); + }; + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> policy.execute(failingOp)); + + assertThat(thrown.getMessage()).contains("persistent failure #3"); + assertThat(callCount.get()).isEqualTo(3); + } + + @Test + @DisplayName("exponential backoff timing: delay increases exponentially") + void testExponentialBackoffTiming() { + RetryPolicy policy = + RetryPolicy.builder() + .maxAttempts(5) + .initialDelayMs(100) + .backoffMultiplier(2.0) + .maxDelayMs(5000) + .build(); + + // Verify delay calculation follows exponential pattern + long delay1 = policy.calculateDelay(1); // 100 * 2^0 = 100 + long delay2 = policy.calculateDelay(2); // 100 * 2^1 = 200 + long delay3 = policy.calculateDelay(3); // 100 * 2^2 = 400 + long delay4 = policy.calculateDelay(4); // 100 * 2^3 = 800 + + assertThat(delay1).isEqualTo(100); + assertThat(delay2).isEqualTo(200); + assertThat(delay3).isEqualTo(400); + assertThat(delay4).isEqualTo(800); + + // Verify delay is capped at maxDelayMs + RetryPolicy cappedPolicy = + RetryPolicy.builder() + .maxAttempts(5) + .initialDelayMs(1000) + .backoffMultiplier(3.0) + .maxDelayMs(5000) + .build(); + + long delayAttempt4 = cappedPolicy.calculateDelay(4); // 1000 * 3^3 = 27000, capped at 5000 + assertThat(delayAttempt4).isEqualTo(5000); + } + + @Test + @DisplayName("actual delay timing increases between attempts") + void testActualDelayIncreases() throws Exception { + RetryPolicy policy = + RetryPolicy.builder() + .maxAttempts(3) + .initialDelayMs(50) + .backoffMultiplier(2.0) + .maxDelayMs(5000) + .build(); + + AtomicInteger callCount = new AtomicInteger(0); + long startTime = System.currentTimeMillis(); + + RuntimeException thrown = + assertThrows( + RuntimeException.class, + () -> + policy.execute( + () -> { + callCount.incrementAndGet(); + throw new RuntimeException("fail"); + })); + + long elapsed = System.currentTimeMillis() - startTime; + + // Should have waited at least ~50ms (delay1) + ~100ms (delay2) = ~150ms total + assertThat(elapsed).isAtLeast(100L); // generous lower bound for CI + assertThat(callCount.get()).isEqualTo(3); + } + + @Test + @DisplayName("interrupted during backoff throws exception with InterruptedException cause") + void testInterruptedDuringBackoff() { + RetryPolicy policy = + RetryPolicy.builder() + .maxAttempts(3) + .initialDelayMs(5000) // long delay so we can interrupt + .backoffMultiplier(2.0) + .build(); + + AtomicInteger callCount = new AtomicInteger(0); + + Thread testThread = Thread.currentThread(); + + // Schedule interrupt after a short delay + Thread interrupter = + new Thread( + () -> { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + // ignore + } + testThread.interrupt(); + }); + interrupter.start(); + + Exception thrown = + assertThrows( + Exception.class, + () -> + policy.execute( + () -> { + callCount.incrementAndGet(); + throw new RuntimeException("transient"); + })); + + assertThat(thrown.getMessage()).contains("Retry interrupted"); + assertThat(callCount.get()).isEqualTo(1); + + // Clear interrupted status + Thread.interrupted(); + } + + @Test + @DisplayName("builder defaults are sensible") + void testBuilderDefaults() { + RetryPolicy policy = RetryPolicy.builder().build(); + + assertThat(policy.getMaxAttempts()).isEqualTo(3); + assertThat(policy.getInitialDelayMs()).isEqualTo(500); + assertThat(policy.getBackoffMultiplier()).isWithin(0.001).of(2.0); + assertThat(policy.getMaxDelayMs()).isEqualTo(5000); + } + + @Test + @DisplayName("builder validates maxAttempts < 1") + void testBuilderValidatesMaxAttempts() { + assertThrows( + IllegalArgumentException.class, () -> RetryPolicy.builder().maxAttempts(0).build()); + } + + @Test + @DisplayName("builder validates negative initialDelayMs") + void testBuilderValidatesNegativeDelay() { + assertThrows( + IllegalArgumentException.class, () -> RetryPolicy.builder().initialDelayMs(-1).build()); + } + + @Test + @DisplayName("builder validates backoffMultiplier < 1.0") + void testBuilderValidatesBackoffMultiplier() { + assertThrows( + IllegalArgumentException.class, () -> RetryPolicy.builder().backoffMultiplier(0.5).build()); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsServiceTest.java b/core/src/test/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsServiceTest.java new file mode 100644 index 000000000..9e616d23b --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/tts/OpenAiCompatibleTtsServiceTest.java @@ -0,0 +1,325 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import java.util.ArrayList; +import java.util.List; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import okio.Buffer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OpenAiCompatibleTtsService}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("OpenAiCompatibleTtsService Tests") +class OpenAiCompatibleTtsServiceTest { + + private MockWebServer mockServer; + private OpenAiCompatibleTtsService ttsService; + private TtsConfig config; + + @BeforeEach + void setUp() throws Exception { + mockServer = new MockWebServer(); + mockServer.start(0); + + String baseUrl = mockServer.url("/").toString(); + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + + ttsService = new OpenAiCompatibleTtsService(baseUrl); + config = + TtsConfig.builder() + .endpoint(baseUrl) + .voice("alloy") + .model("tts-1") + .outputFormat(TtsAudioFormat.WAV) + .build(); + } + + @AfterEach + void tearDown() throws Exception { + mockServer.close(); + } + + @Test + @DisplayName("Constructor throws on null endpoint") + void testConstructorNullEndpoint() { + assertThrows(IllegalArgumentException.class, () -> new OpenAiCompatibleTtsService(null)); + } + + @Test + @DisplayName("Constructor throws on empty endpoint") + void testConstructorEmptyEndpoint() { + assertThrows(IllegalArgumentException.class, () -> new OpenAiCompatibleTtsService("")); + } + + @Test + @DisplayName("Constructor strips trailing slash from endpoint") + void testConstructorStripsTrailingSlash() { + OpenAiCompatibleTtsService service = new OpenAiCompatibleTtsService("http://localhost:8000/"); + assertThat(service.getEndpoint()).isEqualTo("http://localhost:8000"); + } + + @Test + @DisplayName("synthesize returns audio bytes on success") + void testSynthesizeSuccess() throws Exception { + byte[] expectedAudio = new byte[] {0x52, 0x49, 0x46, 0x46, 0x01, 0x02, 0x03, 0x04}; + Buffer buffer = new Buffer(); + buffer.write(expectedAudio); + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue( + new MockResponse.Builder() + .code(200) + .addHeader("Content-Type", "application/octet-stream") + .body(buffer) + .build()); + + byte[] result = ttsService.synthesize("Hello world", config); + + assertThat(result).isEqualTo(expectedAudio); + + // Skip capabilities probe requests + mockServer.takeRequest(); + mockServer.takeRequest(); + RecordedRequest request = mockServer.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + String body = request.getBody().utf8(); + assertThat(body).contains("\"input\":\"Hello world\""); + assertThat(body).contains("\"voice\":\"alloy\""); + assertThat(body).contains("\"model\":\"tts-1\""); + } + + @Test + @DisplayName("synthesize includes API key in Authorization header") + void testSynthesizeWithApiKey() throws Exception { + String baseUrl = mockServer.url("/").toString(); + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + OpenAiCompatibleTtsService serviceWithKey = + new OpenAiCompatibleTtsService(baseUrl, "sk-test-key-123"); + + byte[] audioData = new byte[] {0x01, 0x02}; + Buffer buffer = new Buffer(); + buffer.write(audioData); + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(200).body(buffer).build()); + + serviceWithKey.synthesize("Test", config); + + // Skip capabilities probe requests + mockServer.takeRequest(); + mockServer.takeRequest(); + RecordedRequest request = mockServer.takeRequest(); + assertThat(request.getHeaders().get("Authorization")).isEqualTo("Bearer sk-test-key-123"); + } + + @Test + @DisplayName("synthesize throws TtsException on HTTP error") + void testSynthesizeHttpError() { + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue( + new MockResponse.Builder() + .code(500) + .body("{\"error\": \"Internal Server Error\"}") + .build()); + + TtsException exception = + assertThrows(TtsException.class, () -> ttsService.synthesize("Hello", config)); + + assertThat(exception.getMessage()).contains("500"); + assertThat(exception.getErrorCode()).isEqualTo("HTTP_500"); + } + + @Test + @DisplayName("synthesize throws TtsException on HTTP 429 rate limit") + void testSynthesizeRateLimitError() { + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue( + new MockResponse.Builder().code(429).body("{\"error\": \"Rate limit exceeded\"}").build()); + + TtsException exception = + assertThrows(TtsException.class, () -> ttsService.synthesize("Hello", config)); + + assertThat(exception.getErrorCode()).isEqualTo("HTTP_429"); + } + + @Test + @DisplayName("synthesize throws TtsException on null input") + void testSynthesizeNullInput() { + assertThrows(TtsException.class, () -> ttsService.synthesize(null, config)); + } + + @Test + @DisplayName("synthesize throws TtsException on empty input") + void testSynthesizeEmptyInput() { + assertThrows(TtsException.class, () -> ttsService.synthesize("", config)); + } + + @Test + @DisplayName("synthesizeAsync returns Single with audio bytes") + void testSynthesizeAsync() { + byte[] expectedAudio = new byte[] {0x10, 0x20, 0x30}; + Buffer buffer = new Buffer(); + buffer.write(expectedAudio); + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(200).body(buffer).build()); + + Single result = ttsService.synthesizeAsync("Hello async", config); + byte[] audioBytes = result.blockingGet(); + + assertThat(audioBytes).isEqualTo(expectedAudio); + } + + @Test + @DisplayName("synthesizeAsync propagates error as Single error") + void testSynthesizeAsyncError() { + // Capabilities probe: GET returns 404, HEAD returns 404 -> defaults used + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(404).build()); + mockServer.enqueue(new MockResponse.Builder().code(503).body("Service Unavailable").build()); + + Single result = ttsService.synthesizeAsync("Hello", config); + + assertThrows(RuntimeException.class, result::blockingGet); + } + + @Test + @DisplayName("synthesizeStream returns Flowable of audio chunks") + void testSynthesizeStream() { + byte[] largeAudio = new byte[8192]; + for (int i = 0; i < largeAudio.length; i++) { + largeAudio[i] = (byte) (i % 256); + } + Buffer buffer = new Buffer(); + buffer.write(largeAudio); + mockServer.enqueue( + new MockResponse.Builder() + .code(200) + .addHeader("Content-Type", "application/octet-stream") + .body(buffer) + .build()); + + Flowable stream = ttsService.synthesizeStream("Stream this text", config); + List chunks = new ArrayList<>(); + stream.blockingForEach(chunks::add); + + assertThat(chunks).isNotEmpty(); + int totalBytes = chunks.stream().mapToInt(c -> c.length).sum(); + assertThat(totalBytes).isEqualTo(largeAudio.length); + } + + @Test + @DisplayName("synthesizeStream emits error on HTTP failure") + void testSynthesizeStreamError() { + mockServer.enqueue( + new MockResponse.Builder().code(500).body("{\"error\": \"Server error\"}").build()); + + Flowable stream = ttsService.synthesizeStream("Hello", config); + + assertThrows(RuntimeException.class, () -> stream.blockingFirst()); + } + + @Test + @DisplayName("synthesizeStream emits error on null input") + void testSynthesizeStreamNullInput() { + Flowable stream = ttsService.synthesizeStream(null, config); + + assertThrows(RuntimeException.class, () -> stream.blockingFirst()); + } + + @Test + @DisplayName("isAvailable returns true when server responds OK") + void testIsAvailableTrue() { + mockServer.enqueue(new MockResponse.Builder().code(200).build()); + + boolean available = ttsService.isAvailable(); + + assertThat(available).isTrue(); + } + + @Test + @DisplayName("isAvailable returns false when server returns error") + void testIsAvailableFalseOnError() { + mockServer.enqueue(new MockResponse.Builder().code(503).build()); + + boolean available = ttsService.isAvailable(); + + assertThat(available).isFalse(); + } + + @Test + @DisplayName("isAvailable returns false when server is unreachable") + void testIsAvailableFalseWhenUnreachable() throws Exception { + mockServer.close(); + + boolean available = ttsService.isAvailable(); + + assertThat(available).isFalse(); + } + + @Test + @DisplayName("getHealth returns healthy status when server is up") + void testGetHealthHealthy() { + mockServer.enqueue(new MockResponse.Builder().code(200).build()); + + var health = ttsService.getHealth(); + + assertThat(health.isAvailable()).isTrue(); + assertThat(health.getMessage().isPresent()).isTrue(); + assertThat(health.getMessage().get()).contains("healthy"); + assertThat(health.getResponseTimeMs().isPresent()).isTrue(); + assertThat(health.getResponseTimeMs().get()).isAtLeast(0L); + } + + @Test + @DisplayName("getHealth returns unhealthy when server is down") + void testGetHealthUnhealthy() throws Exception { + mockServer.close(); + + var health = ttsService.getHealth(); + + assertThat(health.isAvailable()).isFalse(); + assertThat(health.getMessage().isPresent()).isTrue(); + assertThat(health.getMessage().get()).contains("failed"); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/tts/TtsCapabilitiesTest.java b/core/src/test/java/com/google/adk/transcription/tts/TtsCapabilitiesTest.java new file mode 100644 index 000000000..9968f930c --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/tts/TtsCapabilitiesTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TtsCapabilities}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("TtsCapabilities Tests") +class TtsCapabilitiesTest { + + @Test + @DisplayName("builder defaults: all formats supported, 4096 max text, streaming true") + void testBuilderDefaults() { + TtsCapabilities capabilities = TtsCapabilities.builder().build(); + + // All formats should be supported by default + assertThat(capabilities.getSupportedFormats()).containsExactly(TtsAudioFormat.values()); + assertThat(capabilities.getSupportedVoices()).isEmpty(); + assertThat(capabilities.getSupportedModels()).isEmpty(); + assertThat(capabilities.getMaxTextLength()).isEqualTo(4096); + assertThat(capabilities.isSupportsStreaming()).isTrue(); + } + + @Test + @DisplayName("builder defaults: isFormatSupported returns true for all formats") + void testBuilderDefaultsAllFormatsSupported() { + TtsCapabilities capabilities = TtsCapabilities.builder().build(); + + for (TtsAudioFormat format : TtsAudioFormat.values()) { + assertThat(capabilities.isFormatSupported(format)).isTrue(); + } + } + + @Test + @DisplayName("custom capabilities with specific formats") + void testCustomFormats() { + List formats = Arrays.asList(TtsAudioFormat.MP3, TtsAudioFormat.WAV); + TtsCapabilities capabilities = TtsCapabilities.builder().supportedFormats(formats).build(); + + assertThat(capabilities.getSupportedFormats()) + .containsExactly(TtsAudioFormat.MP3, TtsAudioFormat.WAV); + assertThat(capabilities.isFormatSupported(TtsAudioFormat.MP3)).isTrue(); + assertThat(capabilities.isFormatSupported(TtsAudioFormat.WAV)).isTrue(); + assertThat(capabilities.isFormatSupported(TtsAudioFormat.OGG)).isFalse(); + assertThat(capabilities.isFormatSupported(TtsAudioFormat.PCM)).isFalse(); + } + + @Test + @DisplayName("custom capabilities with voices and models") + void testCustomVoicesAndModels() { + TtsCapabilities capabilities = + TtsCapabilities.builder() + .supportedVoices(Arrays.asList("alloy", "echo", "nova")) + .supportedModels(Arrays.asList("tts-1", "tts-1-hd")) + .build(); + + assertThat(capabilities.getSupportedVoices()).containsExactly("alloy", "echo", "nova"); + assertThat(capabilities.getSupportedModels()).containsExactly("tts-1", "tts-1-hd"); + } + + @Test + @DisplayName("custom maxTextLength") + void testCustomMaxTextLength() { + TtsCapabilities capabilities = TtsCapabilities.builder().maxTextLength(8192).build(); + + assertThat(capabilities.getMaxTextLength()).isEqualTo(8192); + } + + @Test + @DisplayName("custom streaming disabled") + void testStreamingDisabled() { + TtsCapabilities capabilities = TtsCapabilities.builder().supportsStreaming(false).build(); + + assertThat(capabilities.isSupportsStreaming()).isFalse(); + } + + @Test + @DisplayName("maxTextLength validation rejects non-positive values") + void testMaxTextLengthValidation() { + assertThrows( + IllegalArgumentException.class, () -> TtsCapabilities.builder().maxTextLength(0).build()); + assertThrows( + IllegalArgumentException.class, () -> TtsCapabilities.builder().maxTextLength(-1).build()); + } + + @Test + @DisplayName("lists are immutable after build") + void testImmutability() { + TtsCapabilities capabilities = + TtsCapabilities.builder().supportedVoices(Arrays.asList("alloy", "echo")).build(); + + assertThrows( + UnsupportedOperationException.class, () -> capabilities.getSupportedVoices().add("nova")); + assertThrows( + UnsupportedOperationException.class, + () -> capabilities.getSupportedFormats().add(TtsAudioFormat.FLAC)); + } + + @Test + @DisplayName("toString contains meaningful information") + void testToString() { + TtsCapabilities capabilities = TtsCapabilities.builder().build(); + + String str = capabilities.toString(); + assertThat(str).contains("TtsCapabilities"); + assertThat(str).contains("4096"); + } +} diff --git a/core/src/test/java/com/google/adk/transcription/tts/TtsConfigTest.java b/core/src/test/java/com/google/adk/transcription/tts/TtsConfigTest.java new file mode 100644 index 000000000..1514493b1 --- /dev/null +++ b/core/src/test/java/com/google/adk/transcription/tts/TtsConfigTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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. + */ + +package com.google.adk.transcription.tts; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TtsConfig}. + * + * @author Sandeep Belgavi + * @since 2026-08-04 + */ +@DisplayName("TtsConfig Tests") +class TtsConfigTest { + + @Test + @DisplayName("Builder creates config with default values") + void testBuilderDefaults() { + TtsConfig config = TtsConfig.builder().endpoint("http://localhost:8000").build(); + + assertThat(config.getEndpoint()).isEqualTo("http://localhost:8000"); + assertThat(config.getVoice()).isEqualTo("default"); + assertThat(config.getLanguage()).isEqualTo("en-US"); + assertThat(config.getModel()).isNull(); + assertThat(config.getOutputFormat()).isEqualTo(TtsAudioFormat.WAV); + assertThat(config.getSampleRate()).isEqualTo(24000); + assertThat(config.getSpeed()).isEqualTo(1.0); + assertThat(config.getApiKey().isPresent()).isFalse(); + } + + @Test + @DisplayName("Builder creates config with custom values") + void testBuilderCustomValues() { + TtsConfig config = + TtsConfig.builder() + .endpoint("https://api.openai.com") + .voice("nova") + .language("fr-FR") + .model("tts-1-hd") + .outputFormat(TtsAudioFormat.MP3) + .sampleRate(48000) + .speed(1.5) + .apiKey("sk-test-key") + .build(); + + assertThat(config.getEndpoint()).isEqualTo("https://api.openai.com"); + assertThat(config.getVoice()).isEqualTo("nova"); + assertThat(config.getLanguage()).isEqualTo("fr-FR"); + assertThat(config.getModel()).isEqualTo("tts-1-hd"); + assertThat(config.getOutputFormat()).isEqualTo(TtsAudioFormat.MP3); + assertThat(config.getSampleRate()).isEqualTo(48000); + assertThat(config.getSpeed()).isEqualTo(1.5); + assertThat(config.getApiKey().isPresent()).isTrue(); + assertThat(config.getApiKey().get()).isEqualTo("sk-test-key"); + } + + @Test + @DisplayName("Builder throws exception when endpoint is null") + void testBuilderMissingEndpoint() { + assertThrows(IllegalArgumentException.class, () -> TtsConfig.builder().build()); + } + + @Test + @DisplayName("Builder throws exception when endpoint is empty") + void testBuilderEmptyEndpoint() { + assertThrows(IllegalArgumentException.class, () -> TtsConfig.builder().endpoint("").build()); + } + + @Test + @DisplayName("Builder throws exception for non-positive sample rate") + void testBuilderInvalidSampleRate() { + assertThrows( + IllegalArgumentException.class, + () -> TtsConfig.builder().endpoint("http://localhost:8000").sampleRate(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> TtsConfig.builder().endpoint("http://localhost:8000").sampleRate(-1).build()); + } + + @Test + @DisplayName("Builder throws exception for non-positive speed") + void testBuilderInvalidSpeed() { + assertThrows( + IllegalArgumentException.class, + () -> TtsConfig.builder().endpoint("http://localhost:8000").speed(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> TtsConfig.builder().endpoint("http://localhost:8000").speed(-0.5).build()); + } + + @Test + @DisplayName("toString includes all fields") + void testToString() { + TtsConfig config = + TtsConfig.builder().endpoint("http://localhost:8000").voice("alloy").model("tts-1").build(); + + String str = config.toString(); + assertThat(str).contains("endpoint='http://localhost:8000'"); + assertThat(str).contains("voice='alloy'"); + assertThat(str).contains("model='tts-1'"); + } + + @Test + @DisplayName("ApiKey is empty Optional when not set") + void testApiKeyAbsent() { + TtsConfig config = TtsConfig.builder().endpoint("http://localhost:8000").build(); + + assertThat(config.getApiKey()).isEqualTo(java.util.Optional.empty()); + } +}