From 270221a498263323a313d1e71d2eddb3f06fe702 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Fri, 11 Sep 2026 21:44:06 +0800 Subject: [PATCH 01/15] feat(java): add streaming ASR SDK Add a Java 17 JNA binding for reusable-model PCM transcription, final results, cancellation, and deterministic cleanup. Package a normal Maven SDK and sources JAR while keeping native runtimes and model weights external. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk_v2/DEVELOPMENT.md | 7 +- sdk_v2/build_and_test_all.ps1 | 41 +- sdk_v2/java/.gitignore | 3 + sdk_v2/java/README.md | 142 ++++++ sdk_v2/java/THIRD_PARTY_NOTICES.md | 14 + sdk_v2/java/build.ps1 | 24 ++ sdk_v2/java/pom.xml | 118 +++++ .../microsoft/foundry/local/AudioSession.java | 55 +++ .../foundry/local/CancellationToken.java | 11 + .../com/microsoft/foundry/local/Catalog.java | 56 +++ .../foundry/local/Configuration.java | 18 + .../foundry/local/FoundryLocalException.java | 14 + .../foundry/local/FoundryLocalManager.java | 104 +++++ .../com/microsoft/foundry/local/Model.java | 129 ++++++ .../microsoft/foundry/local/ModelInfo.java | 6 + .../microsoft/foundry/local/NativeApi.java | 315 ++++++++++++++ .../microsoft/foundry/local/PcmFormat.java | 17 + .../microsoft/foundry/local/SpeechEvent.java | 7 + .../foundry/local/Transcription.java | 408 ++++++++++++++++++ .../foundry/local/TranscriptionResult.java | 6 + .../foundry/local/TranscriptionTiming.java | 7 + .../com/microsoft/foundry/local/WavAudio.java | 77 ++++ .../foundry/local/AudioValidationTest.java | 66 +++ .../foundry/local/NativeAsrTest.java | 101 +++++ .../foundry/local/NativeContractTest.java | 39 ++ .../foundry/local/TranscriptionStateTest.java | 72 ++++ 26 files changed, 1849 insertions(+), 8 deletions(-) create mode 100644 sdk_v2/java/.gitignore create mode 100644 sdk_v2/java/README.md create mode 100644 sdk_v2/java/THIRD_PARTY_NOTICES.md create mode 100644 sdk_v2/java/build.ps1 create mode 100644 sdk_v2/java/pom.xml create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/CancellationToken.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalException.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelInfo.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/PcmFormat.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/SpeechEvent.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionResult.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionTiming.java create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/WavAudio.java create mode 100644 sdk_v2/java/src/test/java/com/microsoft/foundry/local/AudioValidationTest.java create mode 100644 sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java create mode 100644 sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java create mode 100644 sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java diff --git a/sdk_v2/DEVELOPMENT.md b/sdk_v2/DEVELOPMENT.md index a26093901..6aeee8e80 100644 --- a/sdk_v2/DEVELOPMENT.md +++ b/sdk_v2/DEVELOPMENT.md @@ -2,7 +2,7 @@ A first-time contributor should be able to install the tools listed below, clone the repo, then run the one-shot build/test script from this directory -and watch all four SDKs go green: +and watch all five SDKs go green: ```powershell pwsh ./build_and_test_all.ps1 @@ -12,7 +12,7 @@ If that passes, your machine is correctly configured. ## Prerequisites -All four SDKs (C++, C#, Python, JS/TS) build on **Windows**, **Linux**, and +All five SDKs (C++, C#, Python, JS/TS, Java) build on **Windows**, **Linux**, and **macOS**. WinML 2.x hardware acceleration is bundled automatically on Windows. ### All platforms @@ -25,6 +25,8 @@ All four SDKs (C++, C#, Python, JS/TS) build on **Windows**, **Linux**, and | Python | 3.11–3.14, **64-bit** | Required by `build.py` and for the Python SDK. 32-bit Python will not work. | | .NET SDK | 9.0 | The SDK targets `net8.0;net9.0;netstandard2.0`; the test project additionally targets `net462` on Windows (via the .NET Framework Targeting Pack from VS); samples target `net9.0`. The single package bundles WinML 2.x on Windows — its OS-version floor is enforced by the native runtime (`LoadLibraryW` + `RtlGetVersion` in `winml_ep_bootstrapper.cc`), not by a .NET TFM. | | Node.js | 20 LTS or newer | Brings `npm`. The JS SDK declares `"engines": { "node": ">=20" }`. | +| JDK | 17 or newer | Required for the Java SDK. Use a 64-bit JVM matching the native runtime architecture. | +| Maven | 3.9 or newer | Builds and tests the Java SDK. | | PowerShell | 7+ (`pwsh`) | The one-shot script and `samples/js/test-v2.ps1` are written for PowerShell 7. | ### Windows-only @@ -68,6 +70,7 @@ install per-SDK package dependencies on first run: | C# | `dotnet test Microsoft.AI.Foundry.Local.SDK.sln -c Release` — restores NuGet packages on demand. | | Python | `python -m pip install -e .[dev]` (compiles the cffi extension; needs MSVC/Clang) → `python -m pytest test/`. | | JS | `npm install` (runs `node-gyp` against the C++ build output) → `npm run build` → `npm test` (vitest). | +| Java | `mvn test` — restores JNA/JUnit, compiles the Java 17 SDK, and runs unit tests. Native ASR tests are opt-in. | ## Common knobs diff --git a/sdk_v2/build_and_test_all.ps1 b/sdk_v2/build_and_test_all.ps1 index cb44da791..a10240eb6 100644 --- a/sdk_v2/build_and_test_all.ps1 +++ b/sdk_v2/build_and_test_all.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Build and test all sdk_v2 SDKs (C++, C#, Python, JS) in one shot. + Build and test all sdk_v2 SDKs (C++, C#, Python, JS, Java) in one shot. .DESCRIPTION The simple developer "build and run all tests" one-shot script for sdk_v2. @@ -10,16 +10,17 @@ 2. C# — dotnet test (builds via project references) 3. Python — pip install -e . then pytest 4. JS — npm install + npm run build + npm test + 5. Java — mvn test Each SDK runs in its own step. The script stops on the first failure unless -ContinueOnError is supplied, and prints a per-SDK pass/fail summary at the end. .PARAMETER Skip - SDKs to skip. Any of: cpp, cs, python, js. + SDKs to skip. Any of: cpp, cs, python, js, java. .PARAMETER Only - Run only the named SDKs. Overrides -Skip. Any of: cpp, cs, python, js. + Run only the named SDKs. Overrides -Skip. Any of: cpp, cs, python, js, java. .PARAMETER ContinueOnError Keep going after a failure instead of aborting on the first one. @@ -39,9 +40,9 @@ #> [CmdletBinding()] param( - [ValidateSet('cpp', 'cs', 'python', 'js')] + [ValidateSet('cpp', 'cs', 'python', 'js', 'java')] [string[]] $Skip = @(), - [ValidateSet('cpp', 'cs', 'python', 'js')] + [ValidateSet('cpp', 'cs', 'python', 'js', 'java')] [string[]] $Only, [switch] $ContinueOnError, [switch] $SkipCppTests @@ -57,9 +58,10 @@ $cppDir = Join-Path $sdkRoot 'cpp' $csDir = Join-Path $sdkRoot 'cs' $pythonDir = Join-Path $sdkRoot 'python' $jsDir = Join-Path $sdkRoot 'js' +$javaDir = Join-Path $sdkRoot 'java' # Resolve which SDKs to run. -$all = @('cpp', 'cs', 'python', 'js') +$all = @('cpp', 'cs', 'python', 'js', 'java') if ($Only) { $targets = $all | Where-Object { $_ -in $Only } } else { @@ -227,6 +229,33 @@ print(sys.executable) } } } + + if ('java' -in $targets) { + Invoke-Step 'java' { + Push-Location $javaDir + try { + mvn --batch-mode --no-transfer-progress clean verify + if ($LASTEXITCODE -ne 0) { throw "mvn clean verify exit $LASTEXITCODE" } + + $mainJar = @(Get-ChildItem target -Filter 'foundry-local-sdk-*.jar' -File | + Where-Object { $_.Name -notlike '*-sources.jar' }) + $sourceJar = @(Get-ChildItem target -Filter 'foundry-local-sdk-*-sources.jar' -File) + if ($mainJar.Count -ne 1 -or $sourceJar.Count -ne 1) { + throw "Expected one SDK JAR and one sources JAR." + } + + $entries = @(jar tf $mainJar[0].FullName) + if ($LASTEXITCODE -ne 0) { throw "jar inspection exit $LASTEXITCODE" } + foreach ($required in @('META-INF/LICENSE', 'META-INF/THIRD_PARTY_NOTICES.md')) { + if ($required -notin $entries) { + throw "Missing $required from $($mainJar[0].Name)" + } + } + } finally { + Pop-Location + } + } + } } catch { # Already recorded by Invoke-Step. Fall through to summary. } diff --git a/sdk_v2/java/.gitignore b/sdk_v2/java/.gitignore new file mode 100644 index 000000000..23f9f6684 --- /dev/null +++ b/sdk_v2/java/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.mvn-local/ +/.flattened-pom.xml diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md new file mode 100644 index 000000000..43cb1fd3c --- /dev/null +++ b/sdk_v2/java/README.md @@ -0,0 +1,142 @@ +# Foundry Local Java SDK + +Java 17+ bindings for in-process, streaming speech recognition through the +Foundry Local C API. The SDK uses JNA, keeps the native runtime and model +weights outside the JAR, and exposes deterministic `AutoCloseable` lifetimes. + +This package is a preview and is not published to Maven Central yet. + +## Build + +Prerequisites: + +- JDK 17 or newer +- Maven 3.9 or newer + +```powershell +mvn -f sdk_v2\java\pom.xml package +``` + +The build produces: + +- `target/foundry-local-sdk-0.1.0-SNAPSHOT.jar` +- `target/foundry-local-sdk-0.1.0-SNAPSHOT-sources.jar` + +Override `revision` when producing an immutable release: + +```powershell +mvn -f sdk_v2\java\pom.xml -Drevision=0.1.0 package +``` + +JNA remains a normal Maven dependency. The SDK JAR does not contain JNA native +code, Foundry Local native libraries, execution providers, or model weights. + +## Runtime setup + +Prepare a directory containing the matching Foundry Local native runtime for +the current OS and architecture. Pass that directory through `Configuration`. +The Java binding requests the stable API v1 prefix, which the current v2 C API +keeps ABI-compatible. + +The runtime directory must contain: + +- Windows: `foundry_local.dll` +- Linux: `libfoundry_local.so` +- macOS: `libfoundry_local.dylib` + +When ONNX Runtime or ONNX Runtime GenAI libraries are present in the same +directory, the SDK preloads them before Foundry Local. Otherwise, the platform +loader must be able to resolve those dependencies. + +The platform-independent JAR can run wherever the matching Foundry Local native +runtime is available. Current upstream native artifacts target Windows x64 and +ARM64, Linux x64 and ARM64, and macOS ARM64. Use a 64-bit JVM with the same +architecture as the native runtime. + +On Java 25, pass `--enable-native-access=ALL-UNNAMED` when required by the JVM. +The older JBR 21.0.8 and 21.0.9 builds tested with Runtime 2.0.1 on Windows load +an older C runtime first and cannot initialize ONNX Runtime. Use a compatible +JBR/native-runtime combination instead of replacing IDE or system DLLs. + +## Streaming ASR + +The model is loaded once and reused across successive dictation requests. +Each `AudioSession` accepts one active `Transcription`; close it before starting +the next request. + +```java +var configuration = new Configuration( + "my-app", + Path.of("native-runtime"), + Path.of("model-cache"), + Path.of("app-data")); + +try (var manager = new FoundryLocalManager(configuration)) { + var model = manager.catalog() + .getModel("nemotron-3.5-asr-streaming-0.6b-generic-cpu:3"); + + if (!model.isCached()) { + throw new IllegalStateException("Download and review the model separately"); + } + + model.load(); + try (var session = model.createAudioSession()) { + for (Iterable request : requests) { + try (var transcription = + session.streamPcm(PcmFormat.SPEECH, event -> showInterim(event.text()))) { + for (byte[] pcmChunk : request) { + // signed PCM16LE, 16 kHz, mono + transcription.writePcm(pcmChunk); + } + transcription.finishInput(); + TranscriptionResult result = transcription.await(); + useFinalText(result.text()); + } + } + } finally { + model.unload(); + } +} +``` + +For a real microphone stream, create one `Transcription`, call `writePcm` for +each chunk, then call `finishInput` and `await`. `finishInput` drains queued +audio and publishes the final transcript. `cancel` requests native cancellation +and produces a result with `cancelled() == true`. + +PCM input is intentionally limited to signed PCM16LE, 16 kHz, mono. Each call +accepts at most one second of audio, and the SDK applies backpressure after two +seconds are queued. + +## Ownership and threading + +- `FoundryLocalManager` owns catalogs, models, and sessions. Only one manager + may be open at a time, but a manager can be closed and recreated later. +- A loaded `Model` can create multiple successive `AudioSession` instances + without reloading the model. +- Closing a manager closes outstanding sessions and transcriptions before + releasing the native manager. +- Download progress and speech listeners run on native callback threads. Keep + callbacks short and do not call SDK lifecycle or input methods from them. +- `Transcription.close()` cancels unfinished work and waits for native callbacks + before releasing request and PCM buffers. + +## Tests + +```powershell +mvn -f sdk_v2\java\pom.xml test +``` + +The native ASR integration test is opt-in and never downloads a model: + +```powershell +mvn -f sdk_v2\java\pom.xml test ` + -Dtest=NativeAsrTest ` + -Dfoundry.test.runtime= ` + -Dfoundry.test.cache= ` + -Dfoundry.test.wav= +``` + +The test reuses one loaded model for repeated PCM requests, covers final +results, cancellation, callback failures, deterministic cleanup, manager +recreation, and verifies that no callback or worker survives close. diff --git a/sdk_v2/java/THIRD_PARTY_NOTICES.md b/sdk_v2/java/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..916840667 --- /dev/null +++ b/sdk_v2/java/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +The Foundry Local Java SDK is MIT licensed; see the repository `LICENSE`, which +is also included in the SDK JAR. + +The Java dependency is Java Native Access (JNA) 5.17.0, used under its Apache +License 2.0 option. JNA is distributed as an unmodified Maven dependency and +retains its embedded licenses and notices, including native libffi notices. +Source: https://github.com/java-native-access/jna/tree/5.17.0 + +Foundry Local native runtime packages and model weights are separate from this +JAR and may carry additional licenses and notices. Review and retain those +materials before distribution. The SDK license does not grant permission to +redistribute model weights. diff --git a/sdk_v2/java/build.ps1 b/sdk_v2/java/build.ps1 new file mode 100644 index 000000000..1ab121b5c --- /dev/null +++ b/sdk_v2/java/build.ps1 @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +[CmdletBinding()] +param([switch]$BootstrapMaven, [string[]]$MavenArguments = @('package')) +$ErrorActionPreference = 'Stop' +$mavenHome = Join-Path $PSScriptRoot '.mvn-local' +$maven = Get-Command mvn -ErrorAction SilentlyContinue +if ($maven) { + $executable = $maven.Source +} else { + $executable = Join-Path $mavenHome 'tools\apache-maven-3.9.9\bin\mvn.cmd' + if (!(Test-Path -LiteralPath $executable)) { + if (!$BootstrapMaven) { throw 'Maven is missing. Install Maven or explicitly pass -BootstrapMaven.' } + $archive = Join-Path $mavenHome 'downloads\maven.zip' + New-Item -ItemType Directory -Force (Split-Path $archive), (Join-Path $mavenHome 'tools') | Out-Null + if (!(Test-Path -LiteralPath $archive)) { + Invoke-WebRequest 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip' -OutFile $archive + } + $expected = '8beac8d11ef208f1e2a8df0682b9448a9a363d2ad13ca74af43705549e72e74c9378823bf689287801cbbfc2f6ea9596201d19ccacfdfb682ee8a2ff4c4418ba' + if ((Get-FileHash $archive -Algorithm SHA512).Hash -ne $expected) { throw 'Maven archive checksum mismatch' } + Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $mavenHome 'tools') -Force + } +} +& $executable -f (Join-Path $PSScriptRoot 'pom.xml') "-Dmaven.repo.local=$(Join-Path $mavenHome 'repository')" --batch-mode --no-transfer-progress @MavenArguments +if ($LASTEXITCODE -ne 0) { throw "Maven exited with $LASTEXITCODE" } diff --git a/sdk_v2/java/pom.xml b/sdk_v2/java/pom.xml new file mode 100644 index 000000000..375b82da3 --- /dev/null +++ b/sdk_v2/java/pom.xml @@ -0,0 +1,118 @@ + + + + 4.0.0 + com.microsoft.foundry + foundry-local-sdk + ${revision} + Foundry Local Java SDK + Java bindings for in-process Foundry Local speech recognition. + https://github.com/microsoft/foundry-local + + + MIT License + https://opensource.org/license/mit + repo + + + + 0.1.0-SNAPSHOT + 17 + UTF-8 + + + + net.java.dev.jna + jna + 5.17.0 + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + com.microsoft.foundry.local + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + + resolveCiFriendliesOnly + true + + + + flatten + process-resources + + flatten + + + + flatten-clean + clean + + clean + + + + + + + + ../.. + + LICENSE + + META-INF + + + . + + THIRD_PARTY_NOTICES.md + + META-INF + + + + diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java new file mode 100644 index 000000000..8c48b9948 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Pointer; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Consumer; + +/** One request at a time; close each Transcription before starting the next. */ +public final class AudioSession implements AutoCloseable { + final Model model; + final NativeApi api; + Pointer handle; + private Transcription active; + + AudioSession(Model model) { + this.model = model; + api = model.owner.api; + handle = api.create(api.inference, NativeApi.InferenceApi.SESSION_CREATE, model.handle); + model.owner.sessions.add(this); + } + + public Transcription transcribeWav(Path wav, Consumer listener) throws IOException { + NativeApi.outsideCallback(); + WavAudio audio = WavAudio.read(wav); + return start(audio.pcm(), audio.format(), listener); + } + + public Transcription streamPcm(PcmFormat format, Consumer listener) { + return start(null, Objects.requireNonNull(format), listener); + } + + private Transcription start(byte[] wav, PcmFormat format, Consumer listener) { + NativeApi.outsideCallback(); + synchronized (model.owner) { + model.owner.checkOpen(); + if (handle == null) throw new IllegalStateException("Session is closed"); + if (active != null && !active.isClosed()) throw new IllegalStateException("Close the previous transcription"); + active = new Transcription(this, wav, format, Objects.requireNonNull(listener)); + return active; + } + } + + @Override public void close() { + NativeApi.outsideCallback(); + synchronized (model.owner) { + if (handle == null) return; + if (active != null) active.close(); + api.inference.call(NativeApi.InferenceApi.SESSION_RELEASE, handle); + handle = null; + model.owner.sessions.remove(this); + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/CancellationToken.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/CancellationToken.java new file mode 100644 index 000000000..2cf495fd3 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/CancellationToken.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Thread-safe cancellation for a blocking explicit model download. */ +public final class CancellationToken { + private final AtomicBoolean cancelled = new AtomicBoolean(); + public void cancel() { cancelled.set(true); } + public boolean isCancelled() { return cancelled.get(); } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java new file mode 100644 index 000000000..d72b059c0 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Pointer; +import java.util.ArrayList; +import java.util.List; + +/** Borrowed from a manager. Queries may fetch public catalog metadata, never model weights. */ +public final class Catalog { + private final FoundryLocalManager owner; + private final Pointer handle; + + Catalog(FoundryLocalManager owner, Pointer handle) { this.owner = owner; this.handle = handle; } + + /** No alias fallback: the returned native identity must equal the requested name:version. */ + public Model getModel(String exactId) { + NativeApi.outsideCallback(); + if (exactId == null || !exactId.matches("[A-Za-z0-9._-]+:[0-9]+")) { + throw new IllegalArgumentException("An exact model ID in name:version form is required"); + } + synchronized (owner) { + owner.checkOpen(); + Model model = new Model(owner, owner.api.create( + owner.api.catalog, NativeApi.CatalogApi.GET_MODEL_VARIANT, handle, exactId)); + if (!model.info().id().equals(exactId)) { + throw new IllegalStateException("Catalog returned a different model ID"); + } + return model; + } + } + + public List models() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + NativeApi api = owner.api; + Pointer list = api.create(api.catalog, NativeApi.CatalogApi.GET_MODELS, handle); + try { + List models = new ArrayList<>(); + long size = api.root.size(NativeApi.Root.MODEL_LIST_SIZE, list); + for (long i = 0; i < size; i++) { + Model alias = new Model(owner, api.root.pointer(NativeApi.Root.MODEL_LIST_GET_AT, list, i)); + Pointer variants = api.create(api.model, NativeApi.ModelApi.GET_VARIANTS, alias.handle); + try { + for (long j = 0; j < api.root.size(NativeApi.Root.MODEL_LIST_SIZE, variants); j++) { + models.add(new Model( + owner, + api.root.pointer(NativeApi.Root.MODEL_LIST_GET_AT, variants, j)).info()); + } + } finally { api.root.call(NativeApi.Root.MODEL_LIST_RELEASE, variants); } + } + return List.copyOf(models); + } finally { api.root.call(NativeApi.Root.MODEL_LIST_RELEASE, list); } + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java new file mode 100644 index 000000000..2fff78144 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import java.nio.file.Path; +import java.util.Objects; + +/** Explicit locations only. Construction never loads native code or downloads assets. */ +public record Configuration(String appName, Path runtimeDirectory, Path modelCacheDirectory, Path appDataDirectory) { + public Configuration { + Objects.requireNonNull(appName, "appName"); + if (appName.isBlank() || appName.indexOf('\0') >= 0) { + throw new IllegalArgumentException("appName must be nonempty and contain no NUL"); + } + runtimeDirectory = Objects.requireNonNull(runtimeDirectory).toAbsolutePath().normalize(); + modelCacheDirectory = Objects.requireNonNull(modelCacheDirectory).toAbsolutePath().normalize(); + appDataDirectory = Objects.requireNonNull(appDataDirectory).toAbsolutePath().normalize(); + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalException.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalException.java new file mode 100644 index 000000000..635b3f2a5 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalException.java @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** Native errors retain the C ABI error code; cancellation is code 5. */ +public final class FoundryLocalException extends RuntimeException { + private final int code; + + public FoundryLocalException(int code, String message) { + super(message); + this.code = code; + } + + public int code() { return code; } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java new file mode 100644 index 000000000..941ec6fa6 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** Owns the native singleton and every session. Catalogs/models are borrowed views. */ +public final class FoundryLocalManager implements AutoCloseable { + private static boolean active; + final NativeApi api; + final Set sessions = new HashSet<>(); + private Pointer handle; + + public FoundryLocalManager(Configuration configuration) { + NativeApi.outsideCallback(); + synchronized (FoundryLocalManager.class) { + if (active) throw new IllegalStateException("Only one FoundryLocalManager may be open per JVM"); + api = NativeApi.load(configuration.runtimeDirectory()); + Pointer config = api.create(api.config, NativeApi.ConfigurationApi.CREATE, configuration.appName()); + try { + // Native status errors still propagate; keep the SDK quiet unless callers opt into logging. + api.check(api.config.pointer(NativeApi.ConfigurationApi.SET_DEFAULT_LOG_LEVEL, config, 5)); + api.check(api.config.pointer( + NativeApi.ConfigurationApi.SET_APP_DATA_DIRECTORY, + config, + configuration.appDataDirectory().toString())); + api.check(api.config.pointer( + NativeApi.ConfigurationApi.SET_MODEL_CACHE_DIRECTORY, + config, + configuration.modelCacheDirectory().toString())); + PointerByReference pairs = new PointerByReference(); + api.root.call(NativeApi.Root.KEY_VALUE_PAIRS_CREATE, pairs); + try { + api.root.call( + NativeApi.Root.KEY_VALUE_PAIRS_ADD, + pairs.getValue(), + "DisableNonessentialTelemetry", + "true"); + api.check(api.config.pointer( + NativeApi.ConfigurationApi.SET_ADDITIONAL_OPTIONS, + config, + pairs.getValue())); + } finally { + api.root.call(NativeApi.Root.KEY_VALUE_PAIRS_RELEASE, pairs.getValue()); + } + handle = api.create(api.root, NativeApi.Root.MANAGER_CREATE, config); + active = true; + } finally { + api.config.call(NativeApi.ConfigurationApi.RELEASE, config); + } + } + } + + public String runtimeVersion() { return api.version; } + public String nativeTarget() { return NativeApi.target(); } + + public Catalog catalog() { + NativeApi.outsideCallback(); + synchronized (this) { + checkOpen(); + return new Catalog(this, api.create(api.root, NativeApi.Root.MANAGER_GET_CATALOG, handle)); + } + } + + void checkOpen() { + NativeApi.outsideCallback(); + if (handle == null) throw new IllegalStateException("Manager is closed"); + } + + @Override public void close() { + NativeApi.outsideCallback(); + synchronized (this) { + if (handle == null) return; + RuntimeException failure = null; + for (AudioSession session : new ArrayList<>(sessions)) { + try { + session.close(); + } catch (RuntimeException e) { + if (failure == null) failure = e; + else failure.addSuppressed(e); + } + } + try { + api.check(api.root.pointer(NativeApi.Root.MANAGER_SHUTDOWN, handle)); + } catch (RuntimeException e) { + if (failure == null) failure = e; + else failure.addSuppressed(e); + } finally { + try { + api.root.call(NativeApi.Root.MANAGER_RELEASE, handle); + } finally { + handle = null; + synchronized (FoundryLocalManager.class) { + active = false; + } + } + } + if (failure != null) throw failure; + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java new file mode 100644 index 000000000..3f93b3b09 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import java.lang.ref.Reference; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.DoubleConsumer; + +/** Borrowed exact native model. No method implicitly downloads model weights or EPs. */ +public final class Model { + final FoundryLocalManager owner; + final Pointer handle; + Model(FoundryLocalManager owner, Pointer handle) { this.owner = owner; this.handle = handle; } + + public ModelInfo info() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + NativeApi api = owner.api; + Pointer info = api.create(api.model, NativeApi.ModelApi.GET_INFO, handle); + return new ModelInfo( + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_ID, info)), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_ALIAS, info)), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_NAME, info)), + api.model.integer(NativeApi.ModelApi.INFO_GET_VERSION, info), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_URI, info)), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_EXECUTION_PROVIDER, info)), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_TASK, info)), + NativeApi.text(api.model.pointer(NativeApi.ModelApi.INFO_GET_STRING_PROPERTY, info, "license")), + NativeApi.text(api.model.pointer( + NativeApi.ModelApi.INFO_GET_STRING_PROPERTY, + info, + "license_description"))); + } + } + + public boolean isCached() { return flag(NativeApi.ModelApi.IS_CACHED); } + public boolean isLoaded() { return flag(NativeApi.ModelApi.IS_LOADED); } + private boolean flag(int slot) { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + IntByReference value = new IntByReference(); + owner.api.check(owner.api.model.pointer(slot, handle, value)); + return value.getValue() != 0; + } + } + + public Path path() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + if (!isCached()) throw new IllegalStateException("Model is not cached"); + return Path.of(NativeApi.text(owner.api.create(owner.api.model, NativeApi.ModelApi.GET_PATH, handle))); + } + } + + /** + * Blocking explicit download. Call only after reviewing the model license. + * Progress is 0..100, on native threads; callbacks must not call SDK methods. + * Cancellation is observed at native progress checkpoints (not a deadline guarantee). + */ + public void download(CancellationToken cancellation, DoubleConsumer progress) { + NativeApi.outsideCallback(); + Objects.requireNonNull(cancellation); + Objects.requireNonNull(progress); + synchronized (owner) { + owner.checkOpen(); + if (cancellation.isCancelled()) throw new FoundryLocalException(5, "Download cancelled before start"); + AtomicReference failure = new AtomicReference<>(); + NativeApi.ProgressCallback callback = (value, userData) -> { + NativeApi.IN_CALLBACK.set(true); + try { + if (cancellation.isCancelled()) return 1; + progress.accept(value); + return cancellation.isCancelled() ? 1 : 0; + } catch (Throwable e) { + // Java exceptions must never escape through a native callback trampoline. + failure.compareAndSet(null, e); + return 1; + } finally { NativeApi.IN_CALLBACK.remove(); } + }; + Pointer status; + try { status = owner.api.model.pointer(NativeApi.ModelApi.DOWNLOAD, handle, callback, null); } + finally { Reference.reachabilityFence(callback); } + if (failure.get() != null) { + if (status != null) owner.api.root.call(NativeApi.Root.STATUS_RELEASE, status); + throw new IllegalStateException("Download progress callback failed", failure.get()); + } + owner.api.check(status); + if (cancellation.isCancelled()) throw new FoundryLocalException(5, "Download cancelled"); + } + } + + public void load() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + if (!isCached()) throw new IllegalStateException("Model is not cached; explicitly download it first"); + owner.api.check(owner.api.model.pointer(NativeApi.ModelApi.LOAD, handle)); + } + } + + public void unload() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + if (owner.sessions.stream().anyMatch(s -> s.model.handle.equals(handle))) { + throw new IllegalStateException("Close all sessions for this model before unloading it"); + } + owner.api.check(owner.api.model.pointer(NativeApi.ModelApi.UNLOAD, handle)); + } + } + + public AudioSession createAudioSession() { + NativeApi.outsideCallback(); + synchronized (owner) { + owner.checkOpen(); + if (!info().task().equals("automatic-speech-recognition")) { + throw new IllegalStateException("The selected model is not an ASR model"); + } + if (!isLoaded()) throw new IllegalStateException("Explicitly load the model before creating a session"); + return new AudioSession(this); + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelInfo.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelInfo.java new file mode 100644 index 000000000..8e05e0632 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelInfo.java @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** Detached immutable metadata, valid even after the manager closes. */ +public record ModelInfo(String id, String alias, String name, int version, String uri, + String executionProvider, String task, String license, String licenseDescription) {} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java new file mode 100644 index 000000000..cd5b38c4c --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Callback; +import com.sun.jna.Function; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.ptr.PointerByReference; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Narrow binding to the stable API v1 prefix of the current Foundry Local C ABI. + * All supported targets use 64-bit pointers/size_t. C bool is one byte, not JNA boolean. + */ +final class NativeApi { + static final int VERSION = 1; + static final ThreadLocal IN_CALLBACK = ThreadLocal.withInitial(() -> false); + private static NativeApi resident; + private final List libraries = new ArrayList<>(); + final Path directory; + final String version; + final Table root, config, catalog, model, item, inference; + + static final class Root { + static final int STATUS_RELEASE = 1; + static final int MANAGER_CREATE = 4; + static final int MANAGER_RELEASE = 5; + static final int MANAGER_GET_CATALOG = 6; + static final int GET_CATALOG_API = 10; + static final int GET_CONFIGURATION_API = 11; + static final int GET_ITEM_API = 12; + static final int GET_INFERENCE_API = 13; + static final int GET_MODEL_API = 14; + static final int KEY_VALUE_PAIRS_CREATE = 15; + static final int KEY_VALUE_PAIRS_ADD = 16; + static final int KEY_VALUE_PAIRS_RELEASE = 20; + static final int MODEL_LIST_RELEASE = 21; + static final int MODEL_LIST_SIZE = 22; + static final int MODEL_LIST_GET_AT = 23; + static final int MANAGER_SHUTDOWN = 27; + } + + static final class ConfigurationApi { + static final int CREATE = 0; + static final int RELEASE = 1; + static final int SET_DEFAULT_LOG_LEVEL = 2; + static final int SET_APP_DATA_DIRECTORY = 3; + static final int SET_MODEL_CACHE_DIRECTORY = 5; + static final int SET_ADDITIONAL_OPTIONS = 10; + } + + static final class CatalogApi { + static final int GET_MODELS = 1; + static final int GET_MODEL_VARIANT = 3; + } + + static final class ModelApi { + static final int GET_INFO = 0; + static final int IS_CACHED = 2; + static final int GET_PATH = 3; + static final int DOWNLOAD = 4; + static final int IS_LOADED = 5; + static final int LOAD = 6; + static final int UNLOAD = 7; + static final int GET_VARIANTS = 9; + static final int INFO_GET_ID = 11; + static final int INFO_GET_NAME = 12; + static final int INFO_GET_VERSION = 13; + static final int INFO_GET_ALIAS = 14; + static final int INFO_GET_URI = 15; + static final int INFO_GET_EXECUTION_PROVIDER = 17; + static final int INFO_GET_TASK = 18; + static final int INFO_GET_STRING_PROPERTY = 21; + } + + static final class ItemApi { + static final int CREATE = 0; + static final int RELEASE = 1; + static final int GET_TYPE = 2; + static final int SET_BYTES = 3; + static final int SET_AUDIO = 8; + static final int GET_SPEECH_SEGMENT = 19; + static final int GET_SPEECH_RESULT = 20; + static final int GET_QUEUE = 23; + static final int QUEUE_PUSH = 26; + static final int QUEUE_TRY_POP = 27; + static final int QUEUE_MARK_FINISHED = 29; + } + + static final class InferenceApi { + static final int REQUEST_CREATE = 0; + static final int REQUEST_RELEASE = 1; + static final int REQUEST_ADD_ITEM = 2; + static final int REQUEST_CANCEL = 6; + static final int RESPONSE_RELEASE = 8; + static final int RESPONSE_GET_ITEM_COUNT = 9; + static final int RESPONSE_GET_ITEM = 10; + static final int RESPONSE_GET_FINISH_REASON = 11; + static final int SESSION_CREATE = 13; + static final int SESSION_RELEASE = 14; + static final int SESSION_SET_STREAMING_CALLBACK = 15; + static final int SESSION_PROCESS_REQUEST = 17; + } + + static void outsideCallback() { + if (IN_CALLBACK.get()) { + throw new IllegalStateException("SDK lifecycle/input calls are not allowed from a native callback"); + } + } + + static String target() { + String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT); + String cpu = switch (arch) { + case "amd64", "x86_64" -> "x64"; + case "aarch64", "arm64" -> "arm64"; + default -> arch; + }; + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (os.contains("win")) return "win-" + cpu; + if (os.contains("linux")) return "linux-" + cpu; + if (os.contains("mac")) return "osx-" + cpu; + return os.replaceAll("[^a-z0-9]+", "-") + "-" + cpu; + } + + static String foundryLibraryName() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (os.contains("win")) return "foundry_local.dll"; + if (os.contains("mac")) return "libfoundry_local.dylib"; + return "libfoundry_local.so"; + } + + static Path findFoundryLibrary(Path directory) { + Path library = directory.resolve(foundryLibraryName()); + if (!Files.isRegularFile(library)) { + throw new IllegalArgumentException( + "Foundry Local native library is missing from " + directory + ": " + library.getFileName()); + } + return library; + } + + static synchronized NativeApi load(Path path) { + outsideCallback(); + try { + Path real = path.toRealPath(); + Path library = findFoundryLibrary(real); + if (resident != null) { + if (!resident.directory.equals(real)) { + throw new IllegalStateException("One native runtime directory per JVM is supported"); + } + return resident; + } + resident = new NativeApi(real, library); + return resident; + } catch (IOException e) { + throw new IllegalArgumentException("Cannot read native runtime directory: " + path, e); + } + } + + private static void verifyArchitecture() { + if (Native.POINTER_SIZE != 8 || Native.SIZE_T_SIZE != 8) { + throw new IllegalStateException("Only 64-bit JVMs are supported"); + } + } + + private NativeApi(Path path, Path foundryLibrary) { + verifyArchitecture(); + directory = path; + for (String dependency : dependencyLibraryNames()) { + Path candidate = path.resolve(dependency); + if (Files.isRegularFile(candidate)) { + libraries.add(open(candidate)); + } + } + NativeLibrary library = open(foundryLibrary); + libraries.add(library); + version = text(library.getFunction("FoundryLocalGetVersionString").invokePointer(new Object[0])); + Pointer api = library.getFunction("FoundryLocalGetApi").invokePointer(new Object[] {VERSION}); + if (api == null) throw new IllegalStateException("Native runtime does not expose C API " + VERSION); + root = new Table(api); + catalog = new Table(root.pointer(Root.GET_CATALOG_API)); + config = new Table(root.pointer(Root.GET_CONFIGURATION_API)); + item = new Table(root.pointer(Root.GET_ITEM_API)); + inference = new Table(root.pointer(Root.GET_INFERENCE_API)); + model = new Table(root.pointer(Root.GET_MODEL_API)); + } + + private static List dependencyLibraryNames() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return List.of("onnxruntime.dll", "onnxruntime-genai.dll"); + } + if (os.contains("mac")) { + return List.of("libonnxruntime.1.dylib", "libonnxruntime.dylib", "libonnxruntime-genai.dylib"); + } + return List.of("libonnxruntime.so.1", "libonnxruntime.so", "libonnxruntime-genai.so"); + } + + private static NativeLibrary open(Path file) { + try { + return NativeLibrary.getInstance(file.toString(), Map.of( + com.sun.jna.Library.OPTION_STRING_ENCODING, "UTF-8")); + } catch (UnsatisfiedLinkError e) { + throw new IllegalStateException("Cannot load " + file.getFileName() + + "; use a matching 64-bit JVM and install platform loader prerequisites", e); + } + } + + static String text(Pointer pointer) { return pointer == null ? "" : pointer.getString(0, "UTF-8"); } + static Memory utf8(String value) { + if (value.indexOf('\0') >= 0) throw new IllegalArgumentException("Strings must not contain NUL"); + byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + Memory memory = new Memory(bytes.length + 1L); + memory.write(0, bytes, 0, bytes.length); + memory.setByte(bytes.length, (byte) 0); + return memory; + } + + void check(Pointer status) { + if (status == null) return; + try { + throw new FoundryLocalException(root.integer(2, status), text(root.pointer(3, status))); + } finally { + root.call(Root.STATUS_RELEASE, status); + } + } + + Pointer create(Table table, int slot, Object... args) { + PointerByReference output = new PointerByReference(); + Object[] all = java.util.Arrays.copyOf(args, args.length + 1); + all[args.length] = output; + check(table.pointer(slot, all)); + if (output.getValue() == null) throw new IllegalStateException("Native API returned a null handle"); + return output.getValue(); + } + + static final class Table { + private final Pointer table; + Table(Pointer table) { + if (table == null) throw new IllegalStateException("Missing native function table"); + this.table = table; + } + private Function function(int index) { + Pointer function = table.getPointer(index * 8L); + if (function == null) throw new IllegalStateException("Missing native function at slot " + index); + return Function.getFunction(function, Function.C_CONVENTION, "UTF-8"); + } + Pointer pointer(int index, Object... args) { return function(index).invokePointer(args); } + int integer(int index, Object... args) { return function(index).invokeInt(args); } + long size(int index, Object... args) { return function(index).invokeLong(args); } + boolean bool(int index, Object... args) { + return ((Byte) function(index).invoke(Byte.class, args)) != 0; + } + void call(int index, Object... args) { function(index).invokeVoid(args); } + } + + interface ProgressCallback extends Callback { int invoke(float value, Pointer userData); } + interface StreamCallback extends Callback { int invoke(CallbackData data, Pointer userData); } + interface BytesDeleter extends Callback { void invoke(Pointer data, Pointer userData); } + + @Structure.FieldOrder({"version", "queue"}) + public static class CallbackData extends Structure implements Structure.ByValue { + public int version; + public Pointer queue; + } + + @Structure.FieldOrder({"version", "data", "mutableData", "dataSize", "format", "uri", + "sampleRate", "channels", "deleter", "userData"}) + public static class AudioData extends Structure { + public int version = VERSION; + public Pointer data, mutableData; + public long dataSize; + public Pointer format, uri; + public int sampleRate, channels; + public Pointer deleter, userData; + } + + @Structure.FieldOrder({"version", "itemType", "data", "mutableData", "dataSize", "deleter", "userData"}) + public static class BytesData extends Structure { + public int version = VERSION, itemType = 1; + public Pointer data, mutableData; + public long dataSize; + public BytesDeleter deleter; + public Pointer userData; + } + + @Structure.FieldOrder({"version", "kind", "text", "start", "end", "utteranceStart", + "words", "wordCount", "language"}) + public static class SegmentData extends Structure { + public int version = VERSION, kind; + public Pointer text; + public long start, end; + public byte utteranceStart; + public Pointer words; + public long wordCount; + public Pointer language; + } + + @Structure.FieldOrder({"version", "text", "language", "duration", "segments", "segmentCount"}) + public static class ResultData extends Structure { + public int version = VERSION; + public Pointer text, language; + public long duration; + public Pointer segments; + public long segmentCount; + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/PcmFormat.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/PcmFormat.java new file mode 100644 index 000000000..7cc9d0e69 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/PcmFormat.java @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** Supported PCM format. The SDK does not perform implicit resampling or channel conversion. */ +public record PcmFormat(int sampleRate, int channels, int bitsPerSample) { + public static final PcmFormat SPEECH = new PcmFormat(16000, 1, 16); + public PcmFormat { + if (sampleRate != 16000 || channels != 1 || bitsPerSample != 16) { + throw new IllegalArgumentException("Expected signed PCM16LE at 16000 Hz, mono"); + } + } + public void validateChunk(byte[] bytes) { + if (bytes == null || bytes.length == 0 || bytes.length % 2 != 0 || bytes.length > 32000) { + throw new IllegalArgumentException("PCM chunks must contain 2..32000 bytes and complete 16-bit samples"); + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/SpeechEvent.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/SpeechEvent.java new file mode 100644 index 000000000..026335a51 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/SpeechEvent.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** NONE is a token delta in this runtime, not a genuine partial utterance hypothesis. */ +public record SpeechEvent(Kind kind, String text, Long startTimeMs, Long endTimeMs, boolean utteranceStart) { + public enum Kind { TOKEN, PARTIAL, FINAL } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java new file mode 100644 index 000000000..c6ad4b9a4 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java @@ -0,0 +1,408 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import com.sun.jna.Memory; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import java.lang.ref.Reference; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +/** Owns a request, input buffers and callback lifetimes until the native worker has joined. */ +public final class Transcription implements AutoCloseable { + private final AudioSession session; + private final NativeApi api; + private final PcmFormat format; + private final Consumer listener; + private final Completion completion = new Completion(); + private final AtomicReference callbackFailure = new AtomicReference<>(); + private final Map buffers = new ConcurrentHashMap<>(); + private final NativeApi.StreamCallback callback = this::onEvent; + private final NativeApi.BytesDeleter deleter = this::releaseBuffer; + private final Thread worker; + private final Thread feeder; + private Pointer request, queue; + private boolean finished, closing, closed; + private long bufferedBytes; + private final long originNanos = System.nanoTime(); + private long firstInputNanos, firstNonemptyNanos, inputClosedNanos, submittedBytes; + + Transcription(AudioSession session, byte[] wav, PcmFormat format, Consumer listener) { + this.session = session; + this.api = session.api; + this.format = format; + this.listener = listener; + request = api.create(api.inference, NativeApi.InferenceApi.REQUEST_CREATE); + try { + Pointer audio = api.create(api.item, NativeApi.ItemApi.CREATE, 30); + boolean transferred = false; + try (Memory text = NativeApi.utf8("pcm")) { + NativeApi.AudioData data = new NativeApi.AudioData(); + data.format = text; + data.sampleRate = format.sampleRate(); + data.channels = format.channels(); + data.write(); + api.check(api.item.pointer(NativeApi.ItemApi.SET_AUDIO, audio, data)); + transferred = true; + api.check(api.inference.pointer(NativeApi.InferenceApi.REQUEST_ADD_ITEM, request, audio, (byte) 1)); + } finally { + if (!transferred) api.item.call(NativeApi.ItemApi.RELEASE, audio); + } + { + Pointer queueItem = api.create(api.item, NativeApi.ItemApi.CREATE, 200); + boolean queueTransferred = false; + try { + queue = api.create(api.item, NativeApi.ItemApi.GET_QUEUE, queueItem); + queueTransferred = true; + api.check(api.inference.pointer( + NativeApi.InferenceApi.REQUEST_ADD_ITEM, request, queueItem, (byte) 1)); + } finally { + if (!queueTransferred) api.item.call(NativeApi.ItemApi.RELEASE, queueItem); + } + } + api.check(api.inference.pointer( + NativeApi.InferenceApi.SESSION_SET_STREAMING_CALLBACK, + session.handle, + callback, + null)); + } catch (RuntimeException | Error e) { + api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + request = null; + throw e; + } + worker = new Thread(this::run, "foundry-java-asr"); + feeder = wav == null ? null : new Thread(() -> feedWav(wav), "foundry-java-asr-input"); + worker.start(); + if (feeder != null) feeder.start(); + } + + private void feedWav(byte[] pcm) { + try { + for (int offset = 0; offset < pcm.length; offset += 3200) { + writePcm(java.util.Arrays.copyOfRange(pcm, offset, Math.min(offset + 3200, pcm.length))); + } + finishInput(); + } catch (InterruptedException | RuntimeException e) { + synchronized (this) { + if (!completion.isCancelled() && !closing && !isDone()) { + callbackFailure.compareAndSet(null, e); + cancel(); + } + } + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + } + } + + /** Copies a complete PCM chunk. Applies bounded backpressure (at most 2 seconds queued). */ + public synchronized void writePcm(byte[] bytes) throws InterruptedException { + NativeApi.outsideCallback(); + if (feeder != null && Thread.currentThread() != feeder) { + throw new IllegalStateException("WAV input is managed automatically"); + } + format.validateChunk(bytes); + ensureWritable(); + while (bufferedBytes + bytes.length > 64000) { + wait(100); + ensureWritable(); + } + Memory memory = new Memory(bytes.length); + memory.write(0, bytes, 0, bytes.length); + long address = Pointer.nativeValue(memory); + buffers.put(address, memory); + bufferedBytes += bytes.length; + Pointer item = null; + boolean ownsBuffer = false; + try { + item = api.create(api.item, NativeApi.ItemApi.CREATE, 1); + NativeApi.BytesData data = new NativeApi.BytesData(); + data.data = memory; + data.mutableData = memory; + data.dataSize = bytes.length; + data.deleter = deleter; + data.write(); + api.check(api.item.pointer(NativeApi.ItemApi.SET_BYTES, item, data)); + ownsBuffer = true; + Pointer transferredItem = item; + item = null; + api.check(api.item.pointer(NativeApi.ItemApi.QUEUE_PUSH, queue, transferredItem)); + if (firstInputNanos == 0) firstInputNanos = System.nanoTime(); + submittedBytes += bytes.length; + } finally { + if (item != null) api.item.call(NativeApi.ItemApi.RELEASE, item); + if (!ownsBuffer) freeBuffer(address); + } + } + + private void ensureWritable() { + if (closed || closing || finished || completion.isCancelled() || isDone()) { + throw new IllegalStateException("Transcription no longer accepts PCM"); + } + } + + /** Signals natural end of PCM input. Unlike cancel(), this flushes final recognition. */ + public synchronized void finishInput() { + NativeApi.outsideCallback(); + if (feeder != null && Thread.currentThread() != feeder) { + throw new IllegalStateException("WAV input is managed automatically"); + } + if (closed || closing) throw new IllegalStateException("Transcription is closed"); + if (queue == null) throw new IllegalStateException("Only PCM requests have an input queue"); + if (!finished) { + api.item.call(NativeApi.ItemApi.QUEUE_MARK_FINISHED, queue); + inputClosedNanos = System.nanoTime(); + finished = true; + } + } + + public synchronized void cancel() { + NativeApi.outsideCallback(); + if (closed || !completion.cancel()) return; + api.check(api.inference.pointer(NativeApi.InferenceApi.REQUEST_CANCEL, request)); + if (queue != null && !finished) { + api.item.call(NativeApi.ItemApi.QUEUE_MARK_FINISHED, queue); + finished = true; + } + notifyAll(); + } + + public boolean isDone() { return completion.result.isDone(); } + public synchronized boolean isClosed() { return closed; } + public synchronized boolean isCancelled() { return completion.isCancelled(); } + + /** Milliseconds share one request-local monotonic origin; absent observations remain null. */ + public synchronized TranscriptionTiming timing() { + synchronized (completion) { + return new TranscriptionTiming(relative(firstInputNanos), relative(firstNonemptyNanos), + relative(inputClosedNanos), relative(completion.finalizedNanos), + relative(completion.cancellationNanos), submittedBytes); + } + } + + private Double relative(long nanos) { return nanos == 0 ? null : (nanos - originNanos) / 1_000_000.0; } + + public TranscriptionResult await() throws InterruptedException { + NativeApi.outsideCallback(); + try { return completion.result.get(); } + catch (ExecutionException e) { throw propagate(e.getCause()); } + } + + public TranscriptionResult await(Duration timeout) throws InterruptedException, TimeoutException { + NativeApi.outsideCallback(); + try { return completion.result.get(timeout.toMillis(), TimeUnit.MILLISECONDS); } + catch (ExecutionException e) { throw propagate(e.getCause()); } + } + + private static RuntimeException propagate(Throwable e) { + if (e instanceof RuntimeException runtime) return runtime; + if (e instanceof Error error) throw error; + return new IllegalStateException("Native transcription failed", e); + } + + private int onEvent(NativeApi.CallbackData event, Pointer userData) { + NativeApi.IN_CALLBACK.set(true); + try { + PointerByReference next = new PointerByReference(); + while (api.item.bool(NativeApi.ItemApi.QUEUE_TRY_POP, event.queue, next)) { + Pointer item = next.getValue(); + try { + if (api.item.integer(NativeApi.ItemApi.GET_TYPE, item) != 31) { + throw new IllegalStateException("Unexpected ASR stream item"); + } + NativeApi.SegmentData data = new NativeApi.SegmentData(); + data.write(); + api.check(api.item.pointer(NativeApi.ItemApi.GET_SPEECH_SEGMENT, item, data)); + data.read(); + SpeechEvent.Kind kind = switch (data.kind) { + case 0 -> SpeechEvent.Kind.TOKEN; + case 1 -> SpeechEvent.Kind.PARTIAL; + case 2 -> SpeechEvent.Kind.FINAL; + default -> throw new IllegalStateException("Unknown speech segment kind: " + data.kind); + }; + String text = NativeApi.text(data.text); + synchronized (this) { + if (!text.isBlank() && firstNonemptyNanos == 0) firstNonemptyNanos = System.nanoTime(); + } + listener.accept(new SpeechEvent(kind, text, + optionalTime(data.start), optionalTime(data.end), data.utteranceStart != 0)); + } finally { api.item.call(NativeApi.ItemApi.RELEASE, item); } + } + return completion.isCancelled() ? 1 : 0; + } catch (Throwable e) { + callbackFailure.compareAndSet(null, e); + return 1; + } finally { NativeApi.IN_CALLBACK.remove(); } + } + + private static Long optionalTime(long value) { return value == Long.MIN_VALUE ? null : value; } + + private void run() { + long start = System.nanoTime(); + PointerByReference response = new PointerByReference(); + try { + Pointer status = api.inference.pointer( + NativeApi.InferenceApi.SESSION_PROCESS_REQUEST, session.handle, request, response); + if (callbackFailure.get() != null) { + if (status != null) api.root.call(NativeApi.Root.STATUS_RELEASE, status); + throw new IllegalStateException("ASR callback or input feeder failed", callbackFailure.get()); + } + try { api.check(status); } + catch (FoundryLocalException e) { + if (e.code() != 5) throw e; + synchronized (completion) { completion.cancelled = true; } + } + completion.complete(wasCancelled -> { + int reason = response.getValue() == null + ? 0 + : api.inference.integer( + NativeApi.InferenceApi.RESPONSE_GET_FINISH_REASON, response.getValue()); + String text = "", language = ""; + Long duration = null; + wasCancelled = cancelledResult(wasCancelled, response.getValue() != null, reason); + if (!wasCancelled) { + boolean found = false; + for (long i = 0; + i < api.inference.size( + NativeApi.InferenceApi.RESPONSE_GET_ITEM_COUNT, response.getValue()); + i++) { + Pointer item = api.create( + api.inference, + NativeApi.InferenceApi.RESPONSE_GET_ITEM, + response.getValue(), + i); + if (api.item.integer(NativeApi.ItemApi.GET_TYPE, item) == 32) { + NativeApi.ResultData data = new NativeApi.ResultData(); + data.write(); + api.check(api.item.pointer(NativeApi.ItemApi.GET_SPEECH_RESULT, item, data)); + data.read(); + text = NativeApi.text(data.text); + language = NativeApi.text(data.language); + duration = optionalTime(data.duration); + found = true; + } + } + if (!found) throw new IllegalStateException("Native ASR response has no speech result"); + } + return new TranscriptionResult(text, language, duration, wasCancelled, reason, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); + }); + } catch (Throwable e) { + completion.fail(e); + } finally { + if (response.getValue() != null) { + api.inference.call(NativeApi.InferenceApi.RESPONSE_RELEASE, response.getValue()); + } + synchronized (this) { notifyAll(); } + Reference.reachabilityFence(callback); + Reference.reachabilityFence(deleter); + } + } + + static final class Completion { + final CompletableFuture result = new CompletableFuture<>(); + private boolean cancelled; + private long cancellationNanos, finalizedNanos; + + synchronized boolean cancel() { + if (result.isDone()) return false; + if (cancellationNanos == 0) cancellationNanos = System.nanoTime(); + cancelled = true; + return true; + } + + synchronized boolean isCancelled() { return cancelled; } + + void complete(Function decode) { + boolean wasCancelled = isCancelled(); + TranscriptionResult value = decode.apply(wasCancelled); + // Native decoding is outside the lock; only cancellation and terminal publication compete here. + synchronized (this) { + if (cancelled) { + value = new TranscriptionResult("", "", null, true, + value.nativeFinishReason(), value.elapsedMillis()); + } + finalizedNanos = System.nanoTime(); + result.complete(value); + } + } + + synchronized void fail(Throwable error) { result.completeExceptionally(error); } + } + + static boolean cancelledResult(boolean cancellationRequested, boolean hasResponse, int finishReason) { + if (cancellationRequested) return true; + if (!hasResponse) throw new IllegalStateException("Native ASR returned no response"); + if (finishReason != 2) throw new IllegalStateException("Unexpected native finish reason: " + finishReason); + return false; + } + + private void releaseBuffer(Pointer data, Pointer userData) { + // flBytesData.mutable_data is at offset 16 on every supported 64-bit target. + freeBuffer(Pointer.nativeValue(data.getPointer(16))); + } + + private synchronized void freeBuffer(long address) { + Memory memory = buffers.remove(address); + if (memory != null) { + bufferedBytes -= memory.size(); + memory.close(); + notifyAll(); + } + } + + /** Cancels unfinished work and waits for native callbacks before releasing handles. */ + @Override public void close() { + NativeApi.outsideCallback(); + boolean interrupted = false; + synchronized (this) { + while (closing && !closed) { + try { wait(); } + catch (InterruptedException e) { interrupted = true; } + } + if (closed) { + if (interrupted) Thread.currentThread().interrupt(); + return; + } + cancel(); + closing = true; + } + while (worker.isAlive()) { + try { worker.join(); } + catch (InterruptedException e) { interrupted = true; } + } + while (feeder != null && feeder.isAlive()) { + try { feeder.join(); } + catch (InterruptedException e) { interrupted = true; } + } + try { + try { + api.check(api.inference.pointer( + NativeApi.InferenceApi.SESSION_SET_STREAMING_CALLBACK, + session.handle, + null, + null)); + } finally { + api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + synchronized (this) { + request = null; + queue = null; + closed = true; + closing = false; + notifyAll(); + } + } + if (!buffers.isEmpty()) throw new IllegalStateException("Native request did not release all PCM buffers"); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + Reference.reachabilityFence(callback); + Reference.reachabilityFence(deleter); + } + } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionResult.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionResult.java new file mode 100644 index 000000000..783733f3a --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionResult.java @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** Cancellation never masquerades as a successful final transcript. */ +public record TranscriptionResult(String text, String language, Long durationMs, + boolean cancelled, int nativeFinishReason, long elapsedMillis) {} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionTiming.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionTiming.java new file mode 100644 index 000000000..6b289588c --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/TranscriptionTiming.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +/** Observed request-local monotonic timing, not model timestamps or download byte counters. */ +public record TranscriptionTiming(Double firstInputMillis, Double firstNonemptyMillis, + Double inputClosedMillis, Double finalizedMillis, + Double cancellationRequestedMillis, long submittedBytes) {} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/WavAudio.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/WavAudio.java new file mode 100644 index 000000000..c922d5e20 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/WavAudio.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +/** Strict, bounded RIFF/WAVE reader; rejects unsupported formats instead of silently converting. */ +public record WavAudio(PcmFormat format, byte[] pcm) { + public WavAudio { + java.util.Objects.requireNonNull(format); + if (pcm == null || pcm.length == 0 || pcm.length % 2 != 0) { + throw new IllegalArgumentException("WAV must contain complete PCM samples"); + } + pcm = pcm.clone(); + } + @Override public byte[] pcm() { return pcm.clone(); } + public double durationSeconds() { return pcm.length / 32000.0; } + + public static WavAudio read(Path path) throws IOException { + long size = Files.size(path); + if (size < 44 || size > 64L * 1024 * 1024) throw new IOException("WAV size must be 44 bytes..64 MiB"); + try (var input = Files.newInputStream(path)) { + byte[] bytes = input.readNBytes(64 * 1024 * 1024 + 1); + if (bytes.length > 64 * 1024 * 1024) throw new IOException("WAV exceeds 64 MiB"); + return parse(bytes); + } + } + + static WavAudio parse(byte[] bytes) throws IOException { + if (bytes.length < 44 || !tag(bytes, 0).equals("RIFF") || !tag(bytes, 8).equals("WAVE")) { + throw new IOException("Expected a RIFF/WAVE file"); + } + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + long end = Integer.toUnsignedLong(buffer.getInt(4)) + 8; + if (end != bytes.length) throw new IOException("RIFF size does not match file length"); + PcmFormat format = null; + byte[] pcm = null; + for (long offset = 12; offset < end;) { + if (offset + 8 > end) throw new IOException("Truncated WAV chunk"); + int at = (int) offset; + long length = Integer.toUnsignedLong(buffer.getInt(at + 4)); + long next = offset + 8 + length + (length & 1); + if (next > end) throw new IOException("WAV chunk extends beyond RIFF"); + switch (tag(bytes, at)) { + case "fmt " -> { + if (format != null || length < 16 || buffer.getShort(at + 8) != 1) { + throw new IOException("Expected a single PCM fmt chunk"); + } + try { + format = new PcmFormat(buffer.getInt(at + 12), buffer.getShort(at + 10), + buffer.getShort(at + 22)); + } catch (IllegalArgumentException e) { throw new IOException(e.getMessage(), e); } + if (buffer.getInt(at + 16) != 32000 || buffer.getShort(at + 20) != 2) { + throw new IOException("Invalid WAV byte rate or block alignment"); + } + } + case "data" -> { + if (pcm != null || length == 0 || length % 2 != 0) throw new IOException("Invalid WAV data chunk"); + pcm = Arrays.copyOfRange(bytes, at + 8, (int) (offset + 8 + length)); + } + default -> { /* Skip legal RIFF metadata chunks, including their odd-byte padding. */ } + } + offset = next; + } + if (format == null || pcm == null) throw new IOException("WAV requires fmt and data chunks"); + return new WavAudio(format, pcm); + } + + private static String tag(byte[] bytes, int offset) { + return new String(bytes, offset, 4, StandardCharsets.US_ASCII); + } +} diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/AudioValidationTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/AudioValidationTest.java new file mode 100644 index 000000000..36616a7b6 --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/AudioValidationTest.java @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.junit.jupiter.api.Test; + +class AudioValidationTest { + @Test void pcmRejectsInvalidFormatsAndChunks() { + assertThrows(IllegalArgumentException.class, () -> new PcmFormat(44100, 1, 16)); + assertThrows(IllegalArgumentException.class, () -> new PcmFormat(16000, 2, 16)); + assertThrows(IllegalArgumentException.class, () -> new PcmFormat(16000, 1, 32)); + for (int length : new int[] {0, 1, 3, 32001, 32002}) { + assertThrows(IllegalArgumentException.class, () -> PcmFormat.SPEECH.validateChunk(new byte[length])); + } + PcmFormat.SPEECH.validateChunk(new byte[3200]); + PcmFormat.SPEECH.validateChunk(new byte[32000]); + } + + @Test void wavPreservesPcmAndDefensivelyCopies() throws Exception { + byte[] wav = wav(); + WavAudio audio = WavAudio.parse(wav); + assertEquals(PcmFormat.SPEECH, audio.format()); + assertEquals(4 / 32000.0, audio.durationSeconds()); + byte[] samples = audio.pcm(); + samples[0] = 99; + assertEquals(0, audio.pcm()[0]); + } + + @Test void wavRejectsTruncationInvalidSizesAndEncoding() { + assertThrows(IOException.class, () -> WavAudio.parse(new byte[0])); + byte[] badLength = wav(); + badLength[4] = 100; + assertThrows(IOException.class, () -> WavAudio.parse(badLength)); + byte[] floatAudio = wav(); + floatAudio[20] = 3; + assertThrows(IOException.class, () -> WavAudio.parse(floatAudio)); + byte[] badRate = wav(); + badRate[24] = 1; + assertThrows(IOException.class, () -> WavAudio.parse(badRate)); + byte[] chunkOverrun = wav(); + chunkOverrun[40] = 100; + assertThrows(IOException.class, () -> WavAudio.parse(chunkOverrun)); + byte[] badAlignment = wav(); + badAlignment[32] = 4; + assertThrows(IOException.class, () -> WavAudio.parse(badAlignment)); + } + + @Test void configurationHasNoNativeSideEffects() { + var path = java.nio.file.Path.of("missing"); + assertNotNull(new Configuration("unit-test", path, path, path)); + assertThrows(IllegalArgumentException.class, () -> new Configuration("", path, path, path)); + assertThrows(IllegalArgumentException.class, () -> new Configuration("a\0b", path, path, path)); + } + + private static byte[] wav() { + ByteBuffer b = ByteBuffer.allocate(48).order(ByteOrder.LITTLE_ENDIAN); + b.put(new byte[] {'R', 'I', 'F', 'F'}).putInt(40).put(new byte[] {'W', 'A', 'V', 'E'}); + b.put(new byte[] {'f', 'm', 't', ' '}).putInt(16).putShort((short) 1).putShort((short) 1); + b.putInt(16000).putInt(32000).putShort((short) 2).putShort((short) 16); + b.put(new byte[] {'d', 'a', 't', 'a'}).putInt(4).putInt(0); + return b.array(); + } +} diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java new file mode 100644 index 000000000..176821562 --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Explicit opt-in; never downloads a model. All sessions share the caller's prepared cache. */ +class NativeAsrTest { + @TempDir Path temporary; + + @Test void realAsrOwnershipCancellationAndRepeatedSessions() throws Exception { + String runtime = System.getProperty("foundry.test.runtime"); + String cache = System.getProperty("foundry.test.cache"); + String wav = System.getProperty("foundry.test.wav"); + assumeTrue(runtime != null && cache != null && wav != null, "Set the three foundry.test.* properties"); + Configuration config = new Configuration("java-asr-test", Path.of(runtime), Path.of(cache), temporary); + Model borrowed; + AtomicInteger callbacks = new AtomicInteger(); + try (FoundryLocalManager manager = new FoundryLocalManager(config)) { + System.err.println("native-test: manager created"); + assertThrows(IllegalStateException.class, () -> new FoundryLocalManager(config)); + assertThrows(IllegalArgumentException.class, () -> manager.catalog().getModel("nemotron")); + borrowed = manager.catalog().getModel(System.getProperty("foundry.test.model", + "nemotron-3.5-asr-streaming-0.6b-generic-cpu:3")); + CancellationToken cancelled = new CancellationToken(); + cancelled.cancel(); + FoundryLocalException download = assertThrows(FoundryLocalException.class, + () -> borrowed.download(cancelled, ignored -> fail("Must not invoke progress"))); + assertEquals(5, download.code()); + assertTrue(borrowed.isCached(), "Explicitly prepare the model first"); + borrowed.load(); + System.err.println("native-test: model loaded"); + byte[] pcm = WavAudio.read(Path.of(wav)).pcm(); + try (AudioSession session = borrowed.createAudioSession(); + Transcription run = session.transcribeWav(Path.of(wav), event -> callbacks.incrementAndGet())) { + assertFalse(run.await(Duration.ofSeconds(60)).text().isBlank()); + assertEquals(pcm.length, run.timing().submittedBytes()); + assertNotNull(run.timing().inputClosedMillis()); + } + System.err.println("native-test: WAV finished and closed"); + for (int iteration = 0; iteration < 2; iteration++) { + try (AudioSession session = borrowed.createAudioSession()) { + assertThrows(IllegalStateException.class, borrowed::unload); + try (Transcription run = session.streamPcm(PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { + for (int offset = 0; offset < pcm.length; offset += 3200) { + run.writePcm(Arrays.copyOfRange(pcm, offset, Math.min(offset + 3200, pcm.length))); + } + run.finishInput(); + TranscriptionResult result = run.await(Duration.ofMinutes(3)); + assertFalse(result.cancelled()); + assertEquals(2, result.nativeFinishReason()); + assertFalse(result.text().isBlank()); + assertThrows(IllegalStateException.class, () -> run.writePcm(new byte[2])); + } + System.err.println("native-test: PCM finished and closed " + iteration); + int count = callbacks.get(); + Thread.sleep(100); + assertEquals(count, callbacks.get(), "No callback may outlive close"); + try (Transcription run = session.streamPcm(PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { + run.writePcm(Arrays.copyOf(pcm, Math.min(3200, pcm.length))); + run.cancel(); + assertTrue(run.await(Duration.ofSeconds(30)).cancelled()); + assertTrue(run.isCancelled()); + } + System.err.println("native-test: cancellation acknowledged and closed " + iteration); + try (Transcription run = session.streamPcm(PcmFormat.SPEECH, event -> { + throw new IllegalStateException("listener failure"); + })) { + for (int offset = 0; offset < Math.min(pcm.length, 64000); offset += 3200) { + if (run.isDone()) break; + run.writePcm(Arrays.copyOfRange(pcm, offset, Math.min(offset + 3200, pcm.length))); + } + run.finishInput(); + assertThrows(IllegalStateException.class, () -> run.await(Duration.ofSeconds(30))); + } + System.err.println("native-test: callback failure surfaced and closed " + iteration); + } + } + // Manager owns and closes an outstanding session/request even if the caller forgets. + borrowed.createAudioSession().streamPcm(PcmFormat.SPEECH, event -> callbacks.incrementAndGet()); + System.err.println("native-test: cascade close starting"); + } + System.err.println("native-test: manager closed"); + assertThrows(IllegalStateException.class, borrowed::isLoaded); + int count = callbacks.get(); + Thread.sleep(100); + assertEquals(count, callbacks.get()); + assertTrue(Thread.getAllStackTraces().keySet().stream() + .noneMatch(thread -> thread.isAlive() && thread.getName().startsWith("foundry-java-asr"))); + try (FoundryLocalManager manager = new FoundryLocalManager(config)) { + assertFalse(manager.runtimeVersion().isBlank()); + } + System.err.println("native-test: manager recreated and closed"); + } +} diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java new file mode 100644 index 000000000..0e4e22415 --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NativeContractTest { + @TempDir Path temporary; + + @Test void matchesPackaged64BitStructSizes() { + assertEquals(16, new NativeApi.CallbackData().size()); + assertEquals(72, new NativeApi.AudioData().size()); + assertEquals(48, new NativeApi.BytesData().size()); + assertEquals(64, new NativeApi.SegmentData().size()); + assertEquals(48, new NativeApi.ResultData().size()); + } + + @Test void runtimeDirectoryMustContainTheCurrentPlatformLibrary() { + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> NativeApi.findFoundryLibrary(temporary)); + assertTrue(error.getMessage().contains("Foundry Local native library")); + } + + @Test void resolvesTheCurrentPlatformLibraryWithoutAReleaseSpecificHashLock() throws Exception { + Path library = temporary.resolve(NativeApi.foundryLibraryName()); + Files.createFile(library); + assertEquals(library, NativeApi.findFoundryLibrary(temporary)); + } + + @Test void callbackLifecycleReentrancyIsRejected() { + NativeApi.IN_CALLBACK.set(true); + try { assertThrows(IllegalStateException.class, NativeApi::outsideCallback); } + finally { NativeApi.IN_CALLBACK.remove(); } + } + +} diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java new file mode 100644 index 000000000..74eb810bc --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class TranscriptionStateTest { + @Test void absentOrUnfinishedResponseIsNotCancellation() { + assertThrows(IllegalStateException.class, () -> Transcription.cancelledResult(false, false, 0)); + assertThrows(IllegalStateException.class, () -> Transcription.cancelledResult(false, true, 0)); + assertThrows(IllegalStateException.class, () -> Transcription.cancelledResult(false, true, 1)); + } + + @Test void onlyAnObservedCancellationMakesACancelledResult() { + assertTrue(Transcription.cancelledResult(true, false, 0)); + assertTrue(Transcription.cancelledResult(true, true, 0)); + assertFalse(Transcription.cancelledResult(false, true, 2)); + } + + @Test void cancellationDuringNativeResultDecodingWinsPublication() throws Exception { + Transcription.Completion completion = new Transcription.Completion(); + CountDownLatch decoding = new CountDownLatch(1); + CountDownLatch resume = new CountDownLatch(1); + var worker = Executors.newSingleThreadExecutor(); + try { + var published = worker.submit(() -> completion.complete(wasCancelled -> { + assertFalse(wasCancelled); + assertFalse(Thread.holdsLock(completion), "Native result decoding must not hold the state lock"); + decoding.countDown(); + try { assertTrue(resume.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException e) { throw new AssertionError(e); } + return new TranscriptionResult("final transcript", "en", 6800L, false, 2, 100); + })); + assertTrue(decoding.await(5, TimeUnit.SECONDS)); + assertTrue(completion.cancel(), "Cancellation must be accepted before result publication"); + resume.countDown(); + published.get(5, TimeUnit.SECONDS); + TranscriptionResult result = completion.result.get(5, TimeUnit.SECONDS); + assertTrue(completion.isCancelled()); + assertTrue(result.cancelled(), "Publication must not use the pre-decoding cancellation snapshot"); + assertEquals("", result.text()); + assertEquals("", result.language()); + assertNull(result.durationMs()); + assertEquals(2, result.nativeFinishReason()); + assertFalse(completion.cancel(), "Further cancellation is too late after publication"); + } finally { + resume.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void cancellationAfterResultPublicationIsTooLate() { + Transcription.Completion completion = new Transcription.Completion(); + completion.complete(wasCancelled -> new TranscriptionResult("final transcript", "en", 6800L, false, 2, 100)); + assertFalse(completion.cancel()); + assertFalse(completion.isCancelled()); + assertFalse(completion.result.join().cancelled()); + assertEquals("final transcript", completion.result.join().text()); + } + + @Test void cancellationAfterExceptionalPublicationIsTooLate() { + Transcription.Completion completion = new Transcription.Completion(); + completion.fail(new IllegalStateException("native error")); + assertFalse(completion.cancel()); + assertFalse(completion.isCancelled()); + assertTrue(completion.result.isCompletedExceptionally()); + } +} From 6daedf0c1370d10a495cbb10ac05a2146c07515e Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Fri, 11 Sep 2026 22:16:57 +0800 Subject: [PATCH 02/15] ci(java): add native ASR platform matrix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/v2/templates/stages-java.yml | 267 ++++++++++++++++++ .pipelines/v2/templates/stages-sdk-v2.yml | 5 + .pipelines/v2/templates/steps-build-java.yml | 85 ++++++ sdk_v2/java/README.md | 5 +- .../foundry/local/NativeAsrTest.java | 18 +- 5 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 .pipelines/v2/templates/stages-java.yml create mode 100644 .pipelines/v2/templates/steps-build-java.yml diff --git a/.pipelines/v2/templates/stages-java.yml b/.pipelines/v2/templates/stages-java.yml new file mode 100644 index 000000000..c07205bfd --- /dev/null +++ b/.pipelines/v2/templates/stages-java.yml @@ -0,0 +1,267 @@ +# Build + native-test + package stages for the sdk_v2 Java SDK. +# +# Native ASR tests run on all five supported platform/architecture pairs using +# the matching cpp-native artifact and the pinned CI model cache. The final +# stage creates one platform-independent Maven bundle. + +stages: +- stage: java_build_win_x64 + displayName: 'Java SDK: Build + Test Windows x64' + dependsOn: + - compute_version + - cpp_build_win_x64 + jobs: + - job: build + timeoutInMinutes: 90 + pool: + name: onnxruntime-Win-CPU-2022 + os: windows + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-win-x64' + targetPath: '$(Pipeline.Workspace)/cpp-native-win-x64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + - template: steps-build-java.yml + parameters: + rid: 'win-x64' + jdkArchitecture: 'x64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-win-x64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +- stage: java_build_win_arm64 + displayName: 'Java SDK: Build + Test Windows ARM64' + dependsOn: + - compute_version + - cpp_build_win_arm64 + jobs: + - job: build + timeoutInMinutes: 90 + pool: + name: onnxruntime-medium-arm64-scus + os: windows + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-win-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-win-arm64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + - template: steps-build-java.yml + parameters: + rid: 'win-arm64' + jdkArchitecture: 'arm64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-win-arm64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +- stage: java_build_linux_x64 + displayName: 'Java SDK: Build + Test Linux x64' + dependsOn: + - compute_version + - cpp_build_linux_x64 + jobs: + - job: build + timeoutInMinutes: 90 + pool: + name: onnxruntime-Ubuntu2404-AMD-CPU + os: linux + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-x64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-x64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + - template: steps-build-java.yml + parameters: + rid: 'linux-x64' + jdkArchitecture: 'x64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-linux-x64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +- stage: java_build_linux_arm64 + displayName: 'Java SDK: Build + Test Linux ARM64' + dependsOn: + - compute_version + - cpp_build_linux_arm64 + jobs: + - job: build + timeoutInMinutes: 90 + pool: + name: onnxruntime-linux-ARM64-CPU-2019 + os: linux + hostArchitecture: arm64 + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-linux-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + - template: steps-build-java.yml + parameters: + rid: 'linux-arm64' + jdkArchitecture: 'arm64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-linux-arm64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +- stage: java_build_osx_arm64 + displayName: 'Java SDK: Build + Test macOS ARM64' + dependsOn: + - compute_version + - cpp_build_osx_arm64 + jobs: + - job: build + timeoutInMinutes: 90 + pool: + name: AcesShared + os: macOS + demands: + - ImageOverride -equals ACES_VM_SharedPool_Sequoia + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + - input: pipelineArtifact + artifactName: 'cpp-native-osx-arm64' + targetPath: '$(Pipeline.Workspace)/cpp-native-osx-arm64' + steps: + - checkout: self + clean: true + - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + - template: steps-build-java.yml + parameters: + rid: 'osx-arm64' + jdkArchitecture: 'arm64' + nativeArtifactDir: '$(Pipeline.Workspace)/cpp-native-osx-arm64' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + +- stage: java_pack_maven + displayName: 'Java SDK: Pack Maven bundle (java-sdk-v2)' + dependsOn: + - compute_version + - java_build_win_x64 + - java_build_win_arm64 + - java_build_linux_x64 + - java_build_linux_arm64 + - java_build_osx_arm64 + jobs: + - job: pack + pool: + name: onnxruntime-Win-CPU-2022 + os: windows + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + outputs: + - output: pipelineArtifact + artifactName: 'java-sdk-v2' + targetPath: '$(Build.ArtifactStagingDirectory)/java-sdk-v2' + steps: + - checkout: self + clean: true + - task: PowerShell@2 + displayName: 'Read Java SDK version' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + $versionFile = Join-Path '$(Pipeline.Workspace)/version-info' 'sdkVersion.txt' + if (-not (Test-Path $versionFile)) { + throw "Missing shared SDK version file: $versionFile" + } + $version = (Get-Content $versionFile -Raw).Trim() + if (-not $version) { + throw 'The shared SDK version is empty.' + } + Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" + - task: Maven@4 + displayName: 'Maven clean verify' + inputs: + mavenPOMFile: 'sdk_v2/java/pom.xml' + goals: 'clean verify' + options: '--batch-mode --no-transfer-progress -Drevision=$(javaSdkVersion)' + publishJUnitResults: true + testResultsFiles: 'sdk_v2/java/target/surefire-reports/TEST-*.xml' + testRunTitle: 'Java SDK package' + javaHomeOption: JDKVersion + jdkVersionOption: '1.17' + jdkArchitectureOption: 'x64' + mavenVersionOption: Default + - task: PowerShell@2 + displayName: 'Stage and validate Maven bundle' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + + $version = '$(javaSdkVersion)' + $javaRoot = '$(Build.SourcesDirectory)/sdk_v2/java' + $target = Join-Path $javaRoot 'target' + $output = '$(Build.ArtifactStagingDirectory)/java-sdk-v2' + New-Item -ItemType Directory -Force -Path $output | Out-Null + + $artifacts = @( + @{ Source = Join-Path $target "foundry-local-sdk-$version.jar"; Name = "foundry-local-sdk-$version.jar" }, + @{ Source = Join-Path $target "foundry-local-sdk-$version-sources.jar"; Name = "foundry-local-sdk-$version-sources.jar" }, + @{ Source = Join-Path $javaRoot '.flattened-pom.xml'; Name = "foundry-local-sdk-$version.pom" } + ) + foreach ($artifact in $artifacts) { + if (-not (Test-Path $artifact.Source)) { + throw "Missing Maven artifact: $($artifact.Source)" + } + Copy-Item $artifact.Source (Join-Path $output $artifact.Name) -Force + } + Copy-Item '$(Build.SourcesDirectory)/LICENSE' (Join-Path $output 'LICENSE') -Force + Copy-Item (Join-Path $javaRoot 'THIRD_PARTY_NOTICES.md') $output -Force + + $mainJar = Join-Path $output "foundry-local-sdk-$version.jar" + $entries = @(jar tf $mainJar) + if ($LASTEXITCODE -ne 0) { + throw 'Could not inspect the Java SDK JAR.' + } + foreach ($required in @('META-INF/LICENSE', 'META-INF/THIRD_PARTY_NOTICES.md')) { + if ($required -notin $entries) { + throw "Missing $required from the Java SDK JAR." + } + } + if ($entries | Where-Object { $_ -match '(?:\.(?:dll|dylib|onnx)$|\.so(?:\.|$))' }) { + throw 'The platform-independent Java SDK JAR contains a native binary or model.' + } + + $utf8NoBom = [Text.UTF8Encoding]::new($false) + Get-ChildItem $output -File | + Where-Object { $_.Extension -in @('.jar', '.pom') } | + ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText("$($_.FullName).sha256", "$hash $($_.Name)`n", $utf8NoBom) + } + + Get-ChildItem $output -File | ForEach-Object { Write-Host " $($_.Name)" } diff --git a/.pipelines/v2/templates/stages-sdk-v2.yml b/.pipelines/v2/templates/stages-sdk-v2.yml index 90d59217a..a509f1802 100644 --- a/.pipelines/v2/templates/stages-sdk-v2.yml +++ b/.pipelines/v2/templates/stages-sdk-v2.yml @@ -6,6 +6,7 @@ # 3. Python SDK (templates/stages-python.yml) # 4. JS SDK (templates/stages-js.yml) # 5. Rust SDK (templates/stages-rust.yml) +# 6. Java SDK (templates/stages-java.yml) # # Assumes the caller has already emitted a `compute_version` stage that # publishes the `version-info` pipeline artifact (containing sdkVersion.txt @@ -45,3 +46,7 @@ stages: # ── Rust SDK (single platform-independent crate) ── # Rust integration tests consume the complete matching cpp-native artifact. - template: stages-rust.yml + +# ── Java SDK (single platform-independent Maven bundle) ── +# Java native ASR tests consume the complete matching cpp-native artifact. +- template: stages-java.yml diff --git a/.pipelines/v2/templates/steps-build-java.yml b/.pipelines/v2/templates/steps-build-java.yml new file mode 100644 index 000000000..25828bc93 --- /dev/null +++ b/.pipelines/v2/templates/steps-build-java.yml @@ -0,0 +1,85 @@ +# Build and native-test the sdk_v2 Java SDK on the current platform. + +parameters: +- name: rid + type: string + # win-x64 | win-arm64 | linux-x64 | linux-arm64 | osx-arm64 +- name: jdkArchitecture + type: string + # x64 | arm64 +- name: nativeArtifactDir + type: string +- name: testDataSharedDir + type: string + +steps: +- task: PowerShell@2 + displayName: 'Assemble native runtime' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + + $rid = '${{ parameters.rid }}' + $assembled = Join-Path '$(Build.BinariesDirectory)' 'fl-native' + if (Test-Path $assembled) { + Remove-Item -Recurse -Force $assembled + } + New-Item -ItemType Directory -Force -Path $assembled | Out-Null + + $nativeFiles = @(Get-ChildItem -Path '${{ parameters.nativeArtifactDir }}' -Recurse -File | + Where-Object { + $file = $_ + switch -Regex ($rid) { + '^win' { $file.Name.EndsWith('.dll', [StringComparison]::OrdinalIgnoreCase) } + '^osx' { $file.Name.EndsWith('.dylib', [StringComparison]::OrdinalIgnoreCase) } + default { $file.Name -match '\.so(?:\.|$)' } + } + }) + if ($nativeFiles.Count -eq 0) { + throw "No native libraries found in the $rid native artifact." + } + + foreach ($file in $nativeFiles) { + Copy-Item $file.FullName (Join-Path $assembled $file.Name) -Force + Write-Host " native: $($file.Name)" + } + + Write-Host "##vso[task.setvariable variable=flNativeDir]$assembled" + +- task: PowerShell@2 + displayName: 'Read Java SDK version' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + $versionFile = Join-Path '$(Pipeline.Workspace)/version-info' 'sdkVersion.txt' + if (-not (Test-Path $versionFile)) { + throw "Missing shared SDK version file: $versionFile" + } + $version = (Get-Content $versionFile -Raw).Trim() + if (-not $version) { + throw 'The shared SDK version is empty.' + } + Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" + +- task: Maven@4 + displayName: 'Maven verify + native ASR (${{ parameters.rid }})' + inputs: + mavenPOMFile: 'sdk_v2/java/pom.xml' + goals: 'clean verify' + options: '--batch-mode --no-transfer-progress -Drevision=$(javaSdkVersion)' + publishJUnitResults: true + testResultsFiles: 'sdk_v2/java/target/surefire-reports/TEST-*.xml' + testRunTitle: 'Java SDK ${{ parameters.rid }}' + javaHomeOption: JDKVersion + jdkVersionOption: '1.17' + jdkArchitectureOption: '${{ parameters.jdkArchitecture }}' + mavenVersionOption: Default + env: + FOUNDRY_LOCAL_NATIVE_BIN_DIR: $(flNativeDir) + FOUNDRY_TEST_DATA_DIR: '${{ parameters.testDataSharedDir }}' + FOUNDRY_TEST_WAV: '$(Build.SourcesDirectory)/sdk_v2/testdata/Recording.wav' + ORT_TELEMETRY_DISABLED: '1' diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md index 43cb1fd3c..e0771cbda 100644 --- a/sdk_v2/java/README.md +++ b/sdk_v2/java/README.md @@ -73,7 +73,7 @@ var configuration = new Configuration( try (var manager = new FoundryLocalManager(configuration)) { var model = manager.catalog() - .getModel("nemotron-3.5-asr-streaming-0.6b-generic-cpu:3"); + .getModel("nemotron-speech-streaming-en-0.6b-generic-cpu:3"); if (!model.isCached()) { throw new IllegalStateException("Download and review the model separately"); @@ -137,6 +137,9 @@ mvn -f sdk_v2\java\pom.xml test ` -Dfoundry.test.wav= ``` +CI may provide the equivalent `FOUNDRY_LOCAL_NATIVE_BIN_DIR`, +`FOUNDRY_TEST_DATA_DIR`, and `FOUNDRY_TEST_WAV` environment variables. + The test reuses one loaded model for repeated PCM requests, covers final results, cancellation, callback failures, deterministic cleanup, manager recreation, and verifies that no callback or worker survives close. diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java index 176821562..c089bfcfd 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -15,10 +15,10 @@ class NativeAsrTest { @TempDir Path temporary; @Test void realAsrOwnershipCancellationAndRepeatedSessions() throws Exception { - String runtime = System.getProperty("foundry.test.runtime"); - String cache = System.getProperty("foundry.test.cache"); - String wav = System.getProperty("foundry.test.wav"); - assumeTrue(runtime != null && cache != null && wav != null, "Set the three foundry.test.* properties"); + String runtime = setting("foundry.test.runtime", "FOUNDRY_LOCAL_NATIVE_BIN_DIR"); + String cache = setting("foundry.test.cache", "FOUNDRY_TEST_DATA_DIR"); + String wav = setting("foundry.test.wav", "FOUNDRY_TEST_WAV"); + assumeTrue(runtime != null && cache != null && wav != null, "Configure the native test runtime, cache, and WAV"); Configuration config = new Configuration("java-asr-test", Path.of(runtime), Path.of(cache), temporary); Model borrowed; AtomicInteger callbacks = new AtomicInteger(); @@ -27,7 +27,7 @@ class NativeAsrTest { assertThrows(IllegalStateException.class, () -> new FoundryLocalManager(config)); assertThrows(IllegalArgumentException.class, () -> manager.catalog().getModel("nemotron")); borrowed = manager.catalog().getModel(System.getProperty("foundry.test.model", - "nemotron-3.5-asr-streaming-0.6b-generic-cpu:3")); + "nemotron-speech-streaming-en-0.6b-generic-cpu:3")); CancellationToken cancelled = new CancellationToken(); cancelled.cancel(); FoundryLocalException download = assertThrows(FoundryLocalException.class, @@ -98,4 +98,12 @@ class NativeAsrTest { } System.err.println("native-test: manager recreated and closed"); } + + private static String setting(String property, String environmentVariable) { + String value = System.getProperty(property); + if (value == null) { + value = System.getenv(environmentVariable); + } + return value == null || value.isBlank() ? null : value; + } } From 6615fa908a4561cdf697e213f7360d7b47911228 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Sat, 12 Sep 2026 10:39:37 +0800 Subject: [PATCH 03/15] fix(java): address lifecycle and CI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fetch-models-from-artifacts-feed.yml | 99 ++++++++++--------- .pipelines/v2/templates/stages-java.yml | 10 ++ sdk_v2/DEVELOPMENT.md | 10 +- sdk_v2/build_and_test_all.ps1 | 6 +- .../microsoft/foundry/local/AudioSession.java | 20 +++- .../foundry/local/FoundryLocalManager.java | 29 +++--- .../microsoft/foundry/local/NativeApi.java | 13 +++ .../foundry/local/Transcription.java | 54 ++++++++-- .../foundry/local/NativeContractTest.java | 9 ++ 9 files changed, 170 insertions(+), 80 deletions(-) diff --git a/.pipelines/templates/fetch-models-from-artifacts-feed.yml b/.pipelines/templates/fetch-models-from-artifacts-feed.yml index 13727c8e6..b2394cec5 100644 --- a/.pipelines/templates/fetch-models-from-artifacts-feed.yml +++ b/.pipelines/templates/fetch-models-from-artifacts-feed.yml @@ -8,27 +8,33 @@ parameters: type: string default: pscore values: ['pscore', 'ps'] +- name: modelSet + type: string + default: all + values: ['all', 'nemotron-speech'] steps: -- task: UniversalPackages@0 - displayName: 'Fetch Qwen 2.5 0.5B Instruct test model' - inputs: - command: download - feedsToUse: internal - vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' - vstsFeedPackage: 'foundry-local-qwen-2-5-0-5b-instruct-generic-cpu' - vstsPackageVersion: '4.0.2' - downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen2.5-0.5b-instruct-generic-cpu-4' +- ${{ if eq(parameters.modelSet, 'all') }}: + - task: UniversalPackages@0 + displayName: 'Fetch Qwen 2.5 0.5B Instruct test model' + inputs: + command: download + feedsToUse: internal + vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' + vstsFeedPackage: 'foundry-local-qwen-2-5-0-5b-instruct-generic-cpu' + vstsPackageVersion: '4.0.2' + downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen2.5-0.5b-instruct-generic-cpu-4' -- task: UniversalPackages@0 - displayName: 'Fetch OpenAI Whisper Tiny test model' - inputs: - command: download - feedsToUse: internal - vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' - vstsFeedPackage: 'foundry-local-openai-whisper-tiny-generic-cpu' - vstsPackageVersion: '4.0.0' - downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/openai-whisper-tiny-generic-cpu-4' +- ${{ if eq(parameters.modelSet, 'all') }}: + - task: UniversalPackages@0 + displayName: 'Fetch OpenAI Whisper Tiny test model' + inputs: + command: download + feedsToUse: internal + vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' + vstsFeedPackage: 'foundry-local-openai-whisper-tiny-generic-cpu' + vstsPackageVersion: '4.0.0' + downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/openai-whisper-tiny-generic-cpu-4' - task: UniversalPackages@0 displayName: 'Fetch Nemotron Speech Streaming test model' @@ -40,32 +46,35 @@ steps: vstsPackageVersion: '3.0.0' downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/nemotron-speech-streaming-en-0.6b-generic-cpu-3' -- task: UniversalPackages@0 - displayName: 'Fetch Qwen3 Embedding test model' - inputs: - command: download - feedsToUse: internal - vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' - vstsFeedPackage: 'foundry-local-qwen3-embedding-0-6b-generic-cpu' - vstsPackageVersion: '1.0.0' - downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen3-embedding-0.6b-generic-cpu-1' +- ${{ if eq(parameters.modelSet, 'all') }}: + - task: UniversalPackages@0 + displayName: 'Fetch Qwen3 Embedding test model' + inputs: + command: download + feedsToUse: internal + vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' + vstsFeedPackage: 'foundry-local-qwen3-embedding-0-6b-generic-cpu' + vstsPackageVersion: '1.0.0' + downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen3-embedding-0.6b-generic-cpu-1' -- task: UniversalPackages@0 - displayName: 'Fetch Qwen3.5 0.8B test model' - inputs: - command: download - feedsToUse: internal - vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' - vstsFeedPackage: 'foundry-local-qwen3-5-0-8b-generic-cpu' - vstsPackageVersion: '2.0.0' - downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen3.5-0.8b-generic-cpu-2' +- ${{ if eq(parameters.modelSet, 'all') }}: + - task: UniversalPackages@0 + displayName: 'Fetch Qwen3.5 0.8B test model' + inputs: + command: download + feedsToUse: internal + vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' + vstsFeedPackage: 'foundry-local-qwen3-5-0-8b-generic-cpu' + vstsPackageVersion: '2.0.0' + downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/qwen3.5-0.8b-generic-cpu-2' -- task: UniversalPackages@0 - displayName: 'Fetch DeepSeek R1 Distill Qwen 14B test model' - inputs: - command: download - feedsToUse: internal - vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' - vstsFeedPackage: 'foundry-local-deepseek-r1-distill-qwen-14b-generic-cpu' - vstsPackageVersion: '4.0.0' - downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/deepseek-r1-distill-qwen-14b-generic-cpu-4' \ No newline at end of file +- ${{ if eq(parameters.modelSet, 'all') }}: + - task: UniversalPackages@0 + displayName: 'Fetch DeepSeek R1 Distill Qwen 14B test model' + inputs: + command: download + feedsToUse: internal + vstsFeed: 'AIFoundryLocal/AIFoundryLocal_PublicPackages' + vstsFeedPackage: 'foundry-local-deepseek-r1-distill-qwen-14b-generic-cpu' + vstsPackageVersion: '4.0.0' + downloadDirectory: '${{ parameters.destinationPath }}/Microsoft/deepseek-r1-distill-qwen-14b-generic-cpu-4' \ No newline at end of file diff --git a/.pipelines/v2/templates/stages-java.yml b/.pipelines/v2/templates/stages-java.yml index c07205bfd..8551a5a1c 100644 --- a/.pipelines/v2/templates/stages-java.yml +++ b/.pipelines/v2/templates/stages-java.yml @@ -28,6 +28,8 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + parameters: + modelSet: 'nemotron-speech' - template: steps-build-java.yml parameters: rid: 'win-x64' @@ -59,6 +61,8 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + parameters: + modelSet: 'nemotron-speech' - template: steps-build-java.yml parameters: rid: 'win-arm64' @@ -89,6 +93,8 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + parameters: + modelSet: 'nemotron-speech' - template: steps-build-java.yml parameters: rid: 'linux-x64' @@ -120,6 +126,8 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + parameters: + modelSet: 'nemotron-speech' - template: steps-build-java.yml parameters: rid: 'linux-arm64' @@ -152,6 +160,8 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-models-from-artifacts-feed.yml@self + parameters: + modelSet: 'nemotron-speech' - template: steps-build-java.yml parameters: rid: 'osx-arm64' diff --git a/sdk_v2/DEVELOPMENT.md b/sdk_v2/DEVELOPMENT.md index 6aeee8e80..5df68a518 100644 --- a/sdk_v2/DEVELOPMENT.md +++ b/sdk_v2/DEVELOPMENT.md @@ -2,7 +2,7 @@ A first-time contributor should be able to install the tools listed below, clone the repo, then run the one-shot build/test script from this directory -and watch all five SDKs go green: +and watch the five SDKs covered by the script go green: ```powershell pwsh ./build_and_test_all.ps1 @@ -10,10 +10,14 @@ pwsh ./build_and_test_all.ps1 If that passes, your machine is correctly configured. +The Rust SDK uses its own Cargo workflow and is not included in this +convenience script. + ## Prerequisites -All five SDKs (C++, C#, Python, JS/TS, Java) build on **Windows**, **Linux**, and -**macOS**. WinML 2.x hardware acceleration is bundled automatically on Windows. +The five SDKs covered by this script (C++, C#, Python, JS/TS, Java) build on +**Windows**, **Linux**, and **macOS**. WinML 2.x hardware acceleration is +bundled automatically on Windows. ### All platforms diff --git a/sdk_v2/build_and_test_all.ps1 b/sdk_v2/build_and_test_all.ps1 index a10240eb6..8928c20b2 100644 --- a/sdk_v2/build_and_test_all.ps1 +++ b/sdk_v2/build_and_test_all.ps1 @@ -1,9 +1,9 @@ <# .SYNOPSIS - Build and test all sdk_v2 SDKs (C++, C#, Python, JS, Java) in one shot. + Build and test the C++, C#, Python, JS, and Java SDKs in one shot. .DESCRIPTION - The simple developer "build and run all tests" one-shot script for sdk_v2. + The developer "build and run tests" script for the five SDKs listed below. Order: 1. C++ — python build.py (configure + build + test) @@ -271,6 +271,6 @@ if ($failed) { Write-Host "FAILED: $($failed.Sdk -join ', ')" -ForegroundColor Red exit 1 } else { - Write-Host "All SDKs passed." -ForegroundColor Green + Write-Host "Selected SDKs passed." -ForegroundColor Green exit 0 } diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java index 8c48b9948..649ee9f9e 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java @@ -46,10 +46,22 @@ private Transcription start(byte[] wav, PcmFormat format, Consumer NativeApi.outsideCallback(); synchronized (model.owner) { if (handle == null) return; - if (active != null) active.close(); - api.inference.call(NativeApi.InferenceApi.SESSION_RELEASE, handle); - handle = null; - model.owner.sessions.remove(this); + Throwable failure = null; + try { + if (active != null) active.close(); + } catch (RuntimeException | Error e) { + failure = e; + } + try { + api.inference.call(NativeApi.InferenceApi.SESSION_RELEASE, handle); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } finally { + active = null; + handle = null; + model.owner.sessions.remove(this); + } + NativeApi.rethrow(failure); } } } diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java index 941ec6fa6..b873ce2fe 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java @@ -74,31 +74,30 @@ void checkOpen() { NativeApi.outsideCallback(); synchronized (this) { if (handle == null) return; - RuntimeException failure = null; + Throwable failure = null; for (AudioSession session : new ArrayList<>(sessions)) { try { session.close(); - } catch (RuntimeException e) { - if (failure == null) failure = e; - else failure.addSuppressed(e); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); } } try { api.check(api.root.pointer(NativeApi.Root.MANAGER_SHUTDOWN, handle)); - } catch (RuntimeException e) { - if (failure == null) failure = e; - else failure.addSuppressed(e); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } + try { + api.root.call(NativeApi.Root.MANAGER_RELEASE, handle); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); } finally { - try { - api.root.call(NativeApi.Root.MANAGER_RELEASE, handle); - } finally { - handle = null; - synchronized (FoundryLocalManager.class) { - active = false; - } + handle = null; + synchronized (FoundryLocalManager.class) { + active = false; } } - if (failure != null) throw failure; + NativeApi.rethrow(failure); } } } diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java index cd5b38c4c..cee8b5462 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java @@ -117,6 +117,19 @@ static void outsideCallback() { } } + static Throwable preserveFailure(Throwable first, Throwable next) { + if (first == null) return next; + if (first != next) first.addSuppressed(next); + return first; + } + + static void rethrow(Throwable failure) { + if (failure == null) return; + if (failure instanceof RuntimeException runtime) throw runtime; + if (failure instanceof Error error) throw error; + throw new AssertionError(failure); + } + static String target() { String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT); String cpu = switch (arch) { diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java index c6ad4b9a4..4cdb6fec4 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java @@ -74,8 +74,13 @@ public final class Transcription implements AutoCloseable { callback, null)); } catch (RuntimeException | Error e) { - api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); - request = null; + try { + api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + } catch (RuntimeException | Error cleanupFailure) { + e.addSuppressed(cleanupFailure); + } finally { + request = null; + } throw e; } worker = new Thread(this::run, "foundry-java-asr"); @@ -136,8 +141,11 @@ public synchronized void writePcm(byte[] bytes) throws InterruptedException { if (firstInputNanos == 0) firstInputNanos = System.nanoTime(); submittedBytes += bytes.length; } finally { - if (item != null) api.item.call(NativeApi.ItemApi.RELEASE, item); - if (!ownsBuffer) freeBuffer(address); + try { + if (item != null) api.item.call(NativeApi.ItemApi.RELEASE, item); + } finally { + if (!ownsBuffer) freeBuffer(address); + } } } @@ -165,12 +173,22 @@ public synchronized void finishInput() { public synchronized void cancel() { NativeApi.outsideCallback(); if (closed || !completion.cancel()) return; - api.check(api.inference.pointer(NativeApi.InferenceApi.REQUEST_CANCEL, request)); + Throwable failure = null; + try { + api.check(api.inference.pointer(NativeApi.InferenceApi.REQUEST_CANCEL, request)); + } catch (RuntimeException | Error e) { + failure = e; + } if (queue != null && !finished) { - api.item.call(NativeApi.ItemApi.QUEUE_MARK_FINISHED, queue); - finished = true; + try { + api.item.call(NativeApi.ItemApi.QUEUE_MARK_FINISHED, queue); + finished = true; + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } } notifyAll(); + NativeApi.rethrow(failure); } public boolean isDone() { return completion.result.isDone(); } @@ -361,6 +379,7 @@ private synchronized void freeBuffer(long address) { @Override public void close() { NativeApi.outsideCallback(); boolean interrupted = false; + Throwable failure = null; synchronized (this) { while (closing && !closed) { try { wait(); } @@ -370,7 +389,11 @@ private synchronized void freeBuffer(long address) { if (interrupted) Thread.currentThread().interrupt(); return; } - cancel(); + try { + cancel(); + } catch (RuntimeException | Error e) { + failure = e; + } closing = true; } while (worker.isAlive()) { @@ -388,8 +411,14 @@ private synchronized void freeBuffer(long address) { session.handle, null, null)); - } finally { + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } + try { api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } finally { synchronized (this) { request = null; queue = null; @@ -398,7 +427,12 @@ private synchronized void freeBuffer(long address) { notifyAll(); } } - if (!buffers.isEmpty()) throw new IllegalStateException("Native request did not release all PCM buffers"); + if (!buffers.isEmpty()) { + failure = NativeApi.preserveFailure( + failure, + new IllegalStateException("Native request did not release all PCM buffers")); + } + NativeApi.rethrow(failure); } finally { if (interrupted) Thread.currentThread().interrupt(); Reference.reachabilityFence(callback); diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java index 0e4e22415..d87515d32 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java @@ -36,4 +36,13 @@ class NativeContractTest { finally { NativeApi.IN_CALLBACK.remove(); } } + @Test void cleanupPreservesTheFirstFailureAndSuppressesLaterFailures() { + IllegalStateException first = new IllegalStateException("transcription"); + IllegalArgumentException second = new IllegalArgumentException("session"); + assertSame(first, NativeApi.preserveFailure(first, second)); + assertArrayEquals(new Throwable[] {second}, first.getSuppressed()); + assertSame(second, NativeApi.preserveFailure(null, second)); + assertSame(first, assertThrows(IllegalStateException.class, () -> NativeApi.rethrow(first))); + } + } From 7c00101ac72104ea5311ac42760f046898d2c50f Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Sun, 13 Sep 2026 14:39:43 +0800 Subject: [PATCH 04/15] fix(java): harden startup and model lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk_v2/java/README.md | 3 + .../com/microsoft/foundry/local/Catalog.java | 16 +++- .../foundry/local/ModelNotFoundException.java | 16 ++++ .../microsoft/foundry/local/NativeApi.java | 9 +- .../foundry/local/Transcription.java | 90 +++++++++++++++++-- .../foundry/local/NativeAsrTest.java | 6 +- .../foundry/local/NativeContractTest.java | 8 ++ .../foundry/local/TranscriptionStateTest.java | 42 +++++++++ 8 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelNotFoundException.java diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md index e0771cbda..5f382437d 100644 --- a/sdk_v2/java/README.md +++ b/sdk_v2/java/README.md @@ -99,6 +99,9 @@ try (var manager = new FoundryLocalManager(configuration)) { } ``` +`Catalog.getModel` requires an exact `name:version` ID and throws +`ModelNotFoundException` when that valid ID is unavailable. + For a real microphone stream, create one `Transcription`, call `writePcm` for each chunk, then call `finishInput` and `await`. `finishInput` drains queued audio and publishes the final transcript. `cancel` requests native cancellation diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java index d72b059c0..5b3213e10 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java @@ -12,7 +12,11 @@ public final class Catalog { Catalog(FoundryLocalManager owner, Pointer handle) { this.owner = owner; this.handle = handle; } - /** No alias fallback: the returned native identity must equal the requested name:version. */ + /** + * No alias fallback: the returned native identity must equal the requested name:version. + * + * @throws ModelNotFoundException if the exact ID is valid but unavailable + */ public Model getModel(String exactId) { NativeApi.outsideCallback(); if (exactId == null || !exactId.matches("[A-Za-z0-9._-]+:[0-9]+")) { @@ -20,8 +24,9 @@ public Model getModel(String exactId) { } synchronized (owner) { owner.checkOpen(); - Model model = new Model(owner, owner.api.create( - owner.api.catalog, NativeApi.CatalogApi.GET_MODEL_VARIANT, handle, exactId)); + Pointer modelHandle = owner.api.output( + owner.api.catalog, NativeApi.CatalogApi.GET_MODEL_VARIANT, handle, exactId); + Model model = new Model(owner, requireModelHandle(exactId, modelHandle)); if (!model.info().id().equals(exactId)) { throw new IllegalStateException("Catalog returned a different model ID"); } @@ -29,6 +34,11 @@ public Model getModel(String exactId) { } } + static Pointer requireModelHandle(String exactId, Pointer handle) { + if (handle == null) throw new ModelNotFoundException(exactId); + return handle; + } + public List models() { NativeApi.outsideCallback(); synchronized (owner) { diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelNotFoundException.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelNotFoundException.java new file mode 100644 index 000000000..5673dab06 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/ModelNotFoundException.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +package com.microsoft.foundry.local; + +import java.util.NoSuchElementException; + +/** An exact model ID was valid but is not available from the catalog. */ +public final class ModelNotFoundException extends NoSuchElementException { + private final String modelId; + + public ModelNotFoundException(String modelId) { + super("Model not found: " + modelId); + this.modelId = modelId; + } + + public String modelId() { return modelId; } +} diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java index cee8b5462..8331dc94a 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java @@ -246,15 +246,20 @@ void check(Pointer status) { } } - Pointer create(Table table, int slot, Object... args) { + Pointer output(Table table, int slot, Object... args) { PointerByReference output = new PointerByReference(); Object[] all = java.util.Arrays.copyOf(args, args.length + 1); all[args.length] = output; check(table.pointer(slot, all)); - if (output.getValue() == null) throw new IllegalStateException("Native API returned a null handle"); return output.getValue(); } + Pointer create(Table table, int slot, Object... args) { + Pointer output = output(table, slot, args); + if (output == null) throw new IllegalStateException("Native API returned a null handle"); + return output; + } + static final class Table { private final Pointer table; Table(Pointer table) { diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java index 4cdb6fec4..b1a72caea 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java @@ -85,8 +85,79 @@ public final class Transcription implements AutoCloseable { } worker = new Thread(this::run, "foundry-java-asr"); feeder = wav == null ? null : new Thread(() -> feedWav(wav), "foundry-java-asr-input"); - worker.start(); - if (feeder != null) feeder.start(); + startThreads(worker, feeder, this::rollbackStartup); + } + + static void startThreads(Thread worker, Thread feeder, Runnable rollback) { + try { + worker.start(); + if (feeder != null) feeder.start(); + } catch (RuntimeException | Error failure) { + try { + rollback.run(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + private void rollbackStartup() { + boolean interrupted = false; + Throwable failure = null; + synchronized (this) { + closing = true; + try { + cancel(); + } catch (RuntimeException | Error e) { + failure = e; + } + } + while (worker.isAlive()) { + try { + worker.join(); + } catch (InterruptedException e) { + interrupted = true; + } + } + while (feeder != null && feeder.isAlive()) { + try { + feeder.join(); + } catch (InterruptedException e) { + interrupted = true; + } + } + try { + try { + api.check(api.inference.pointer( + NativeApi.InferenceApi.SESSION_SET_STREAMING_CALLBACK, + session.handle, + null, + null)); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } + try { + api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } finally { + request = null; + queue = null; + closed = true; + closing = false; + } + if (!buffers.isEmpty()) { + failure = NativeApi.preserveFailure( + failure, + new IllegalStateException("Native request did not release all PCM buffers")); + } + NativeApi.rethrow(failure); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + Reference.reachabilityFence(callback); + Reference.reachabilityFence(deleter); + } } private void feedWav(byte[] pcm) { @@ -95,17 +166,26 @@ private void feedWav(byte[] pcm) { writePcm(java.util.Arrays.copyOfRange(pcm, offset, Math.min(offset + 3200, pcm.length))); } finishInput(); - } catch (InterruptedException | RuntimeException e) { + } catch (InterruptedException | RuntimeException | Error e) { synchronized (this) { if (!completion.isCancelled() && !closing && !isDone()) { callbackFailure.compareAndSet(null, e); - cancel(); + try { + cancel(); + } catch (RuntimeException | Error cleanupFailure) { + e.addSuppressed(cleanupFailure); + } } } - if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + finishFeederFailure(e); } } + static void finishFeederFailure(Throwable failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + if (failure instanceof Error error) throw error; + } + /** Copies a complete PCM chunk. Applies bounded backpressure (at most 2 seconds queued). */ public synchronized void writePcm(byte[] bytes) throws InterruptedException { NativeApi.outsideCallback(); diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java index c089bfcfd..62edc5deb 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -25,8 +25,10 @@ class NativeAsrTest { try (FoundryLocalManager manager = new FoundryLocalManager(config)) { System.err.println("native-test: manager created"); assertThrows(IllegalStateException.class, () -> new FoundryLocalManager(config)); - assertThrows(IllegalArgumentException.class, () -> manager.catalog().getModel("nemotron")); - borrowed = manager.catalog().getModel(System.getProperty("foundry.test.model", + Catalog catalog = manager.catalog(); + assertThrows(IllegalArgumentException.class, () -> catalog.getModel("nemotron")); + assertThrows(ModelNotFoundException.class, () -> catalog.getModel("missing-java-test-model:999")); + borrowed = catalog.getModel(System.getProperty("foundry.test.model", "nemotron-speech-streaming-en-0.6b-generic-cpu:3")); CancellationToken cancelled = new CancellationToken(); cancelled.cancel(); diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java index d87515d32..1f52b90ea 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java @@ -10,6 +10,14 @@ class NativeContractTest { @TempDir Path temporary; + @Test void missingModelReportsTheRequestedId() { + ModelNotFoundException error = assertThrows( + ModelNotFoundException.class, + () -> Catalog.requireModelHandle("missing-model:9", null)); + assertEquals("missing-model:9", error.modelId()); + assertTrue(error.getMessage().contains("missing-model:9")); + } + @Test void matchesPackaged64BitStructSizes() { assertEquals(16, new NativeApi.CallbackData().size()); assertEquals(72, new NativeApi.AudioData().size()); diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java index 74eb810bc..90d675c8c 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java @@ -5,9 +5,51 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; class TranscriptionStateTest { + @Test void feederErrorsEscapeAfterCleanup() { + LinkageError failure = new LinkageError("JNA callback"); + assertSame(failure, assertThrows(LinkageError.class, () -> + Transcription.finishFeederFailure(failure))); + } + + @Test void secondThreadStartFailureRollsBackTheStartedWorker() throws Exception { + CountDownLatch workerStarted = new CountDownLatch(1); + CountDownLatch stopWorker = new CountDownLatch(1); + Thread worker = new Thread(() -> { + workerStarted.countDown(); + try { + stopWorker.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + LinkageError startupFailure = new LinkageError("feeder startup"); + Thread feeder = new Thread() { + @Override public synchronized void start() { throw startupFailure; } + }; + AtomicBoolean rolledBack = new AtomicBoolean(); + + LinkageError thrown = assertThrows(LinkageError.class, () -> + Transcription.startThreads(worker, feeder, () -> { + stopWorker.countDown(); + try { + worker.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + rolledBack.set(true); + })); + + assertSame(startupFailure, thrown); + assertTrue(workerStarted.await(5, TimeUnit.SECONDS)); + assertTrue(rolledBack.get()); + assertFalse(worker.isAlive()); + } + @Test void absentOrUnfinishedResponseIsNotCancellation() { assertThrows(IllegalStateException.class, () -> Transcription.cancelledResult(false, false, 0)); assertThrows(IllegalStateException.class, () -> Transcription.cancelledResult(false, true, 0)); From 4a626985b5e21d85273ad639c7328cb755174f7e Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Sun, 13 Sep 2026 14:41:27 +0800 Subject: [PATCH 05/15] fix(java): allocate ASR threads before native handles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/java/com/microsoft/foundry/local/Transcription.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java index b1a72caea..07f5c643c 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java @@ -40,6 +40,8 @@ public final class Transcription implements AutoCloseable { this.api = session.api; this.format = format; this.listener = listener; + worker = new Thread(this::run, "foundry-java-asr"); + feeder = wav == null ? null : new Thread(() -> feedWav(wav), "foundry-java-asr-input"); request = api.create(api.inference, NativeApi.InferenceApi.REQUEST_CREATE); try { Pointer audio = api.create(api.item, NativeApi.ItemApi.CREATE, 30); @@ -83,8 +85,6 @@ public final class Transcription implements AutoCloseable { } throw e; } - worker = new Thread(this::run, "foundry-java-asr"); - feeder = wav == null ? null : new Thread(() -> feedWav(wav), "foundry-java-asr-input"); startThreads(worker, feeder, this::rollbackStartup); } From 52ecf7b36571bffbf0c2e15cb8f505acb77f70c8 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Mon, 14 Sep 2026 09:13:25 +0800 Subject: [PATCH 06/15] style(java): enforce line length limit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/microsoft/foundry/local/AudioSession.java | 4 +++- .../com/microsoft/foundry/local/NativeAsrTest.java | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java index 649ee9f9e..e21b6c8fd 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java @@ -36,7 +36,9 @@ private Transcription start(byte[] wav, PcmFormat format, Consumer synchronized (model.owner) { model.owner.checkOpen(); if (handle == null) throw new IllegalStateException("Session is closed"); - if (active != null && !active.isClosed()) throw new IllegalStateException("Close the previous transcription"); + if (active != null && !active.isClosed()) { + throw new IllegalStateException("Close the previous transcription"); + } active = new Transcription(this, wav, format, Objects.requireNonNull(listener)); return active; } diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java index 62edc5deb..d73d7d618 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -18,7 +18,9 @@ class NativeAsrTest { String runtime = setting("foundry.test.runtime", "FOUNDRY_LOCAL_NATIVE_BIN_DIR"); String cache = setting("foundry.test.cache", "FOUNDRY_TEST_DATA_DIR"); String wav = setting("foundry.test.wav", "FOUNDRY_TEST_WAV"); - assumeTrue(runtime != null && cache != null && wav != null, "Configure the native test runtime, cache, and WAV"); + assumeTrue( + runtime != null && cache != null && wav != null, + "Configure the native test runtime, cache, and WAV"); Configuration config = new Configuration("java-asr-test", Path.of(runtime), Path.of(cache), temporary); Model borrowed; AtomicInteger callbacks = new AtomicInteger(); @@ -49,7 +51,8 @@ class NativeAsrTest { for (int iteration = 0; iteration < 2; iteration++) { try (AudioSession session = borrowed.createAudioSession()) { assertThrows(IllegalStateException.class, borrowed::unload); - try (Transcription run = session.streamPcm(PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { + try (Transcription run = session.streamPcm( + PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { for (int offset = 0; offset < pcm.length; offset += 3200) { run.writePcm(Arrays.copyOfRange(pcm, offset, Math.min(offset + 3200, pcm.length))); } @@ -64,7 +67,8 @@ class NativeAsrTest { int count = callbacks.get(); Thread.sleep(100); assertEquals(count, callbacks.get(), "No callback may outlive close"); - try (Transcription run = session.streamPcm(PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { + try (Transcription run = session.streamPcm( + PcmFormat.SPEECH, event -> callbacks.incrementAndGet())) { run.writePcm(Arrays.copyOf(pcm, Math.min(3200, pcm.length))); run.cancel(); assertTrue(run.await(Duration.ofSeconds(30)).cancelled()); From c9b30f06a92ad4e483818fc4f05d25d58824a1cb Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Wed, 16 Sep 2026 22:49:13 +0800 Subject: [PATCH 07/15] ci(java): build immutable preview SDK artifact Build and test the Java 17 SDK from the team preview branch, derive a fixed version from the source tag, reject shaded JNA or Gson classes, and upload the exact first-party JAR with checksum evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/java-sdk-preview.yml | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/java-sdk-preview.yml diff --git a/.github/workflows/java-sdk-preview.yml b/.github/workflows/java-sdk-preview.yml new file mode 100644 index 000000000..ab7267727 --- /dev/null +++ b/.github/workflows/java-sdk-preview.yml @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +name: Java SDK preview + +on: + push: + branches: + - java/sdk-preview + tags: + - java-sdk-v0.1.0-preview.* + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build Java 17 SDK + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: sdk_v2/java/pom.xml + + - name: Resolve immutable preview version + id: version + shell: pwsh + run: | + $shortSha = $env:GITHUB_SHA.Substring(0, 12) + if ($env:GITHUB_REF_TYPE -eq 'tag') { + $expectedTag = "java-sdk-v0.1.0-preview.1-$shortSha" + if ($env:GITHUB_REF_NAME -ne $expectedTag) { + throw "Expected tag $expectedTag for source commit $env:GITHUB_SHA." + } + $version = $env:GITHUB_REF_NAME.Substring('java-sdk-v'.Length) + } else { + $version = "0.1.0-preview.0-$shortSha" + } + "version=$version" >> $env:GITHUB_OUTPUT + + - name: Build and test SDK + shell: pwsh + run: | + mvn --batch-mode --no-transfer-progress ` + -f sdk_v2/java/pom.xml ` + "-Drevision=${{ steps.version.outputs.version }}" ` + clean verify + if ($LASTEXITCODE -ne 0) { + throw "Maven exited with $LASTEXITCODE." + } + + - name: Verify preview JAR + id: artifact + shell: pwsh + run: | + $version = '${{ steps.version.outputs.version }}' + $target = Join-Path $env:GITHUB_WORKSPACE 'sdk_v2/java/target' + $jar = Join-Path $target "foundry-local-sdk-$version.jar" + if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { + throw "Missing expected preview JAR: $jar" + } + + $mainJars = @(Get-ChildItem -LiteralPath $target -Filter '*.jar' | + Where-Object { $_.Name -notlike '*-sources.jar' }) + if ($mainJars.Count -ne 1 -or $mainJars[0].FullName -ne $jar) { + throw "The build must produce exactly one first-party SDK JAR." + } + + $entries = @(& jar tf $jar) + if ($LASTEXITCODE -ne 0) { + throw "Unable to inspect $jar." + } + if ($entries | Where-Object { + $_ -like 'com/sun/jna/*' -or $_ -like 'com/google/gson/*' + }) { + throw "The preview SDK JAR must not embed JNA or Gson classes." + } + + $file = Get-Item -LiteralPath $jar + $sha256 = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant() + "jar=$jar" >> $env:GITHUB_OUTPUT + "filename=$($file.Name)" >> $env:GITHUB_OUTPUT + "bytes=$($file.Length)" >> $env:GITHUB_OUTPUT + "sha256=$sha256" >> $env:GITHUB_OUTPUT + + @" + ### Java SDK preview + + - Version: ``$version`` + - File: ``$($file.Name)`` + - Bytes: ``$($file.Length)`` + - SHA-256: ``$sha256`` + - Source: ``$env:GITHUB_SHA`` + - JDK: ``$(& java -version 2>&1 | Select-Object -First 1)`` + - Build: ``mvn -f sdk_v2/java/pom.xml -Drevision=$version clean verify`` + "@ >> $env:GITHUB_STEP_SUMMARY + + - name: Upload preview SDK JAR + uses: actions/upload-artifact@v4 + with: + name: foundry-local-sdk-${{ steps.version.outputs.version }} + path: ${{ steps.artifact.outputs.jar }} + if-no-files-found: error + retention-days: 90 From 1c7c2a6bc7da7e57ae6801525006393df34a42f7 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Wed, 16 Sep 2026 22:50:52 +0800 Subject: [PATCH 08/15] ci(java): use current GitHub Actions runtimes Move checkout, Java setup, and artifact upload to their current Node 24-based major versions so preview builds do not rely on deprecated action runtimes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/java-sdk-preview.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/java-sdk-preview.yml b/.github/workflows/java-sdk-preview.yml index ab7267727..860f49270 100644 --- a/.github/workflows/java-sdk-preview.yml +++ b/.github/workflows/java-sdk-preview.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Java 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: "17" @@ -104,7 +104,7 @@ jobs: "@ >> $env:GITHUB_STEP_SUMMARY - name: Upload preview SDK JAR - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: foundry-local-sdk-${{ steps.version.outputs.version }} path: ${{ steps.artifact.outputs.jar }} From 25fb11f3ff3e6bf98bca05b0fcd8974e4d4709de Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 17 Sep 2026 14:01:21 +0800 Subject: [PATCH 09/15] ci(java): bootstrap Maven on Azure agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/templates/stages-java.yml | 11 ++++- .../templates/steps-bootstrap-maven.yml | 40 +++++++++++++++++++ .pipelines/templates/steps-build-java.yml | 2 + 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .pipelines/templates/steps-bootstrap-maven.yml diff --git a/.pipelines/templates/stages-java.yml b/.pipelines/templates/stages-java.yml index 3d8488aa6..9d39c7756 100644 --- a/.pipelines/templates/stages-java.yml +++ b/.pipelines/templates/stages-java.yml @@ -211,6 +211,7 @@ stages: throw 'The shared SDK version is empty.' } Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" + - template: steps-bootstrap-maven.yml - task: Maven@4 displayName: 'Maven clean verify' inputs: @@ -239,8 +240,14 @@ stages: New-Item -ItemType Directory -Force -Path $output | Out-Null $artifacts = @( - @{ Source = Join-Path $target "foundry-local-sdk-$version.jar"; Name = "foundry-local-sdk-$version.jar" }, - @{ Source = Join-Path $target "foundry-local-sdk-$version-sources.jar"; Name = "foundry-local-sdk-$version-sources.jar" }, + @{ + Source = Join-Path $target "foundry-local-sdk-$version.jar" + Name = "foundry-local-sdk-$version.jar" + }, + @{ + Source = Join-Path $target "foundry-local-sdk-$version-sources.jar" + Name = "foundry-local-sdk-$version-sources.jar" + }, @{ Source = Join-Path $javaRoot '.flattened-pom.xml'; Name = "foundry-local-sdk-$version.pom" } ) foreach ($artifact in $artifacts) { diff --git a/.pipelines/templates/steps-bootstrap-maven.yml b/.pipelines/templates/steps-bootstrap-maven.yml new file mode 100644 index 000000000..0d77771a1 --- /dev/null +++ b/.pipelines/templates/steps-bootstrap-maven.yml @@ -0,0 +1,40 @@ +# Install a pinned Maven version for self-hosted agents that do not provide it. + +steps: +- task: PowerShell@2 + displayName: 'Bootstrap Maven 3.9.9' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + + $version = '3.9.9' + $tools = Join-Path '$(Agent.TempDirectory)' 'foundry-java-tools' + $archive = Join-Path $tools "apache-maven-$version.zip" + $mavenHome = Join-Path $tools "apache-maven-$version" + $mavenCommand = if ($IsWindows) { 'bin/mvn.cmd' } else { 'bin/mvn' } + $maven = Join-Path $mavenHome $mavenCommand + + if (-not (Test-Path -LiteralPath $maven -PathType Leaf)) { + New-Item -ItemType Directory -Force -Path $tools | Out-Null + $uri = "https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/$version/" + + "apache-maven-$version-bin.zip" + Invoke-WebRequest $uri -OutFile $archive + + $expected = '8beac8d11ef208f1e2a8df0682b9448a9a363d2ad13ca74af43705549e72e74' + + 'c9378823bf689287801cbbfc2f6ea9596201d19ccacfdfb682ee8a2ff4c4418ba' + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA512).Hash -ne $expected) { + throw 'Maven archive checksum mismatch.' + } + Expand-Archive -LiteralPath $archive -DestinationPath $tools -Force + } + + if (-not $IsWindows) { + & chmod u+x $maven + if ($LASTEXITCODE -ne 0) { + throw "Could not make Maven executable: $maven" + } + } + + Write-Host "##vso[task.prependpath]$(Join-Path $mavenHome 'bin')" diff --git a/.pipelines/templates/steps-build-java.yml b/.pipelines/templates/steps-build-java.yml index 25828bc93..0c520a38e 100644 --- a/.pipelines/templates/steps-build-java.yml +++ b/.pipelines/templates/steps-build-java.yml @@ -65,6 +65,8 @@ steps: } Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" +- template: steps-bootstrap-maven.yml + - task: Maven@4 displayName: 'Maven verify + native ASR (${{ parameters.rid }})' inputs: From f6423ca03ca1fe2b3b9bae80f744e4acce5bdce9 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 17 Sep 2026 15:26:19 +0800 Subject: [PATCH 10/15] ci(java): drop fork-only preview workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/java-sdk-preview.yml | 112 ------------------------- 1 file changed, 112 deletions(-) delete mode 100644 .github/workflows/java-sdk-preview.yml diff --git a/.github/workflows/java-sdk-preview.yml b/.github/workflows/java-sdk-preview.yml deleted file mode 100644 index 860f49270..000000000 --- a/.github/workflows/java-sdk-preview.yml +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (c) Microsoft Corporation. Licensed under the MIT License. -name: Java SDK preview - -on: - push: - branches: - - java/sdk-preview - tags: - - java-sdk-v0.1.0-preview.* - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - name: Build Java 17 SDK - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Check out source - uses: actions/checkout@v7 - - - name: Set up Java 17 - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: "17" - cache: maven - cache-dependency-path: sdk_v2/java/pom.xml - - - name: Resolve immutable preview version - id: version - shell: pwsh - run: | - $shortSha = $env:GITHUB_SHA.Substring(0, 12) - if ($env:GITHUB_REF_TYPE -eq 'tag') { - $expectedTag = "java-sdk-v0.1.0-preview.1-$shortSha" - if ($env:GITHUB_REF_NAME -ne $expectedTag) { - throw "Expected tag $expectedTag for source commit $env:GITHUB_SHA." - } - $version = $env:GITHUB_REF_NAME.Substring('java-sdk-v'.Length) - } else { - $version = "0.1.0-preview.0-$shortSha" - } - "version=$version" >> $env:GITHUB_OUTPUT - - - name: Build and test SDK - shell: pwsh - run: | - mvn --batch-mode --no-transfer-progress ` - -f sdk_v2/java/pom.xml ` - "-Drevision=${{ steps.version.outputs.version }}" ` - clean verify - if ($LASTEXITCODE -ne 0) { - throw "Maven exited with $LASTEXITCODE." - } - - - name: Verify preview JAR - id: artifact - shell: pwsh - run: | - $version = '${{ steps.version.outputs.version }}' - $target = Join-Path $env:GITHUB_WORKSPACE 'sdk_v2/java/target' - $jar = Join-Path $target "foundry-local-sdk-$version.jar" - if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { - throw "Missing expected preview JAR: $jar" - } - - $mainJars = @(Get-ChildItem -LiteralPath $target -Filter '*.jar' | - Where-Object { $_.Name -notlike '*-sources.jar' }) - if ($mainJars.Count -ne 1 -or $mainJars[0].FullName -ne $jar) { - throw "The build must produce exactly one first-party SDK JAR." - } - - $entries = @(& jar tf $jar) - if ($LASTEXITCODE -ne 0) { - throw "Unable to inspect $jar." - } - if ($entries | Where-Object { - $_ -like 'com/sun/jna/*' -or $_ -like 'com/google/gson/*' - }) { - throw "The preview SDK JAR must not embed JNA or Gson classes." - } - - $file = Get-Item -LiteralPath $jar - $sha256 = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant() - "jar=$jar" >> $env:GITHUB_OUTPUT - "filename=$($file.Name)" >> $env:GITHUB_OUTPUT - "bytes=$($file.Length)" >> $env:GITHUB_OUTPUT - "sha256=$sha256" >> $env:GITHUB_OUTPUT - - @" - ### Java SDK preview - - - Version: ``$version`` - - File: ``$($file.Name)`` - - Bytes: ``$($file.Length)`` - - SHA-256: ``$sha256`` - - Source: ``$env:GITHUB_SHA`` - - JDK: ``$(& java -version 2>&1 | Select-Object -First 1)`` - - Build: ``mvn -f sdk_v2/java/pom.xml -Drevision=$version clean verify`` - "@ >> $env:GITHUB_STEP_SUMMARY - - - name: Upload preview SDK JAR - uses: actions/upload-artifact@v7 - with: - name: foundry-local-sdk-${{ steps.version.outputs.version }} - path: ${{ steps.artifact.outputs.jar }} - if-no-files-found: error - retention-days: 90 From 58270514a6f501a4ab52df95bea48aca5d3abb1b Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 17 Sep 2026 15:44:34 +0800 Subject: [PATCH 11/15] fix(java): align native contracts and build outputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk_v2/java/.gitignore | 2 +- sdk_v2/java/build.ps1 | 32 +++++++++++++++---- .../com/microsoft/foundry/local/Catalog.java | 18 +++++++++-- .../com/microsoft/foundry/local/Model.java | 1 - .../foundry/local/NativeAsrTest.java | 5 +++ .../foundry/local/NativeContractTest.java | 10 ++++++ 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/sdk_v2/java/.gitignore b/sdk_v2/java/.gitignore index 23f9f6684..58eb6de8c 100644 --- a/sdk_v2/java/.gitignore +++ b/sdk_v2/java/.gitignore @@ -1,3 +1,3 @@ /target/ -/.mvn-local/ +/build/ /.flattened-pom.xml diff --git a/sdk_v2/java/build.ps1 b/sdk_v2/java/build.ps1 index 1ab121b5c..c857f662c 100644 --- a/sdk_v2/java/build.ps1 +++ b/sdk_v2/java/build.ps1 @@ -2,23 +2,41 @@ [CmdletBinding()] param([switch]$BootstrapMaven, [string[]]$MavenArguments = @('package')) $ErrorActionPreference = 'Stop' -$mavenHome = Join-Path $PSScriptRoot '.mvn-local' +$mavenHome = Join-Path $PSScriptRoot 'build\maven' +$mavenTools = Join-Path $mavenHome 'tools' +$mavenDistribution = Join-Path $mavenTools 'apache-maven-3.9.9' +$mavenExecutable = if ($IsWindows) { 'mvn.cmd' } else { 'mvn' } +$bootstrappedMaven = Join-Path (Join-Path $mavenDistribution 'bin') $mavenExecutable $maven = Get-Command mvn -ErrorAction SilentlyContinue if ($maven) { $executable = $maven.Source } else { - $executable = Join-Path $mavenHome 'tools\apache-maven-3.9.9\bin\mvn.cmd' + $executable = $bootstrappedMaven if (!(Test-Path -LiteralPath $executable)) { if (!$BootstrapMaven) { throw 'Maven is missing. Install Maven or explicitly pass -BootstrapMaven.' } $archive = Join-Path $mavenHome 'downloads\maven.zip' - New-Item -ItemType Directory -Force (Split-Path $archive), (Join-Path $mavenHome 'tools') | Out-Null + New-Item -ItemType Directory -Force (Split-Path $archive), $mavenTools | Out-Null if (!(Test-Path -LiteralPath $archive)) { - Invoke-WebRequest 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip' -OutFile $archive + $uri = 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/' + + 'apache-maven-3.9.9-bin.zip' + Invoke-WebRequest $uri -OutFile $archive } - $expected = '8beac8d11ef208f1e2a8df0682b9448a9a363d2ad13ca74af43705549e72e74c9378823bf689287801cbbfc2f6ea9596201d19ccacfdfb682ee8a2ff4c4418ba' + $expected = '8beac8d11ef208f1e2a8df0682b9448a9a363d2ad13ca74af43705549e72e74' + + 'c9378823bf689287801cbbfc2f6ea9596201d19ccacfdfb682ee8a2ff4c4418ba' if ((Get-FileHash $archive -Algorithm SHA512).Hash -ne $expected) { throw 'Maven archive checksum mismatch' } - Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $mavenHome 'tools') -Force + Expand-Archive -LiteralPath $archive -DestinationPath $mavenTools -Force + } + if (!$IsWindows) { + & chmod u+x $executable + if ($LASTEXITCODE -ne 0) { throw "Could not make Maven executable: $executable" } } } -& $executable -f (Join-Path $PSScriptRoot 'pom.xml') "-Dmaven.repo.local=$(Join-Path $mavenHome 'repository')" --batch-mode --no-transfer-progress @MavenArguments +$arguments = @( + '-f' + (Join-Path $PSScriptRoot 'pom.xml') + "-Dmaven.repo.local=$(Join-Path $mavenHome 'repository')" + '--batch-mode' + '--no-transfer-progress' +) + $MavenArguments +& $executable @arguments if ($LASTEXITCODE -ne 0) { throw "Maven exited with $LASTEXITCODE" } diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java index 5b3213e10..ba45be937 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java @@ -4,9 +4,12 @@ import com.sun.jna.Pointer; import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; /** Borrowed from a manager. Queries may fetch public catalog metadata, never model weights. */ public final class Catalog { + private static final Pattern EXACT_MODEL_ID = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*:(0|[1-9][0-9]*)"); private final FoundryLocalManager owner; private final Pointer handle; @@ -19,9 +22,7 @@ public final class Catalog { */ public Model getModel(String exactId) { NativeApi.outsideCallback(); - if (exactId == null || !exactId.matches("[A-Za-z0-9._-]+:[0-9]+")) { - throw new IllegalArgumentException("An exact model ID in name:version form is required"); - } + validateExactId(exactId); synchronized (owner) { owner.checkOpen(); Pointer modelHandle = owner.api.output( @@ -34,6 +35,17 @@ public Model getModel(String exactId) { } } + private static void validateExactId(String exactId) { + if (exactId == null || !EXACT_MODEL_ID.matcher(exactId).matches()) { + throw new IllegalArgumentException("A canonical model ID in name:version form is required"); + } + try { + Integer.parseInt(exactId.substring(exactId.indexOf(':') + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("The model ID version is out of range", e); + } + } + static Pointer requireModelHandle(String exactId, Pointer handle) { if (handle == null) throw new ModelNotFoundException(exactId); return handle; diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java index 3f93b3b09..7b4bc2e0c 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java @@ -91,7 +91,6 @@ public void download(CancellationToken cancellation, DoubleConsumer progress) { throw new IllegalStateException("Download progress callback failed", failure.get()); } owner.api.check(status); - if (cancellation.isCancelled()) throw new FoundryLocalException(5, "Download cancelled"); } } diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java index d73d7d618..54b8ca1d2 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -38,6 +38,11 @@ class NativeAsrTest { () -> borrowed.download(cancelled, ignored -> fail("Must not invoke progress"))); assertEquals(5, download.code()); assertTrue(borrowed.isCached(), "Explicitly prepare the model first"); + CancellationToken completedDownload = new CancellationToken(); + borrowed.download(completedDownload, value -> { + if (value == 100.0) completedDownload.cancel(); + }); + assertTrue(completedDownload.isCancelled(), "Cached download must report completion"); borrowed.load(); System.err.println("native-test: model loaded"); byte[] pcm = WavAudio.read(Path.of(wav)).pcm(); diff --git a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java index 1f52b90ea..a32047e19 100644 --- a/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.java @@ -18,6 +18,16 @@ class NativeContractTest { assertTrue(error.getMessage().contains("missing-model:9")); } + @Test void modelLookupRejectsNonCanonicalIdsBeforeQueryingNativeCatalog() { + Catalog catalog = new Catalog(null, null); + for (String invalidId : new String[] { + "", "missing-version", ":1", ".name:1", "model:", "model:1:2", "model:-1", + "model:+1", "model:01", "model:2147483648", "model/path:1", "model name:1" + }) { + assertThrows(IllegalArgumentException.class, () -> catalog.getModel(invalidId), invalidId); + } + } + @Test void matchesPackaged64BitStructSizes() { assertEquals(16, new NativeApi.CallbackData().size()); assertEquals(72, new NativeApi.AudioData().size()); From f13c223e89e1b84fff72a44de92ef7dd789c177b Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 17 Sep 2026 18:07:07 +0800 Subject: [PATCH 12/15] fix(java): pin CI Maven and document streaming scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/templates/stages-java.yml | 3 ++- .pipelines/templates/steps-bootstrap-maven.yml | 1 + .pipelines/templates/steps-build-java.yml | 3 ++- sdk_v2/java/README.md | 17 +++++++++++++++++ .../microsoft/foundry/local/AudioSession.java | 5 +++++ .../java/com/microsoft/foundry/local/Model.java | 7 +++++++ 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.pipelines/templates/stages-java.yml b/.pipelines/templates/stages-java.yml index 9d39c7756..9832f971b 100644 --- a/.pipelines/templates/stages-java.yml +++ b/.pipelines/templates/stages-java.yml @@ -224,7 +224,8 @@ stages: javaHomeOption: JDKVersion jdkVersionOption: '1.17' jdkArchitectureOption: 'x64' - mavenVersionOption: Default + mavenVersionOption: Path + mavenDirectory: '$(foundryMavenHome)' - task: PowerShell@2 displayName: 'Stage and validate Maven bundle' inputs: diff --git a/.pipelines/templates/steps-bootstrap-maven.yml b/.pipelines/templates/steps-bootstrap-maven.yml index 0d77771a1..de06c9647 100644 --- a/.pipelines/templates/steps-bootstrap-maven.yml +++ b/.pipelines/templates/steps-bootstrap-maven.yml @@ -37,4 +37,5 @@ steps: } } + Write-Host "##vso[task.setvariable variable=foundryMavenHome]$mavenHome" Write-Host "##vso[task.prependpath]$(Join-Path $mavenHome 'bin')" diff --git a/.pipelines/templates/steps-build-java.yml b/.pipelines/templates/steps-build-java.yml index 0c520a38e..6ea0f0c9f 100644 --- a/.pipelines/templates/steps-build-java.yml +++ b/.pipelines/templates/steps-build-java.yml @@ -79,7 +79,8 @@ steps: javaHomeOption: JDKVersion jdkVersionOption: '1.17' jdkArchitectureOption: '${{ parameters.jdkArchitecture }}' - mavenVersionOption: Default + mavenVersionOption: Path + mavenDirectory: '$(foundryMavenHome)' env: FOUNDRY_LOCAL_NATIVE_BIN_DIR: $(flNativeDir) FOUNDRY_TEST_DATA_DIR: '${{ parameters.testDataSharedDir }}' diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md index 5f382437d..ebe02e35f 100644 --- a/sdk_v2/java/README.md +++ b/sdk_v2/java/README.md @@ -5,6 +5,9 @@ Foundry Local C API. The SDK uses JNA, keeps the native runtime and model weights outside the JAR, and exposes deterministic `AutoCloseable` lifetimes. This package is a preview and is not published to Maven Central yet. +Its first phase intentionally covers streaming ASR rather than the full +cross-language SDK surface. Generic Session/Request/Response/Item APIs, Chat, +and native file/URI transcription are future extension points. ## Build @@ -58,6 +61,11 @@ The older JBR 21.0.8 and 21.0.9 builds tested with Runtime 2.0.1 on Windows load an older C runtime first and cannot initialize ONNX Runtime. Use a compatible JBR/native-runtime combination instead of replacing IDE or system DLLs. +The manager defaults native logging to Fatal and disables nonessential +telemetry. These safety defaults are not configurable through the initial Java +API. Runtime diagnostics such as `ORTGENAI_ORT_VERBOSE_LOGGING` can still +control the underlying ONNX Runtime GenAI logging where supported. + ## Streaming ASR The model is loaded once and reused across successive dictation requests. @@ -102,6 +110,15 @@ try (var manager = new FoundryLocalManager(configuration)) { `Catalog.getModel` requires an exact `name:version` ID and throws `ModelNotFoundException` when that valid ID is unavailable. +This preview uses Foundry Local's native streaming-audio processor. The ASR +task in catalog metadata is necessary but does not promise that every +file-oriented ASR model supports this path. Use a model whose native runtime +supports streaming audio; the example and integration test use the Nemotron +streaming model. `transcribeWav` decodes a supported PCM WAV file in Java and +feeds its samples through the same streaming path. It does not invoke native +file/URI transcription, and this preview does not promise file-input parity +for models such as Whisper. + For a real microphone stream, create one `Transcription`, call `writePcm` for each chunk, then call `finishInput` and `await`. `finishInput` drains queued audio and publishes the final transcript. `cancel` requests native cancellation diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java index e21b6c8fd..5b728f22c 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java @@ -21,12 +21,17 @@ public final class AudioSession implements AutoCloseable { model.owner.sessions.add(this); } + /** + * Decodes a PCM WAV file and submits its samples through the native streaming-audio path. + * This method does not use the native file/URI transcription path. + */ public Transcription transcribeWav(Path wav, Consumer listener) throws IOException { NativeApi.outsideCallback(); WavAudio audio = WavAudio.read(wav); return start(audio.pcm(), audio.format(), listener); } + /** Starts a native streaming-audio transcription for PCM chunks supplied by the caller. */ public Transcription streamPcm(PcmFormat format, Consumer listener) { return start(null, Objects.requireNonNull(format), listener); } diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java index 7b4bc2e0c..5c834a791 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java @@ -114,6 +114,13 @@ public void unload() { } } + /** + * Creates a session for the preview streaming-ASR API. + * + *

An ASR task is required, but not every file-oriented ASR model necessarily supports + * the native streaming-audio processor used by this API. Unsupported models fail through + * the native status returned when transcription starts. + */ public AudioSession createAudioSession() { NativeApi.outsideCallback(); synchronized (owner) { From dee3dd7482d4ce1884df338a9c4453b11e68accc Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 17 Sep 2026 22:04:52 +0800 Subject: [PATCH 13/15] docs(java): fix Maven paths and runtime lifetime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk_v2/java/README.md | 19 +++++++++++++------ .../foundry/local/Configuration.java | 7 ++++++- .../foundry/local/FoundryLocalManager.java | 7 ++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md index ebe02e35f..475138b90 100644 --- a/sdk_v2/java/README.md +++ b/sdk_v2/java/README.md @@ -17,7 +17,7 @@ Prerequisites: - Maven 3.9 or newer ```powershell -mvn -f sdk_v2\java\pom.xml package +mvn -f sdk_v2/java/pom.xml package ``` The build produces: @@ -28,7 +28,7 @@ The build produces: Override `revision` when producing an immutable release: ```powershell -mvn -f sdk_v2\java\pom.xml -Drevision=0.1.0 package +mvn -f sdk_v2/java/pom.xml -Drevision=0.1.0 package ``` JNA remains a normal Maven dependency. The SDK JAR does not contain JNA native @@ -51,6 +51,11 @@ When ONNX Runtime or ONNX Runtime GenAI libraries are present in the same directory, the SDK preloads them before Foundry Local. Otherwise, the platform loader must be able to resolve those dependencies. +The first successfully resolved runtime directory remains loaded for the JVM +lifetime. After closing a manager, another manager can be created only with +the same resolved runtime directory. Start a new JVM to use a different native +runtime directory. + The platform-independent JAR can run wherever the matching Foundry Local native runtime is available. Current upstream native artifacts target Windows x64 and ARM64, Linux x64 and ARM64, and macOS ARM64. Use a 64-bit JVM with the same @@ -131,7 +136,8 @@ seconds are queued. ## Ownership and threading - `FoundryLocalManager` owns catalogs, models, and sessions. Only one manager - may be open at a time, but a manager can be closed and recreated later. + may be open at a time. After close, a manager can be recreated in the same + JVM only with the same resolved runtime directory. - A loaded `Model` can create multiple successive `AudioSession` instances without reloading the model. - Closing a manager closes outstanding sessions and transcriptions before @@ -144,13 +150,13 @@ seconds are queued. ## Tests ```powershell -mvn -f sdk_v2\java\pom.xml test +mvn -f sdk_v2/java/pom.xml test ``` The native ASR integration test is opt-in and never downloads a model: ```powershell -mvn -f sdk_v2\java\pom.xml test ` +mvn -f sdk_v2/java/pom.xml test ` -Dtest=NativeAsrTest ` -Dfoundry.test.runtime= ` -Dfoundry.test.cache= ` @@ -162,4 +168,5 @@ CI may provide the equivalent `FOUNDRY_LOCAL_NATIVE_BIN_DIR`, The test reuses one loaded model for repeated PCM requests, covers final results, cancellation, callback failures, deterministic cleanup, manager -recreation, and verifies that no callback or worker survives close. +recreation with the same runtime directory, and verifies that no callback or +worker survives close. diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java index 2fff78144..c7478f77d 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java @@ -4,7 +4,12 @@ import java.nio.file.Path; import java.util.Objects; -/** Explicit locations only. Construction never loads native code or downloads assets. */ +/** + * Explicit locations only. Construction never loads native code or downloads assets. + * + *

The first runtime directory loaded is retained for the JVM lifetime. Recreated managers + * must use the same resolved runtime directory. + */ public record Configuration(String appName, Path runtimeDirectory, Path modelCacheDirectory, Path appDataDirectory) { public Configuration { Objects.requireNonNull(appName, "appName"); diff --git a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java index b873ce2fe..ef5bc8565 100644 --- a/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java @@ -7,7 +7,12 @@ import java.util.HashSet; import java.util.Set; -/** Owns the native singleton and every session. Catalogs/models are borrowed views. */ +/** + * Owns the native singleton and every session. Catalogs/models are borrowed views. + * + *

Only one manager may be open at a time. After close, another manager can be created in + * the same JVM only with the same resolved runtime directory used by the first manager. + */ public final class FoundryLocalManager implements AutoCloseable { private static boolean active; final NativeApi api; From 271fc71498666c81acaba94d548dbadca29bf0c0 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Fri, 18 Sep 2026 03:25:04 +0800 Subject: [PATCH 14/15] fix(java): resolve preinstalled JDKs by path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/templates/stages-java.yml | 8 +- .pipelines/templates/steps-build-java.yml | 9 +- .pipelines/templates/steps-resolve-java.yml | 123 ++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 .pipelines/templates/steps-resolve-java.yml diff --git a/.pipelines/templates/stages-java.yml b/.pipelines/templates/stages-java.yml index 9832f971b..def8680ee 100644 --- a/.pipelines/templates/stages-java.yml +++ b/.pipelines/templates/stages-java.yml @@ -211,6 +211,9 @@ stages: throw 'The shared SDK version is empty.' } Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" + - template: steps-resolve-java.yml + parameters: + jdkArchitecture: 'x64' - template: steps-bootstrap-maven.yml - task: Maven@4 displayName: 'Maven clean verify' @@ -221,9 +224,8 @@ stages: publishJUnitResults: true testResultsFiles: 'sdk_v2/java/target/surefire-reports/TEST-*.xml' testRunTitle: 'Java SDK package' - javaHomeOption: JDKVersion - jdkVersionOption: '1.17' - jdkArchitectureOption: 'x64' + javaHomeOption: Path + jdkDirectory: '$(foundryJavaHome)' mavenVersionOption: Path mavenDirectory: '$(foundryMavenHome)' - task: PowerShell@2 diff --git a/.pipelines/templates/steps-build-java.yml b/.pipelines/templates/steps-build-java.yml index 6ea0f0c9f..af8759c1c 100644 --- a/.pipelines/templates/steps-build-java.yml +++ b/.pipelines/templates/steps-build-java.yml @@ -65,6 +65,10 @@ steps: } Write-Host "##vso[task.setvariable variable=javaSdkVersion]$version" +- template: steps-resolve-java.yml + parameters: + jdkArchitecture: '${{ parameters.jdkArchitecture }}' + - template: steps-bootstrap-maven.yml - task: Maven@4 @@ -76,9 +80,8 @@ steps: publishJUnitResults: true testResultsFiles: 'sdk_v2/java/target/surefire-reports/TEST-*.xml' testRunTitle: 'Java SDK ${{ parameters.rid }}' - javaHomeOption: JDKVersion - jdkVersionOption: '1.17' - jdkArchitectureOption: '${{ parameters.jdkArchitecture }}' + javaHomeOption: Path + jdkDirectory: '$(foundryJavaHome)' mavenVersionOption: Path mavenDirectory: '$(foundryMavenHome)' env: diff --git a/.pipelines/templates/steps-resolve-java.yml b/.pipelines/templates/steps-resolve-java.yml new file mode 100644 index 000000000..8919dc1f7 --- /dev/null +++ b/.pipelines/templates/steps-resolve-java.yml @@ -0,0 +1,123 @@ +# Resolve a pre-installed JDK by its actual version and architecture instead of +# relying only on Azure agent JAVA_HOME__ capabilities. + +parameters: +- name: jdkArchitecture + type: string + # x64 | arm64 + +steps: +- task: PowerShell@2 + displayName: 'Resolve JDK 17 (${{ parameters.jdkArchitecture }})' + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + + $requiredMajor = 17 + $requiredArchitecture = '${{ parameters.jdkArchitecture }}' + $candidateHomes = [Collections.Generic.List[string]]::new() + + function Add-Candidate([string] $path) { + if (-not [string]::IsNullOrWhiteSpace($path)) { + $candidateHomes.Add($path.Trim().Trim('"')) + } + } + + $registeredArchitectures = if ($requiredArchitecture -eq 'arm64') { + @('ARM64', 'AARCH64') + } else { + @('X64') + } + foreach ($architecture in $registeredArchitectures) { + Add-Candidate ([Environment]::GetEnvironmentVariable( + "JAVA_HOME_${requiredMajor}_$architecture")) + } + Add-Candidate $env:JAVA_HOME + + if ($IsMacOS) { + $macHomes = @(& /usr/libexec/java_home -v $requiredMajor -a $requiredArchitecture 2>$null) + if ($LASTEXITCODE -eq 0) { + foreach ($home in $macHomes) { + Add-Candidate $home + } + } + } + + $javaOnPath = Get-Command java -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($javaOnPath) { + $pathProperties = @(& $javaOnPath.Source -XshowSettings:properties -version 2>&1 | + ForEach-Object { "$_" }) + if ($LASTEXITCODE -eq 0) { + $homeProperty = $pathProperties | + Where-Object { $_ -match '^\s*java\.home\s*=' } | + Select-Object -First 1 + if ($homeProperty) { + Add-Candidate (($homeProperty -split '=', 2)[1]) + } + } + } + + $acceptedArchitectures = if ($requiredArchitecture -eq 'arm64') { + @('aarch64', 'arm64') + } else { + @('amd64', 'x86_64', 'x64') + } + $resolvedHome = $null + $resolvedVersion = $null + $resolvedArchitecture = $null + + foreach ($candidate in @($candidateHomes | Select-Object -Unique)) { + $javaName = if ($IsWindows) { 'java.exe' } else { 'java' } + $javacName = if ($IsWindows) { 'javac.exe' } else { 'javac' } + $java = [IO.Path]::Combine($candidate, 'bin', $javaName) + $javac = [IO.Path]::Combine($candidate, 'bin', $javacName) + if (-not (Test-Path $java -PathType Leaf -ErrorAction SilentlyContinue) -or + -not (Test-Path $javac -PathType Leaf -ErrorAction SilentlyContinue)) { + continue + } + + $properties = @(& $java -XshowSettings:properties -version 2>&1 | + ForEach-Object { "$_" }) + if ($LASTEXITCODE -ne 0) { + continue + } + + $versionProperty = $properties | + Where-Object { $_ -match '^\s*java\.version\s*=' } | + Select-Object -First 1 + $architectureProperty = $properties | + Where-Object { $_ -match '^\s*os\.arch\s*=' } | + Select-Object -First 1 + if (-not $versionProperty -or -not $architectureProperty) { + continue + } + + $version = (($versionProperty -split '=', 2)[1]).Trim() + $architecture = (($architectureProperty -split '=', 2)[1]).Trim().ToLowerInvariant() + if ($version -notmatch "^$requiredMajor(?:\.|$)" -or + $architecture -notin $acceptedArchitectures) { + continue + } + + $resolvedHome = (Resolve-Path $candidate).Path + $resolvedVersion = $version + $resolvedArchitecture = $architecture + break + } + + if (-not $resolvedHome) { + $inspected = if ($candidateHomes.Count -eq 0) { + '' + } else { + ($candidateHomes | Select-Object -Unique) -join ', ' + } + throw "Could not find a pre-installed JDK $requiredMajor $requiredArchitecture. " + + "Inspected: $inspected" + } + + Write-Host "Using JDK $resolvedVersion ($resolvedArchitecture): $resolvedHome" + Write-Host "##vso[task.setvariable variable=foundryJavaHome]$resolvedHome" + Write-Host "##vso[task.prependpath]$(Join-Path $resolvedHome 'bin')" From 2eaba2f0042af751b20fbd7120edb9044fa7cdf3 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Fri, 18 Sep 2026 05:09:03 +0800 Subject: [PATCH 15/15] fix(java): bootstrap Windows ARM64 JDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/templates/steps-resolve-java.yml | 127 ++++++++++++++------ 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/.pipelines/templates/steps-resolve-java.yml b/.pipelines/templates/steps-resolve-java.yml index 8919dc1f7..981f67f3e 100644 --- a/.pipelines/templates/steps-resolve-java.yml +++ b/.pipelines/templates/steps-resolve-java.yml @@ -65,50 +65,103 @@ steps: } else { @('amd64', 'x86_64', 'x64') } - $resolvedHome = $null - $resolvedVersion = $null - $resolvedArchitecture = $null - - foreach ($candidate in @($candidateHomes | Select-Object -Unique)) { - $javaName = if ($IsWindows) { 'java.exe' } else { 'java' } - $javacName = if ($IsWindows) { 'javac.exe' } else { 'javac' } - $java = [IO.Path]::Combine($candidate, 'bin', $javaName) - $javac = [IO.Path]::Combine($candidate, 'bin', $javacName) - if (-not (Test-Path $java -PathType Leaf -ErrorAction SilentlyContinue) -or - -not (Test-Path $javac -PathType Leaf -ErrorAction SilentlyContinue)) { - continue + + function Find-Jdk { + foreach ($candidate in @($candidateHomes | Select-Object -Unique)) { + $javaName = if ($IsWindows) { 'java.exe' } else { 'java' } + $javacName = if ($IsWindows) { 'javac.exe' } else { 'javac' } + $java = [IO.Path]::Combine($candidate, 'bin', $javaName) + $javac = [IO.Path]::Combine($candidate, 'bin', $javacName) + if (-not (Test-Path $java -PathType Leaf -ErrorAction SilentlyContinue) -or + -not (Test-Path $javac -PathType Leaf -ErrorAction SilentlyContinue)) { + continue + } + + $properties = @(& $java -XshowSettings:properties -version 2>&1 | + ForEach-Object { "$_" }) + if ($LASTEXITCODE -ne 0) { + continue + } + + $versionProperty = $properties | + Where-Object { $_ -match '^\s*java\.version\s*=' } | + Select-Object -First 1 + $architectureProperty = $properties | + Where-Object { $_ -match '^\s*os\.arch\s*=' } | + Select-Object -First 1 + if (-not $versionProperty -or -not $architectureProperty) { + continue + } + + $version = (($versionProperty -split '=', 2)[1]).Trim() + $architecture = (($architectureProperty -split '=', 2)[1]).Trim().ToLowerInvariant() + if ($version -notmatch "^$requiredMajor(?:\.|$)" -or + $architecture -notin $acceptedArchitectures) { + continue + } + + return [pscustomobject]@{ + Home = (Resolve-Path $candidate).Path + Version = $version + Architecture = $architecture + } } - $properties = @(& $java -XshowSettings:properties -version 2>&1 | - ForEach-Object { "$_" }) - if ($LASTEXITCODE -ne 0) { - continue + return $null + } + + $resolved = Find-Jdk + if (-not $resolved -and $IsWindows -and $requiredArchitecture -eq 'arm64') { + $version = '17.0.20.1' + $tools = Join-Path '$(Agent.TempDirectory)' 'foundry-java-tools' + $archive = Join-Path $tools "microsoft-jdk-$version-windows-aarch64.zip" + $extractRoot = Join-Path $tools "microsoft-jdk-$version-windows-aarch64" + + New-Item -ItemType Directory -Force -Path $tools | Out-Null + if (-not (Test-Path $archive -PathType Leaf)) { + $uri = "https://aka.ms/download-jdk/" + + "microsoft-jdk-$version-windows-aarch64.zip" + Invoke-WebRequest $uri -OutFile $archive } - $versionProperty = $properties | - Where-Object { $_ -match '^\s*java\.version\s*=' } | - Select-Object -First 1 - $architectureProperty = $properties | - Where-Object { $_ -match '^\s*os\.arch\s*=' } | - Select-Object -First 1 - if (-not $versionProperty -or -not $architectureProperty) { - continue + $expected = '6872e702181fe88773a80bc3684281c111091e29e7f190ac5af06b0315f8b849' + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expected) { + Remove-Item $archive -Force + throw 'Microsoft OpenJDK archive checksum mismatch.' } - $version = (($versionProperty -split '=', 2)[1]).Trim() - $architecture = (($architectureProperty -split '=', 2)[1]).Trim().ToLowerInvariant() - if ($version -notmatch "^$requiredMajor(?:\.|$)" -or - $architecture -notin $acceptedArchitectures) { - continue + $possibleHomes = if (Test-Path $extractRoot -PathType Container) { + @((Get-Item $extractRoot)) + @(Get-ChildItem $extractRoot -Directory) + } else { + @() + } + $jdkHome = $possibleHomes | + Where-Object { + Test-Path ([IO.Path]::Combine($_.FullName, 'bin', 'java.exe')) -PathType Leaf + } | + Select-Object -First 1 + if (-not $jdkHome) { + if (Test-Path $extractRoot) { + Remove-Item $extractRoot -Recurse -Force + } + Expand-Archive -LiteralPath $archive -DestinationPath $extractRoot + $possibleHomes = @((Get-Item $extractRoot)) + + @(Get-ChildItem $extractRoot -Directory) + $jdkHome = $possibleHomes | + Where-Object { + Test-Path ([IO.Path]::Combine($_.FullName, 'bin', 'java.exe')) -PathType Leaf + } | + Select-Object -First 1 + } + if (-not $jdkHome) { + throw "Microsoft OpenJDK archive does not contain a JDK home: $archive" } - $resolvedHome = (Resolve-Path $candidate).Path - $resolvedVersion = $version - $resolvedArchitecture = $architecture - break + Add-Candidate $jdkHome.FullName + $resolved = Find-Jdk } - if (-not $resolvedHome) { + if (-not $resolved) { $inspected = if ($candidateHomes.Count -eq 0) { '' } else { @@ -118,6 +171,6 @@ steps: "Inspected: $inspected" } - Write-Host "Using JDK $resolvedVersion ($resolvedArchitecture): $resolvedHome" - Write-Host "##vso[task.setvariable variable=foundryJavaHome]$resolvedHome" - Write-Host "##vso[task.prependpath]$(Join-Path $resolvedHome 'bin')" + Write-Host "Using JDK $($resolved.Version) ($($resolved.Architecture)): $($resolved.Home)" + Write-Host "##vso[task.setvariable variable=foundryJavaHome]$($resolved.Home)" + Write-Host "##vso[task.prependpath]$(Join-Path $resolved.Home 'bin')"