Skip to content

Commit 4680413

Browse files
MarkDaoustcopybara-github
authored andcommitted
feat: voice consent signature types across all SDK languages.
Most languages capture the "setup complete" message as a property on the session object. Golang returns the setup complete to the user as the first message. So there's no need to capture it, and that would be a breaking change. PiperOrigin-RevId: 885814555
1 parent c748b7c commit 4680413

10 files changed

Lines changed: 817 additions & 33 deletions

File tree

examples/src/main/java/com/google/genai/examples/LiveAudioConversationAsync.java

Lines changed: 152 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@
7575
import javax.sound.sampled.LineUnavailableException;
7676
import javax.sound.sampled.SourceDataLine;
7777
import javax.sound.sampled.TargetDataLine;
78+
import java.io.ByteArrayOutputStream;
79+
import java.io.ByteArrayInputStream;
80+
import java.io.IOException;
81+
import java.io.File;
82+
import java.nio.file.Files;
83+
import java.nio.file.Paths;
84+
import javax.sound.sampled.AudioInputStream;
85+
import javax.sound.sampled.AudioFileFormat;
86+
import com.google.genai.types.ReplicatedVoiceConfig;
87+
import com.google.genai.types.VoiceConsentSignature;
7888

7989
/** Example of using the live module for a streaming audio conversation. */
8090
public final class LiveAudioConversationAsync {
@@ -93,14 +103,15 @@ public final class LiveAudioConversationAsync {
93103
private static TargetDataLine microphoneLine;
94104
private static SourceDataLine speakerLine;
95105
private static AsyncSession session;
96-
private static ExecutorService micExecutor = Executors.newSingleThreadExecutor();
106+
private static final ExecutorService micExecutor = Executors.newSingleThreadExecutor();
107+
private static String promptString = null;
108+
private static final ByteArrayOutputStream audioResponseBytes = new ByteArrayOutputStream();
97109

98110
/** Creates the parameters for sending an audio chunk. */
99111
public static LiveSendRealtimeInputParameters createAudioContent(byte[] audioData) {
100112

101113
if (audioData == null) {
102-
System.err.println("Error: Audio is null");
103-
return null;
114+
throw new IllegalArgumentException("Audio data cannot be null");
104115
}
105116

106117
return LiveSendRealtimeInputParameters.builder()
@@ -155,31 +166,95 @@ public static void main(String[] args) throws LineUnavailableException {
155166
System.out.println("Using Gemini Developer API");
156167
}
157168

169+
String getModelFromArgs = null;
170+
String voiceSamplePath = null;
171+
String voiceConsentPath = null;
172+
String voiceSignature = null;
173+
promptString = null;
174+
175+
for (String arg : args) {
176+
if (arg.startsWith("--model=")) {
177+
getModelFromArgs = arg.substring("--model=".length());
178+
} else if (arg.startsWith("--voice-sample=")) {
179+
voiceSamplePath = arg.substring("--voice-sample=".length());
180+
} else if (arg.startsWith("--voice-consent=")) {
181+
voiceConsentPath = arg.substring("--voice-consent=".length());
182+
} else if (arg.startsWith("--voice-signature=")) {
183+
voiceSignature = arg.substring("--voice-signature=".length());
184+
} else if (arg.startsWith("--prompt=")) {
185+
promptString = arg.substring("--prompt=".length());
186+
} else if (!arg.startsWith("--") && getModelFromArgs == null) {
187+
getModelFromArgs = arg;
188+
}
189+
}
190+
191+
byte[] voiceSampleAudio = null;
192+
byte[] consentAudio = null;
193+
194+
if (voiceSamplePath != null) {
195+
try {
196+
voiceSampleAudio = Files.readAllBytes(Paths.get(voiceSamplePath));
197+
} catch (IOException e) {
198+
throw new IllegalStateException("Failed to read voice sample", e);
199+
}
200+
if (voiceConsentPath == null && voiceSignature == null) {
201+
throw new IllegalArgumentException(
202+
"Either --voice-consent or --voice-signature must be provided when --voice-sample is"
203+
+ " used.");
204+
}
205+
}
206+
if (voiceConsentPath != null) {
207+
try {
208+
consentAudio = Files.readAllBytes(Paths.get(voiceConsentPath));
209+
} catch (IOException e) {
210+
throw new IllegalStateException("Failed to read voice consent", e);
211+
}
212+
}
213+
158214
final String modelId;
159-
if (args.length != 0) {
160-
modelId = args[0];
215+
if (getModelFromArgs != null) {
216+
modelId = getModelFromArgs;
161217
} else if (client.vertexAI()) {
162218
modelId = Constants.GEMINI_LIVE_MODEL_NAME;
163219
} else {
164220
modelId = Constants.GEMINI_LIVE_MODEL_NAME_PREVIEW;
165221
}
166222

167223
// --- Audio Line Setup ---
168-
microphoneLine = getMicrophoneLine();
169-
speakerLine = getSpeakerLine();
224+
if (promptString == null) {
225+
microphoneLine = getMicrophoneLine();
226+
speakerLine = getSpeakerLine();
227+
}
170228

171229
// --- Live API Config for Audio ---
172230
// Choice of ["Aoede", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Zephyr"]
173231
String voiceName = "Aoede";
232+
233+
VoiceConfig.Builder voiceConfigBuilder = VoiceConfig.builder();
234+
if (voiceSampleAudio != null) {
235+
ReplicatedVoiceConfig.Builder repBuilder =
236+
ReplicatedVoiceConfig.builder()
237+
.mimeType("audio/wav")
238+
.voiceSampleAudio(voiceSampleAudio);
239+
if (consentAudio != null) {
240+
repBuilder.consentAudio(consentAudio);
241+
}
242+
if (voiceSignature != null) {
243+
repBuilder.voiceConsentSignature(
244+
VoiceConsentSignature.builder().signature(voiceSignature));
245+
}
246+
voiceConfigBuilder.replicatedVoiceConfig(repBuilder);
247+
} else {
248+
voiceConfigBuilder.prebuiltVoiceConfig(
249+
PrebuiltVoiceConfig.builder().voiceName(voiceName));
250+
}
251+
174252
LiveConnectConfig config =
175253
LiveConnectConfig.builder()
176254
.responseModalities(Modality.Known.AUDIO)
177255
.speechConfig(
178256
SpeechConfig.builder()
179-
.voiceConfig(
180-
VoiceConfig.builder()
181-
.prebuiltVoiceConfig(
182-
PrebuiltVoiceConfig.builder().voiceName(voiceName)))
257+
.voiceConfig(voiceConfigBuilder)
183258
.languageCode("en-US"))
184259
.realtimeInputConfig(
185260
RealtimeInputConfig.builder()
@@ -215,8 +290,11 @@ public static void main(String[] args) throws LineUnavailableException {
215290
System.out.println("Closing API session...");
216291
session.close().get(5, TimeUnit.SECONDS); // Wait with timeout
217292
System.out.println("API session closed.");
218-
} catch (Exception e) {
293+
} catch (ExecutionException | java.util.concurrent.TimeoutException e) {
219294
System.err.println("Error closing API session: " + e.getMessage());
295+
} catch (InterruptedException e) {
296+
System.err.println("Interrupted while closing API session");
297+
Thread.currentThread().interrupt();
220298
}
221299
}
222300
// Close audio lines
@@ -232,19 +310,33 @@ public static void main(String[] args) throws LineUnavailableException {
232310
session = client.async.live.connect(modelId, config).get();
233311
System.out.println("Connected.");
234312

235-
// --- Start Audio Lines ---
236-
microphoneLine.start();
237-
speakerLine.start();
238-
System.out.println("Microphone and speakers started. Speak now (Press Ctrl+C to exit)...");
313+
if (session.setupComplete() != null && session.setupComplete().voiceConsentSignature().isPresent()) {
314+
System.out.println(
315+
"\n=== Voice Consent Signature Received ===\n"
316+
+ session.setupComplete().voiceConsentSignature().get().signature().orElse("")
317+
+ "\n========================================\n");
318+
}
239319

240320
// --- Start Receiving Audio Responses ---
241321
CompletableFuture<Void> receiveFuture =
242322
session.receive(LiveAudioConversationAsync::handleAudioResponse);
243-
System.err.println("Receive stream started."); // Add this line
244-
245-
// --- Start Sending Microphone Audio ---
246-
CompletableFuture<Void> sendFuture =
247-
CompletableFuture.runAsync(LiveAudioConversationAsync::sendMicrophoneAudio, micExecutor);
323+
System.err.println("Receive stream started.");
324+
325+
CompletableFuture<Void> sendFuture;
326+
if (promptString == null) {
327+
// --- Start Audio Lines ---
328+
microphoneLine.start();
329+
speakerLine.start();
330+
System.out.println("Microphone and speakers started. Speak now (Press Ctrl+C to exit)...");
331+
332+
// --- Start Sending Microphone Audio ---
333+
sendFuture = CompletableFuture.runAsync(LiveAudioConversationAsync::sendMicrophoneAudio, micExecutor);
334+
} else {
335+
System.out.println("Sending prompt: " + promptString);
336+
session.sendRealtimeInput(
337+
LiveSendRealtimeInputParameters.builder().text(promptString).build()).get();
338+
sendFuture = CompletableFuture.completedFuture(null);
339+
}
248340

249341
// Keep the main thread alive. Wait for sending or receiving to finish (or
250342
// error).
@@ -313,13 +405,19 @@ public static void handleAudioResponse(LiveServerMessage message) {
313405
content -> {
314406
// Handle interruptions from Gemini.
315407
if (content.interrupted().orElse(false)) {
316-
speakerLine.flush();
408+
if (speakerLine != null && speakerLine.isOpen()) {
409+
speakerLine.flush();
410+
}
317411
return; // Skip processing the rest of this message's audio.
318412
}
319413

320414
// Handle Model turn completion.
321415
if (content.turnComplete().orElse(false)) {
322-
// The turn is over, no more audio will be sent for this turn.
416+
if (promptString != null) {
417+
saveWavFile();
418+
System.out.println("Response received, exiting.");
419+
System.exit(0);
420+
}
323421
return;
324422
}
325423

@@ -334,15 +432,45 @@ public static void handleAudioResponse(LiveServerMessage message) {
334432
if (speakerLine != null && speakerLine.isOpen()) {
335433
// Write audio data to the speaker
336434
speakerLine.write(audioBytes, 0, audioBytes.length);
435+
} else {
436+
System.out.println(
437+
"Received audio response chunk: " + audioBytes.length + " bytes.");
438+
}
439+
try {
440+
audioResponseBytes.write(audioBytes);
441+
} catch (IOException e) {
442+
System.err.println("Failed to accumulate audio bytes: " + e.getMessage());
337443
}
338444
});
339445

340446
// If this is the last message of a generation, drain the buffer.
341447
if (content.generationComplete().orElse(false)) {
342-
speakerLine.drain();
448+
if (speakerLine != null && speakerLine.isOpen()) {
449+
speakerLine.drain();
450+
}
343451
}
344452
});
345453
}
346454

455+
private static void saveWavFile() {
456+
byte[] audioData = audioResponseBytes.toByteArray();
457+
if (audioData.length == 0) {
458+
System.out.println("No audio data received to save.");
459+
return;
460+
}
461+
try {
462+
AudioInputStream ais = new AudioInputStream(
463+
new ByteArrayInputStream(audioData),
464+
SPEAKER_AUDIO_FORMAT,
465+
audioData.length / SPEAKER_AUDIO_FORMAT.getFrameSize()
466+
);
467+
File outputFile = new File("output.wav");
468+
AudioSystem.write(ais, AudioFileFormat.Type.WAVE, outputFile);
469+
System.out.println("Saved audio response to " + outputFile.getAbsolutePath());
470+
} catch (IOException e) {
471+
System.err.println("Failed to save WAV file: " + e.getMessage());
472+
}
473+
}
474+
347475
private LiveAudioConversationAsync() {}
348476
}

src/main/java/com/google/genai/AsyncLive.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.genai.types.LiveConnectConfig;
2626
import com.google.genai.types.LiveConnectParameters;
2727
import com.google.genai.types.LiveServerMessage;
28+
import com.google.genai.types.LiveServerSetupComplete;
2829
import java.io.IOException;
2930
import java.net.URI;
3031
import java.net.URISyntaxException;
@@ -283,11 +284,13 @@ private void handleIncomingMessage(String message) {
283284
try {
284285
LiveServerMessage initialResponse = LiveServerMessage.fromJson(message);
285286
if (initialResponse.setupComplete().isPresent()) {
287+
LiveServerSetupComplete setupComplete = initialResponse.setupComplete().get();
286288
sessionFuture.complete(
287289
new AsyncSession(
288290
apiClient,
289291
this,
290-
initialResponse.setupComplete().get().sessionId().orElse(null)));
292+
setupComplete.sessionId().orElse(null),
293+
setupComplete));
291294
} else {
292295
sessionFuture.completeExceptionally(
293296
new GenAiIOException(

src/main/java/com/google/genai/AsyncSession.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.genai.types.LiveClientContent;
2222
import com.google.genai.types.LiveClientMessage;
2323
import com.google.genai.types.LiveClientToolResponse;
24+
import com.google.genai.types.LiveServerSetupComplete;
2425
import com.google.genai.types.LiveSendClientContentParameters;
2526
import com.google.genai.types.LiveSendRealtimeInputParameters;
2627
import com.google.genai.types.LiveSendToolResponseParameters;
@@ -40,11 +41,17 @@ public final class AsyncSession {
4041

4142
private final AsyncLive.GenAiWebSocketClient websocket;
4243
final String sessionId;
44+
private final LiveServerSetupComplete setupComplete;
4345

44-
AsyncSession(ApiClient apiClient, AsyncLive.GenAiWebSocketClient websocket, String sessionId) {
46+
AsyncSession(
47+
ApiClient apiClient,
48+
AsyncLive.GenAiWebSocketClient websocket,
49+
String sessionId,
50+
LiveServerSetupComplete setupComplete) {
4551
this.apiClient = apiClient;
4652
this.websocket = websocket;
4753
this.sessionId = sessionId;
54+
this.setupComplete = setupComplete;
4855
}
4956

5057
/**
@@ -145,4 +152,8 @@ public CompletableFuture<Void> close() {
145152
public String sessionId() {
146153
return sessionId;
147154
}
155+
156+
public LiveServerSetupComplete setupComplete() {
157+
return setupComplete;
158+
}
148159
}

0 commit comments

Comments
 (0)