Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .fern/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
},
"enable-wire-tests": true
},
"originGitCommit": "8dd6f48a06eee8c3985894f68ba3b554e5564d21",
"originGitCommit": "335af96251466afae7a9f71badb65c630a48626e",
"originGitCommitIsDirty": true,
"invokedBy": "manual",
"sdkVersion": "0.6.0"
"sdkVersion": "0.6.1"
}
17 changes: 14 additions & 3 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,21 @@ src/main/java/com/deepgram/core/transport/
# Pull this back out once the fixes are upstreamed into the Fern generator.
src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java

# Manual equals/hashCode contract fix: Fern generates equals() but no hashCode() for this
# fields-less message type. We add a consistent hashCode(). Unfreeze and drop the patch once
# the generator emits a matching equals/hashCode pair.
# Manual equals/hashCode contract fix: Fern generates equals() (all instances equal) but no
# hashCode() for fields-less message types, violating the Object contract. We add a consistent
# hashCode() to each. Unfreeze and drop these patches once the generator emits a matching
# equals/hashCode pair for fields-less types (tracked as an upstream Fern request).
src/main/java/com/deepgram/resources/listen/v2/types/ListenV2CloseStream.java
src/main/java/com/deepgram/resources/speak/v2/types/SpeakV2Close.java
src/main/java/com/deepgram/resources/speak/v2/types/SpeakV2Flush.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1ListenUpdated.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1SpeakUpdated.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1AgentAudioDone.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1SettingsApplied.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1UserStartedSpeaking.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1KeepAlive.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1ThinkUpdated.java
src/main/java/com/deepgram/resources/agent/v1/types/AgentV1PromptUpdated.java

# Build and project configuration
build.gradle
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ Current temporarily frozen files:

- `src/main/java/com/deepgram/core/ClientOptions.java` - preserves release-please version markers and correct SDK header constants that Fern currently overwrites; use the standard `.bak` swap/restore workflow during regen review
- `src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java` - carries bug fixes for `maxRetries(0)` semantics ("connect once, don't retry") and a configurable `connectionTimeoutMs` field (was hardcoded 4000ms), plus an `applyOptionsOverride(...)` hook used by `TransportWebSocketFactory` to apply per-transport reconnect policy; pull this back out once the fixes are upstreamed into the Fern generator. Use the standard `.bak` swap/restore workflow during regen review.
- Fields-less message types carrying a manual `hashCode()` patch (Fern generates `equals()` but no `hashCode()` for these, violating the Object contract): `src/main/java/com/deepgram/resources/listen/v2/types/ListenV2CloseStream.java`, `src/main/java/com/deepgram/resources/speak/v2/types/SpeakV2Close.java`, `src/main/java/com/deepgram/resources/speak/v2/types/SpeakV2Flush.java`, and the `AgentV1*` event types `src/main/java/com/deepgram/resources/agent/v1/types/{AgentV1ListenUpdated,AgentV1SpeakUpdated,AgentV1AgentAudioDone,AgentV1SettingsApplied,AgentV1UserStartedSpeaking,AgentV1KeepAlive,AgentV1ThinkUpdated,AgentV1PromptUpdated}.java`. Use the standard `.bak` swap/restore workflow during regen review; drop the patches and unfreeze all of them once the generator emits a matching equals/hashCode pair for fields-less types (tracked as an upstream Fern request).

### Prepare repo for regeneration

