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/adktriaginghelloworldmcpfilesystem
+ 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:
+ *
+ *
+ *
live_voice_agent — Uses Gemini Live model for real-time bidirectional audio
+ * (mic → Gemini → speaker directly). This is the native live streaming approach.
+ *
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).
+ *
Then open http://localhost:8080 in your browser. You'll see both agents in the Dev UI dropdown.
+ *
+ *
+ *
live_voice_agent: Click the microphone button → speak → hear Gemini respond in
+ * real-time audio
+ *
tts_voice_agent: Type text → navigation commands handled instantly, complex queries
+ * go to Gemini. If TTS endpoint is configured, audio comes back too.
+ *
+ *
+ *
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:
+ *
+ *
+ *
+ * @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-junit5test
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:
+ *
+ *
+ *
Receives audio input (inline data) or text from the invocation context
+ *
Transcribes audio via STT (OllamaWhisperSttService)
+ *
Classifies intent using the configured {@link IntentClassifier}
+ *
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
+ *
+ *
Synthesizes TTS audio for voice responses
+ *
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.
+ *
+ *
+ *
+ * @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.
+ *
+ *
+ * @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.
+ *
+ *
+ * @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.
+ *
+ *
+ *
+ * @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());
+ }
+}