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/templates/stages-java.yml b/.pipelines/templates/stages-java.yml new file mode 100644 index 000000000..def8680ee --- /dev/null +++ b/.pipelines/templates/stages-java.yml @@ -0,0 +1,287 @@ +# 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: fetch-models-from-artifacts-feed.yml + parameters: + modelSet: 'nemotron-speech' + - 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: fetch-models-from-artifacts-feed.yml + parameters: + modelSet: 'nemotron-speech' + - 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: fetch-models-from-artifacts-feed.yml + parameters: + modelSet: 'nemotron-speech' + - 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: fetch-models-from-artifacts-feed.yml + parameters: + modelSet: 'nemotron-speech' + - 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: fetch-models-from-artifacts-feed.yml + parameters: + modelSet: 'nemotron-speech' + - 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" + - template: steps-resolve-java.yml + parameters: + jdkArchitecture: 'x64' + - template: steps-bootstrap-maven.yml + - 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: Path + jdkDirectory: '$(foundryJavaHome)' + mavenVersionOption: Path + mavenDirectory: '$(foundryMavenHome)' + - 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/templates/stages-sdk-v2.yml b/.pipelines/templates/stages-sdk-v2.yml index b9e17ea99..657828d19 100644 --- a/.pipelines/templates/stages-sdk-v2.yml +++ b/.pipelines/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 @@ -52,3 +53,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/templates/steps-bootstrap-maven.yml b/.pipelines/templates/steps-bootstrap-maven.yml new file mode 100644 index 000000000..de06c9647 --- /dev/null +++ b/.pipelines/templates/steps-bootstrap-maven.yml @@ -0,0 +1,41 @@ +# 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.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 new file mode 100644 index 000000000..af8759c1c --- /dev/null +++ b/.pipelines/templates/steps-build-java.yml @@ -0,0 +1,91 @@ +# 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" + +- template: steps-resolve-java.yml + parameters: + jdkArchitecture: '${{ parameters.jdkArchitecture }}' + +- template: steps-bootstrap-maven.yml + +- 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: Path + jdkDirectory: '$(foundryJavaHome)' + mavenVersionOption: Path + mavenDirectory: '$(foundryMavenHome)' + 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/.pipelines/templates/steps-resolve-java.yml b/.pipelines/templates/steps-resolve-java.yml new file mode 100644 index 000000000..981f67f3e --- /dev/null +++ b/.pipelines/templates/steps-resolve-java.yml @@ -0,0 +1,176 @@ +# 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') + } + + 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 + } + } + + 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 + } + + $expected = '6872e702181fe88773a80bc3684281c111091e29e7f190ac5af06b0315f8b849' + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expected) { + Remove-Item $archive -Force + throw 'Microsoft OpenJDK archive checksum mismatch.' + } + + $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" + } + + Add-Candidate $jdkHome.FullName + $resolved = Find-Jdk + } + + if (-not $resolved) { + $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 $($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')" diff --git a/sdk_v2/DEVELOPMENT.md b/sdk_v2/DEVELOPMENT.md index a26093901..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 four 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 four SDKs (C++, C#, Python, JS/TS) 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 @@ -25,6 +29,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 +74,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..8928c20b2 100644 --- a/sdk_v2/build_and_test_all.ps1 +++ b/sdk_v2/build_and_test_all.ps1 @@ -1,25 +1,26 @@ <# .SYNOPSIS - Build and test all sdk_v2 SDKs (C++, C#, Python, JS) 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) 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. } @@ -242,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/.gitignore b/sdk_v2/java/.gitignore new file mode 100644 index 000000000..58eb6de8c --- /dev/null +++ b/sdk_v2/java/.gitignore @@ -0,0 +1,3 @@ +/target/ +/build/ +/.flattened-pom.xml diff --git a/sdk_v2/java/README.md b/sdk_v2/java/README.md new file mode 100644 index 000000000..475138b90 --- /dev/null +++ b/sdk_v2/java/README.md @@ -0,0 +1,172 @@ +# 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. +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 + +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 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 +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. + +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. +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-speech-streaming-en-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(); + } +} +``` + +`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 +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. 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 + 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= +``` + +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 with the same runtime directory, 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..c857f662c --- /dev/null +++ b/sdk_v2/java/build.ps1 @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +[CmdletBinding()] +param([switch]$BootstrapMaven, [string[]]$MavenArguments = @('package')) +$ErrorActionPreference = 'Stop' +$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 = $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), $mavenTools | Out-Null + if (!(Test-Path -LiteralPath $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 = '8beac8d11ef208f1e2a8df0682b9448a9a363d2ad13ca74af43705549e72e74' + + 'c9378823bf689287801cbbfc2f6ea9596201d19ccacfdfb682ee8a2ff4c4418ba' + if ((Get-FileHash $archive -Algorithm SHA512).Hash -ne $expected) { throw 'Maven archive checksum mismatch' } + Expand-Archive -LiteralPath $archive -DestinationPath $mavenTools -Force + } + if (!$IsWindows) { + & chmod u+x $executable + if ($LASTEXITCODE -ne 0) { throw "Could not make Maven executable: $executable" } + } +} +$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/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..5b728f22c --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/AudioSession.java @@ -0,0 +1,74 @@ +// 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); + } + + /** + * 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); + } + + 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; + 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/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..ba45be937 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Catalog.java @@ -0,0 +1,78 @@ +// 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; +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; + + Catalog(FoundryLocalManager owner, Pointer handle) { this.owner = owner; this.handle = handle; } + + /** + * 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(); + validateExactId(exactId); + synchronized (owner) { + owner.checkOpen(); + 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"); + } + return model; + } + } + + 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; + } + + 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..c7478f77d --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Configuration.java @@ -0,0 +1,23 @@ +// 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. + * + *

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"); + 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..ef5bc8565 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/FoundryLocalManager.java @@ -0,0 +1,108 @@ +// 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. + * + *

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; + 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; + Throwable failure = null; + for (AudioSession session : new ArrayList<>(sessions)) { + try { + session.close(); + } catch (RuntimeException | Error e) { + failure = NativeApi.preserveFailure(failure, e); + } + } + try { + api.check(api.root.pointer(NativeApi.Root.MANAGER_SHUTDOWN, handle)); + } 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 { + handle = null; + synchronized (FoundryLocalManager.class) { + active = false; + } + } + NativeApi.rethrow(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..5c834a791 --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Model.java @@ -0,0 +1,135 @@ +// 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); + } + } + + 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)); + } + } + + /** + * 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) { + 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/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 new file mode 100644 index 000000000..8331dc94a --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/NativeApi.java @@ -0,0 +1,333 @@ +// 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 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) { + 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 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)); + 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) { + 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..07f5c643c --- /dev/null +++ b/sdk_v2/java/src/main/java/com/microsoft/foundry/local/Transcription.java @@ -0,0 +1,522 @@ +// 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; + 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); + 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) { + try { + api.inference.call(NativeApi.InferenceApi.REQUEST_RELEASE, request); + } catch (RuntimeException | Error cleanupFailure) { + e.addSuppressed(cleanupFailure); + } finally { + request = null; + } + throw e; + } + 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) { + 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 | Error e) { + synchronized (this) { + if (!completion.isCancelled() && !closing && !isDone()) { + callbackFailure.compareAndSet(null, e); + try { + cancel(); + } catch (RuntimeException | Error cleanupFailure) { + e.addSuppressed(cleanupFailure); + } + } + } + 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(); + 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 { + try { + if (item != null) api.item.call(NativeApi.ItemApi.RELEASE, item); + } finally { + 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; + Throwable failure = null; + try { + api.check(api.inference.pointer(NativeApi.InferenceApi.REQUEST_CANCEL, request)); + } catch (RuntimeException | Error e) { + failure = e; + } + if (queue != null && !finished) { + 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(); } + 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; + Throwable failure = null; + synchronized (this) { + while (closing && !closed) { + try { wait(); } + catch (InterruptedException e) { interrupted = true; } + } + if (closed) { + if (interrupted) Thread.currentThread().interrupt(); + return; + } + try { + cancel(); + } catch (RuntimeException | Error e) { + failure = e; + } + 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)); + } 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; + closed = true; + closing = false; + notifyAll(); + } + } + 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); + } + } +} 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..54b8ca1d2 --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeAsrTest.java @@ -0,0 +1,120 @@ +// 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 = 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(); + try (FoundryLocalManager manager = new FoundryLocalManager(config)) { + System.err.println("native-test: manager created"); + assertThrows(IllegalStateException.class, () -> new FoundryLocalManager(config)); + 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(); + 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"); + 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(); + 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"); + } + + 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; + } +} 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..a32047e19 --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/NativeContractTest.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.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 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 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()); + 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(); } + } + + @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))); + } + +} 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..90d675c8c --- /dev/null +++ b/sdk_v2/java/src/test/java/com/microsoft/foundry/local/TranscriptionStateTest.java @@ -0,0 +1,114 @@ +// 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 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)); + 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()); + } +}