1. Create a new branch off `main` named `lo/sdk-gen-<YYYY-MM-DD>`.
1. Create a new branch off `main` named `<YOUR_INITIALS>/sdk-gen-<YYYY-MM-DD>` (e.g. `gh/sdk-gen-2026-07-09`). Use your own initials as the prefix.
2. Push the branch and create a PR titled `chore: SDK regeneration <YYYY-MM-DD>` (empty commit if needed).
3. Read `.fernignore` and classify each entry using the rules above.
4. For each temporarily frozen file only:
Expand Down
140 changes: 140 additions & 0 deletions examples/speak/StreamingTtsV2.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import com.deepgram.DeepgramClient;
import com.deepgram.resources.speak.v2.types.SpeakV2Close;
import com.deepgram.resources.speak.v2.types.SpeakV2Flush;
import com.deepgram.resources.speak.v2.types.SpeakV2Speak;
import com.deepgram.resources.speak.v2.websocket.V2ConnectOptions;
import com.deepgram.resources.speak.v2.websocket.V2WebSocketClient;
import com.deepgram.types.SpeakV2Encoding;
import com.deepgram.types.SpeakV2SampleRate;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
* Streaming text-to-speech using the Speak V2 WebSocket. Sends text chunks and receives audio data in real time, saving
* to a file. Unlike V1, the V2 connection is opened with {@link V2ConnectOptions} (model is required; encoding and
* sample rate are optional).
*
* <p>Usage: java StreamingTtsV2 [output-file]
*/
public class StreamingTtsV2 {
public static void main(String[] args) {
// Get API key from environment
String apiKey = System.getenv("DEEPGRAM_API_KEY");
if (apiKey == null || apiKey.isEmpty()) {
System.err.println("DEEPGRAM_API_KEY environment variable is required");
System.exit(1);
}

String outputFile = "output_streaming_v2.wav";
if (args.length > 0) {
outputFile = args[0];
}

System.out.println("Streaming Text-to-Speech (Speak V2 WebSocket)");
System.out.println("Output: " + outputFile);
System.out.println();

// Create client
DeepgramClient client = DeepgramClient.builder().apiKey(apiKey).build();

// Get the Speak V2 WebSocket client
V2WebSocketClient wsClient = client.speak().v2().v2WebSocket();

CountDownLatch closeLatch = new CountDownLatch(1);
AtomicInteger audioChunks = new AtomicInteger(0);

try (OutputStream audioOutput = new FileOutputStream(outputFile)) {
final String outputPath = outputFile;

// Register event handlers before connecting
wsClient.onConnected(() -> {
System.out.println("Connected to Deepgram TTS WebSocket (V2)");
});

wsClient.onSpeakV2Audio(audioData -> {
try {
// Audio data arrives as ByteString
byte[] bytes = audioData.toByteArray();
audioOutput.write(bytes);
int count = audioChunks.incrementAndGet();
System.out.printf("Received audio chunk #%d (%d bytes)%n", count, bytes.length);
} catch (Exception e) {
System.err.println("Error writing audio: " + e.getMessage());
}
});

wsClient.onSpeechStarted(started -> {
System.out.println("Speech started: " + started);
});

wsClient.onFlushed(flushed -> {
System.out.println("Audio flushed - all queued text has been converted");
});

wsClient.onWarning(warning -> {
System.out.println("Warning: " + warning);
});

wsClient.onErrorMessage(error -> {
System.err.println("Server error: " + error);
});

wsClient.onError(error -> {
System.err.println("Error: " + error.getMessage());
});

wsClient.onDisconnected(reason -> {
// audioOutput is closed by try-with-resources when the block exits.
System.out.println(
"\nConnection closed (code: " + reason.getCode() + ", reason: " + reason.getReason() + ")");
closeLatch.countDown();
});

// Connect to the WebSocket. Model is required; encoding and sample rate are optional.
V2ConnectOptions connectOptions = V2ConnectOptions.builder()
.model("aura-2-thalia-en")
.encoding(SpeakV2Encoding.LINEAR16)
.sampleRate(SpeakV2SampleRate.SIXTEEN_THOUSAND)
.build();
CompletableFuture<Void> connectFuture = wsClient.connect(connectOptions);
connectFuture.get(10, TimeUnit.SECONDS);

// Send text chunks for TTS conversion
String[] sentences = {
"Hello, this is a streaming text-to-speech demo.",
"Each sentence is sent as a separate message.",
"The audio is generated and streamed back in real time."
};

for (String sentence : sentences) {
System.out.println("Sending: \"" + sentence + "\"");
wsClient.sendSpeak(SpeakV2Speak.builder().text(sentence).build());
}

// Flush to ensure all text is processed
wsClient.sendFlush(SpeakV2Flush.builder().build());

// Give time for audio to arrive
Thread.sleep(5000);

// Close the connection
System.out.println("\nClosing connection...");
wsClient.sendClose(SpeakV2Close.builder().build());

closeLatch.await(10, TimeUnit.SECONDS);

System.out.printf("%nTotal audio chunks received: %d%n", audioChunks.get());
System.out.printf("Audio saved to %s%n", outputPath);

} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
e.printStackTrace();
} finally {
wsClient.disconnect();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,17 @@
* Provides production-ready resilience for WebSocket connections.
*/
public abstract class ReconnectingWebSocketListener extends WebSocketListener {
// Option-derived fields are volatile (not final) so {@link #applyOptionsOverride} can rewire them
// after construction — used by {@code TransportWebSocketFactory} to honour
// Overridable options are held behind a single volatile reference (not five separate volatile
// fields) so {@link #applyOptionsOverride} swaps them atomically — a reader that snapshots the
// reference once sees a mutually-consistent set (e.g. getNextDelay() cannot observe a new min
// paired with an old max). Rewiring is used by {@code TransportWebSocketFactory} to honour
// {@code DeepgramTransportFactory.reconnectOptions()} without editing the generated WS clients.
private volatile long minReconnectionDelayMs;

private volatile long maxReconnectionDelayMs;

private volatile double reconnectionDelayGrowFactor;

private volatile int maxRetries;
private volatile ReconnectOptions activeOptions;

// maxEnqueuedMessages is fixed at construction (the queue is sized once) and intentionally not
// overridable, so it stays a separate final field rather than being read from activeOptions.
private final int maxEnqueuedMessages;

private volatile long connectionTimeoutMs;

private final AtomicInteger retryCount = new AtomicInteger(0);

private final AtomicBoolean connectLock = new AtomicBoolean(false);
Expand All @@ -66,12 +62,8 @@ public abstract class ReconnectingWebSocketListener extends WebSocketListener {
*/
public ReconnectingWebSocketListener(
ReconnectingWebSocketListener.ReconnectOptions options, Supplier<? extends WebSocket> connectionSupplier) {
this.minReconnectionDelayMs = options.minReconnectionDelayMs;
this.maxReconnectionDelayMs = options.maxReconnectionDelayMs;
this.reconnectionDelayGrowFactor = options.reconnectionDelayGrowFactor;
this.maxRetries = options.maxRetries;
this.activeOptions = options;
this.maxEnqueuedMessages = options.maxEnqueuedMessages;
this.connectionTimeoutMs = options.connectionTimeoutMs;
this.connectionSupplier = connectionSupplier;
}

Expand All @@ -81,24 +73,21 @@ public ReconnectingWebSocketListener(
* without requiring edits to the generated per-resource WebSocket clients. {@code maxEnqueuedMessages}
* is intentionally not overridden — the message queue is sized at construction.
*
* <p>Thread-safety: option-derived fields are volatile; reads observe the latest write. The
* initial connect() call may have already started before the override lands, so for the very
* first attempt the original options apply; the override takes effect from the next attempt
* onwards. For the SageMaker storm-suppression case ({@code maxRetries(0)}) this is fine
* because the initial attempt's gate ({@code retryCount > maxRetries} with {@code retryCount=0})
* always passes regardless.
* <p>Thread-safety: the override is a single atomic write to a {@code volatile} reference, so a
* reader that snapshots {@link #activeOptions} once sees a mutually-consistent set of values
* (never a new min paired with an old max). The initial connect() call may have already started
* before the override lands, so for the very first attempt the original options apply; the
* override takes effect from the next attempt onwards. For the SageMaker storm-suppression case
* ({@code maxRetries(0)}) this is fine because the initial attempt's gate
* ({@code retryCount > maxRetries} with {@code retryCount=0}) always passes regardless.
*
* @param options replacement options; {@code null} is a no-op.
*/
public void applyOptionsOverride(ReconnectOptions options) {
if (options == null) {
return;
}
this.minReconnectionDelayMs = options.minReconnectionDelayMs;
this.maxReconnectionDelayMs = options.maxReconnectionDelayMs;
this.reconnectionDelayGrowFactor = options.reconnectionDelayGrowFactor;
this.maxRetries = options.maxRetries;
this.connectionTimeoutMs = options.connectionTimeoutMs;
this.activeOptions = options;
}

/**
Expand All @@ -119,23 +108,26 @@ public void connect() {
if (!connectLock.compareAndSet(false, true)) {
return;
}
// Snapshot the overridable options once so the gate and timeout below use a consistent set.
ReconnectOptions opts = this.activeOptions;
// retryCount is incremented inside scheduleReconnect() before re-entering connect(),
// so on the initial call retryCount == 0 and we always proceed. The cap applies to
// retries only — maxRetries(0) blocks retries but allows the initial attempt.
if (retryCount.get() > maxRetries) {
if (retryCount.get() > opts.maxRetries) {
connectLock.set(false);
return;
}
try {
CompletableFuture<? extends WebSocket> connectionFuture = CompletableFuture.supplyAsync(connectionSupplier);
try {
webSocket = connectionFuture.get(connectionTimeoutMs, MILLISECONDS);
webSocket = connectionFuture.get(opts.connectionTimeoutMs, MILLISECONDS);
} catch (TimeoutException e) {
connectionFuture.cancel(true);
TimeoutException timeoutError =
new TimeoutException("WebSocket connection timeout after " + connectionTimeoutMs + " milliseconds"
new TimeoutException("WebSocket connection timeout after " + opts.connectionTimeoutMs
+ " milliseconds"
+ (retryCount.get() > 0
? " (retry attempt #" + retryCount.get()
? " (retry attempt #" + retryCount.get() + ")"
: " (initial connection attempt)"));
onWebSocketFailure(null, timeoutError, null);
if (shouldReconnect.get()) {
Expand Down Expand Up @@ -350,11 +342,14 @@ public void onClosed(WebSocket webSocket, int code, String reason) {
* - 2+ = exponential backoff up to maxReconnectionDelayMs
*/
private long getNextDelay() {
// Single volatile read → a consistent (min, growFactor, max) snapshot even if
// applyOptionsOverride swaps the options concurrently.
ReconnectOptions opts = this.activeOptions;
if (retryCount.get() == 1) {
return minReconnectionDelayMs;
return opts.minReconnectionDelayMs;
}
long delay = (long) (minReconnectionDelayMs * Math.pow(reconnectionDelayGrowFactor, retryCount.get() - 1));
return Math.min(delay, maxReconnectionDelayMs);
long delay = (long) (opts.minReconnectionDelayMs * Math.pow(opts.reconnectionDelayGrowFactor, retryCount.get() - 1));
return Math.min(delay, opts.maxReconnectionDelayMs);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public boolean equals(Object other) {
return other instanceof AgentV1AgentAudioDone;
}

@java.lang.Override
public int hashCode() {
// Manual patch: Fern generates equals() (all instances of this fields-less message
// are equal) but no hashCode(), violating the Object contract. Mirror equals() with a
// type-based constant hash. Remove once Fern emits a consistent equals/hashCode pair.
return getClass().hashCode();
}

@JsonAnyGetter
public Map<String, Object> getAdditionalProperties() {
return this.additionalProperties;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public boolean equals(Object other) {
return other instanceof AgentV1KeepAlive;
}

@java.lang.Override
public int hashCode() {
// Manual patch: Fern generates equals() (all instances of this fields-less message
// are equal) but no hashCode(), violating the Object contract. Mirror equals() with a
// type-based constant hash. Remove once Fern emits a consistent equals/hashCode pair.
return getClass().hashCode();
}

@JsonAnyGetter
public Map<String, Object> getAdditionalProperties() {
return this.additionalProperties;
Expand Down
Loading
Loading