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
45 changes: 45 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Files Fern must not overwrite on regeneration.
# Mirrors the convention used in smallest-python-sdk/.fernignore.

# Hand-written repo scaffolding (generator never produces these)
README.md
CONTRIBUTING.md
build.gradle
settings.gradle
jitpack.yml
gradle/**
gradlew
gradlew.bat
.gitignore
.fernignore

# Hand-written examples
examples/**

# --- Hand-patched GENERATED files (temporary; upstream before removing) ---
# These four files carry manual fixes that are NOT yet in the Fern spec or
# generator. Regenerating without upstreaming first will silently reintroduce
# the bugs. Tracking:
#
# 1. PulseSttStreamingWebSocketClient.java
# - VAD event routing by `type` discriminator (generator emitted
# presence-based checks that sent speech_ended to the speech_started
# handler). Needs a fern-java-sdk generator fix; report to Fern.
# - Streaming session query params + additionalProperties passthrough in
# connect(). Upstream: expose the full ws query binding from
# fern/apis/waves/asyncapi/pulse-stt-ws(-overrides).yml in
# smallest-ai-documentation so the generator emits these.
# - Null guard in disconnect() when connect() was never called.
# 2. PulseSttStreamingConnectOptions.java
# - Typed options for keywords, eou_timeout_ms, endpointing,
# sentence_timestamps, redact_pii, redact_pci, diarize, format,
# punctuate, capitalize, itn_normalize, finalize_on_words, max_words.
# Same upstream path as above.
# 3. RawWavesClient.java / 4. AsyncRawWavesClient.java
# - transcribePulse uses a byte[]-backed RequestBody so the
# RetryInterceptor can re-send it (one-shot InputStream body sent an
# empty body on 408/429/5xx retries). Generator fix; report to Fern.
src/main/java/resources/waves/pulsesttstreaming/websocket/PulseSttStreamingWebSocketClient.java
src/main/java/resources/waves/pulsesttstreaming/websocket/PulseSttStreamingConnectOptions.java
src/main/java/resources/waves/RawWavesClient.java
src/main/java/resources/waves/AsyncRawWavesClient.java
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.gradle/
build/
temp/
*.class
.DS_Store
.idea/
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ stt.connect(PulseSttStreamingConnectOptions.builder()
.language("en")
.encoding(PulseStreamEncoding.MULAW) // G.711 u-law telephony; LINEAR16 for raw PCM16
.sampleRate(8000) // 8000 for telephony
.eouTimeoutMs(500) // faster end-of-utterance for agent turn-taking
.keywords("Smallest:6,Pulse:6") // boost domain terms (streaming only)
.build()).get();

// feed audio as it arrives (e.g. per RTP packet), then finalize
Expand All @@ -114,9 +116,44 @@ stt.transcribeStreamingPulseSendFinalize(
.type(PulseFinalizeSignalMessageType.FINALIZE).build()).get();
```

Other session options on the builder: `sentenceTimestamps`, `diarize`, `redactPii`, `redactPci`,
`punctuate`, `capitalize`, `format`, `itnNormalize`, `endpointing`, `finalizeOnWords`, `maxWords`,
`wordTimestamps`, `vadEvents`. New server params not yet typed can be passed with
`.additionalProperty("name", value)`: they are forwarded as query parameters.

A full runnable telephony (8 kHz μ-law) example is in
[`examples/StreamingSttTelephony.java`](examples/StreamingSttTelephony.java).
Migrating from Azure Speech SDK? See
[`examples/AzureStyleSttRecognizer.java`](examples/AzureStyleSttRecognizer.java) for a
recognizing/recognized/canceled event-style wrapper.

## Generation and maintenance

This SDK is generated with `fernapi/fern-java-sdk` from the Fern API definition in
[smallest-ai-documentation](https://github.com/smallest-inc/smallest-ai-documentation)
(`fern/apis/unified`). Generation metadata (generator version, origin spec commit) is
recorded in `.fern/metadata.json`.

**Current state: generated manually, with hand patches.** The Java SDK is not yet wired
into the Fern CI pipeline (`generators.yml` has no `java-sdk` group), and a small set of
generated files carry manual fixes that are not yet in the spec or generator. The full
list, with the reason for each patch and where it must be upstreamed, lives in
[`.fernignore`](.fernignore), the same convention the
[Python SDK](https://github.com/smallest-inc/smallest-python-sdk) uses to protect
customer-owned files from regeneration.

Rules for maintainers:

- Do not regenerate over the files listed in `.fernignore` until their patches are
upstreamed (spec changes go to `smallest-ai-documentation`; generator bugs go to Fern
support). Regenerating first silently reintroduces known bugs (see `.fernignore`).
- Hand-written files (`examples/`, `build.gradle`, this README, etc.) are always
customer-owned and listed in `.fernignore`.
- End state: add a `java-sdk` group to `fern/apis/unified/generators.yml` with
`github: mode: pull-request` (like the Python SDK), upstream the streaming session
params into the AsyncAPI ws bindings, and shrink `.fernignore` down to the
hand-written files only.

## Status

Initial `0.1.3` snapshot. Real-time streaming STT/TTS WebSocket clients are included and evolving.
Initial `0.1.x` snapshot. Real-time streaming STT/TTS WebSocket clients are included and evolving.
126 changes: 126 additions & 0 deletions examples/AzureStyleSttRecognizer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package examples;

import ai.smallest.SmallestAI;
import ai.smallest.core.DisconnectReason;
import ai.smallest.resources.waves.pulsesttstreaming.types.PulseCloseStreamSignalMessage;
import ai.smallest.resources.waves.pulsesttstreaming.types.PulseCloseStreamSignalMessageType;
import ai.smallest.resources.waves.pulsesttstreaming.types.PulseStreamEncoding;
import ai.smallest.resources.waves.pulsesttstreaming.websocket.PulseSttStreamingConnectOptions;
import ai.smallest.resources.waves.pulsesttstreaming.websocket.PulseSttStreamingWebSocketClient;
import okio.ByteString;

import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

/**
* Drop-in event-style wrapper for teams migrating from the Azure Speech SDK.
*
* Azure's SpeechRecognizer exposes recognizing / recognized / canceled /
* sessionStopped events. This adapter maps the Smallest Pulse streaming STT
* WebSocket onto the same shape so an existing Azure integration can switch
* providers with minimal code movement:
*
* Azure This adapter
* ----- ------------
* recognizer.recognizing recognizer.recognizing(text -> ...) // interim
* recognizer.recognized recognizer.recognized(text -> ...) // is_final
* recognizer.canceled recognizer.canceled(err -> ...)
* recognizer.sessionStopped recognizer.sessionStopped(r -> ...)
* PushAudioInputStream.write recognizer.writeAudio(bytes)
* startContinuousRecognition recognizer.start()
* stopContinuousRecognition recognizer.stop()
*
* Usage (telephony, G.711 u-law 8 kHz):
*
* var recognizer = new AzureStyleSttRecognizer(
* SmallestAI.builder().build(),
* AzureStyleSttRecognizer.Config.telephony("en"));
* recognizer.recognizing(text -> render(text));
* recognizer.recognized(text -> handleFinal(text));
* recognizer.canceled(err -> log(err));
* recognizer.start().join();
* // per RTP packet:
* recognizer.writeAudio(packetBytes);
* // on hangup:
* recognizer.stop();
*/
public final class AzureStyleSttRecognizer implements AutoCloseable {

public record Config(String language, PulseStreamEncoding encoding, int sampleRate,
Integer eouTimeoutMs, String keywords) {
/** 8 kHz G.711 u-law, tuned for fast agent turn-taking. */
public static Config telephony(String language) {
return new Config(language, PulseStreamEncoding.MULAW, 8000, 500, null);
}

/** 16 kHz PCM16, e.g. microphone or decoded audio. */
public static Config pcm16k(String language) {
return new Config(language, PulseStreamEncoding.LINEAR16, 16000, null, null);
}
}

private final PulseSttStreamingWebSocketClient ws;
private final Config config;

private volatile Consumer<String> recognizing = t -> {};
private volatile Consumer<String> recognized = t -> {};
private volatile Consumer<Exception> canceled = e -> {};
private volatile Consumer<DisconnectReason> sessionStopped = r -> {};

public AzureStyleSttRecognizer(SmallestAI client, Config config) {
this.config = config;
this.ws = client.waves().pulseSttStreaming().pulseSttStreamingWebSocket();

ws.receiveTranscribeStreamingPulse(m -> {
String text = m.getTranscript().orElse("");
if (m.getIsFinal().orElse(false)) {
if (!text.isBlank()) recognized.accept(text);
} else {
recognizing.accept(text);
}
});
ws.onError(e -> canceled.accept(e));
ws.onDisconnected(r -> sessionStopped.accept(r));
}

public void recognizing(Consumer<String> handler) { this.recognizing = handler; }

public void recognized(Consumer<String> handler) { this.recognized = handler; }

public void canceled(Consumer<Exception> handler) { this.canceled = handler; }

public void sessionStopped(Consumer<DisconnectReason> handler) { this.sessionStopped = handler; }

/** Equivalent of startContinuousRecognitionAsync(). */
public CompletableFuture<Void> start() {
var opts = PulseSttStreamingConnectOptions.builder()
.language(config.language())
.encoding(config.encoding())
.sampleRate(config.sampleRate());
if (config.eouTimeoutMs() != null) opts.eouTimeoutMs(config.eouTimeoutMs());
if (config.keywords() != null) opts.keywords(config.keywords());
return ws.connect(opts.build());
}

/** Equivalent of PushAudioInputStream.write(). Push audio as it arrives. */
public void writeAudio(byte[] audio) {
ws.transcribeStreamingPulseSendAudio(ByteString.of(audio));
}

/**
* Equivalent of stopContinuousRecognitionAsync(): asks the server to flush and
* deliver the last transcript (is_last=true), then closes the socket.
*/
public void stop() throws Exception {
ws.transcribeStreamingPulseSendCloseStream(PulseCloseStreamSignalMessage.builder()
.type(PulseCloseStreamSignalMessageType.CLOSE_STREAM).build()).get();
// Give the server a moment to deliver the final transcript before closing.
Thread.sleep(1000);
ws.close();
}

@Override
public void close() throws Exception {
stop();
}
}
4 changes: 3 additions & 1 deletion src/main/java/resources/waves/AsyncRawWavesClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -2514,7 +2514,9 @@ public CompletableFuture<SmallestAIHttpResponse<TranscribePulseWavesResponse>> t
httpUrl.addQueryParameter(_key, _value);
} );
}
RequestBody body = new InputStreamRequestBody(MediaType.parse("application/octet-stream"), new ByteArrayInputStream(request.getBody()));
// byte[]-backed body so the RetryInterceptor can re-send it; a one-shot
// InputStream body would be empty on the retry attempt
RequestBody body = RequestBody.create(request.getBody(), MediaType.parse("application/octet-stream"));
Request.Builder _requestBuilder = new Request.Builder()
.url(httpUrl.build())
.method("POST", body)
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/resources/waves/RawWavesClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -2204,7 +2204,9 @@ public SmallestAIHttpResponse<TranscribePulseWavesResponse> transcribePulse(
httpUrl.addQueryParameter(_key, _value);
} );
}
RequestBody body = new InputStreamRequestBody(MediaType.parse("application/octet-stream"), new ByteArrayInputStream(request.getBody()));
// byte[]-backed body so the RetryInterceptor can re-send it; a one-shot
// InputStream body would be empty on the retry attempt
RequestBody body = RequestBody.create(request.getBody(), MediaType.parse("application/octet-stream"));
Request.Builder _requestBuilder = new Request.Builder()
.url(httpUrl.build())
.method("POST", body)
Expand Down
Loading