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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,64 @@ jobs:
- name: Check
run: yarn check

android:
name: Android Moonshine unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: false

- uses: actions/setup-node@v4
with:
node-version: 20

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3

- uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4

- name: Enable Corepack
run: corepack enable

- name: Install
run: yarn install --immutable --mode=skip-build

- name: Generate Android project
run: yarn workspace audio-playground exec expo prebuild --platform android --no-install
env:
APP_VARIANT: development
EAS_PROJECT_ID: ci-placeholder

- name: Test Moonshine Android bridge
working-directory: apps/playground/android
run: ./gradlew :siteed_moonshine.rn:testDebugUnitTest
env:
APP_VARIANT: development

- name: Prepare Sherpa native test dependency
run: |
node packages/sherpa-onnx.rn/install.js
for abi in arm64-v8a armeabi-v7a x86_64; do
cp packages/sherpa-onnx.rn/prebuilt/android/$abi/*.so \
packages/sherpa-onnx.rn/android/src/main/jniLibs/$abi/
done

- name: Test runtime coexistence on API 26
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
with:
api-level: 26
arch: x86_64
profile: pixel_2
disable-animations: true
script: cd apps/playground/android && ./gradlew :siteed_moonshine.rn:connectedDebugAndroidTest
env:
APP_VARIANT: development

ios:
name: iOS unit tests
# macOS minutes bill at a multiple of Linux, so this job is deliberately
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ packages/react-native-essentia/third_party/essentia
packages/moonshine.rn/third_party/moonshine
packages/moonshine.rn/third_party/moonshine-js
packages/moonshine.rn/prebuilt/ios/current/
packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar
packages/moonshine.rn/lib/
packages/moonshine.rn/*.tgz

Expand Down
3 changes: 3 additions & 0 deletions .package-manifests/siteed__moonshine.rn.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ README.md
android/build.gradle
android/src/main/AndroidManifest.xml
android/src/main/java/net/siteed/moonshine/MoonshineDirectJni.kt
android/src/main/java/net/siteed/moonshine/MoonshineIntentRecognizerStore.kt
android/src/main/java/net/siteed/moonshine/MoonshineModule.kt
android/src/main/java/net/siteed/moonshine/MoonshinePackage.kt
android/src/main/java/net/siteed/moonshine/MoonshineTranscriptPolicy.kt
apply-upstream-patches.sh
build-moonshine-android.sh
build-moonshine-ios.sh
Expand Down Expand Up @@ -138,6 +140,7 @@ prebuilt/android/build-metadata.json
prebuilt/ios/build-metadata.json
prebuilt/web/build-metadata.json
react-native.config.js
scripts/ensure-android-artifacts.sh
scripts/ensure-ios-artifacts.sh
setup.sh
src/NativeMoonshine.ts
Expand Down
35 changes: 5 additions & 30 deletions apps/playground/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,17 @@ import 'ts-node/register'
// Deps
import { ConfigContext, ExpoConfig } from '@expo/config'
import { config as dotenvConfig } from 'dotenv-flow'
import Joi from 'joi'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { validatePlaygroundEnvironment } from './config/validate-env'
import { version as packageVersion } from './package.json'

dotenvConfig({
silent: true,
node_env: process.env.APP_VARIANT || "production", // This will use APP_VARIANT for env file selection
}) // Load variables from .env* files

// Define a schema for the environment variables
const envSchema = Joi.object({
EAS_PROJECT_ID: Joi.string().required(),
APPLE_TEAM_ID: Joi.string().optional(),
APP_VARIANT: Joi.string()
.valid('development', 'staging', 'production')
.default('production')
.required(),
}).unknown() // Allow other environment variables

// Validate and get environment variables
const { error, value: env } = envSchema.validate(process.env, {
abortEarly: true,
debug: true,
presence: 'required', // This ensures defaults are applied
stripUnknown: false,
})

if (error) {
console.error('Environment validation error:', error.message)
throw error
}

// Add type assertion to ensure APP_VARIANT is typed correctly
const validatedEnv = env as typeof env & {
APP_VARIANT: 'development' | 'staging' | 'production'
}
const validatedEnv = validatePlaygroundEnvironment(process.env)

try {
const ortPackageJsonPath = join(__dirname, 'node_modules/onnxruntime-web/package.json')
Expand Down Expand Up @@ -212,8 +186,9 @@ export default ({ config }: ConfigContext): ExpoConfig => {
}
},
android: {
// Moonshine's Android AAR declares minSdk 35; keep the app aligned so release manifest merging succeeds.
minSdkVersion: 35,
// Moonshine Maven AAR 0.1.5 declares minSdk 26. Source-built AARs pinned
// to v0.0.59 still declare 35 and will raise the merged floor.
minSdkVersion: 26,
// Keep .so files uncompressed so AGP 8.5+ can zipalign them at 16KB boundaries
// Required for Google Play's 16KB page size alignment check (Android 15+)
useLegacyPackaging: false,
Expand Down
32 changes: 32 additions & 0 deletions apps/playground/config/validate-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { validatePlaygroundEnvironment } from './validate-env'

test('validation errors contain key names but never environment values', () => {
const secret = 'should-never-appear-in-build-output'

assert.throws(
() =>
validatePlaygroundEnvironment({
APP_VARIANT: secret,
UNRELATED_CREDENTIAL: secret,
}),
(error: unknown) => {
assert.ok(error instanceof Error)
const serialized = `${error.message}\n${error.stack}\n${JSON.stringify(error)}`
assert.match(serialized, /APP_VARIANT/)
assert.match(serialized, /EAS_PROJECT_ID/)
assert.doesNotMatch(serialized, new RegExp(secret))
assert.doesNotMatch(serialized, /UNRELATED_CREDENTIAL/)
return true
}
)
})

test('validation applies the production variant default', () => {
const validated = validatePlaygroundEnvironment({
EAS_PROJECT_ID: 'project-id',
})

assert.equal(validated.APP_VARIANT, 'production')
})
40 changes: 40 additions & 0 deletions apps/playground/config/validate-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Joi from 'joi'

export type PlaygroundEnvironment = Record<string, string | undefined> & {
APP_VARIANT: 'development' | 'staging' | 'production'
EAS_PROJECT_ID: string
}

const envSchema = Joi.object({
EAS_PROJECT_ID: Joi.string().required(),
APPLE_TEAM_ID: Joi.string().optional(),
APP_VARIANT: Joi.string()
.valid('development', 'staging', 'production')
.default('production'),
}).unknown()

export function validatePlaygroundEnvironment(
input: Record<string, string | undefined>
): PlaygroundEnvironment {
const { error, value } = envSchema.validate(input, {
abortEarly: false,
stripUnknown: false,
})

if (error) {
const invalidKeys = [
...new Set(
error.details.map((detail) =>
detail.path.length > 0
? detail.path.join('.')
: 'environment'
)
),
].sort((left, right) => left.localeCompare(right))
throw new Error(
`Invalid environment variables: ${invalidKeys.join(', ')}`
)
}

return value as PlaygroundEnvironment
}
2 changes: 2 additions & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
"recipe:preconditions": "bash scripts/agentic/validate-pre-conditions.sh",
"recipe:v1": "node scripts/agentic/recipe-v1/run-recipe-v1.mjs",
"benchmark:moonshine:longform": "BENCHMARK_PRESET=moonshine-longform node scripts/agentic/direct-asr-benchmark.mjs",
"benchmark:moonshine:diarization": "node scripts/agentic/moonshine-speaker-turn-validation.mjs",
"reload": "bash scripts/agentic/device-cmd.sh reload",
"wake": "bash scripts/agentic/wake-devices.sh",
"debug": "bash scripts/agentic/device-cmd.sh debug",
"dev-menu": "bash scripts/agentic/device-cmd.sh dev-menu",
"typecheck": "bash scripts/typecheck.sh",
"test:config": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' node -r ts-node/register --test config/validate-env.test.ts src/utils/moonshineDiarizationRuntime.test.ts src/utils/moonshineIntentModelFiles.test.ts",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint:fix-nullish": "bash scripts/fix-nullish-coalescing.sh",
Expand Down
23 changes: 0 additions & 23 deletions apps/playground/plugins/withCustomGradleConfig.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,29 +134,6 @@ ${newConfigurations}
return config;
});

config = withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents
if (contents.includes("pickFirst 'lib/arm64-v8a/libonnxruntime.so'")) {
return config
}

const marker = "pickFirst 'lib/x86_64/libc++_shared.so'"
if (!contents.includes(marker)) {
return config
}

config.modResults.contents = contents.replace(
marker,
`${marker}
pickFirst 'lib/arm64-v8a/libonnxruntime.so'
pickFirst 'lib/armeabi-v7a/libonnxruntime.so'
pickFirst 'lib/x86/libonnxruntime.so'
pickFirst 'lib/x86_64/libonnxruntime.so'`
)

return config
})

// Keep Android debug-only manifest tweaks in one durable place so prebuild
// regeneration doesn't wipe them.
config = withDangerousMod(config, [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ const TIMEOUT_MS = 10 * 60 * 1000;
const STATE_TIMEOUT_MS = 90 * 1000;
const POLL_INTERVAL_MS = 1000;

const WORDS_ROOT = '/Volumes/c910ssd/datasets/ami_public_manual_1.6.2/words';
const WORDS_ROOT =
process.env.AMI_WORDS_ROOT ||
'/Volumes/c910ssd/datasets/ami_public_manual_1.6.2/words';
const AUDIO_ROOT =
process.env.AMI_AUDIO_ROOT || '/Volumes/c910ssd/datasets/amicorpus';
const MODELS = [
{ id: 'moonshine-small-streaming-en', label: 'Moonshine Small Streaming' },
{ id: 'moonshine-medium-streaming-en', label: 'Moonshine Medium Streaming' },
Expand Down Expand Up @@ -204,7 +208,12 @@ function getClipInfo(windowSpec) {
...windowSpec,
clipId,
durationS: windowSpec.endS - windowSpec.startS,
hostAudio: `/Volumes/c910ssd/datasets/amicorpus/${windowSpec.meetingId}/audio/${windowSpec.meetingId}.Mix-Headset.wav`,
hostAudio: path.join(
AUDIO_ROOT,
windowSpec.meetingId,
'audio',
`${windowSpec.meetingId}.Mix-Headset.wav`
),
hostClip: `/tmp/${clipId}.wav`,
deviceClip: `/data/user/0/${PKG}/files/benchmarks/${clipId}.wav`,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"title": "Playground Moonshine intent validation",
"description": "Drives the Moonshine intent demo screen end-to-end (download model, create recognizer with synced intents, process the default utterance) so the synchronous moonshine_get_closest_intents() pipeline is exercised on-device.",
"description": "Drives the Moonshine intent demo screen end-to-end (download model, create recognizer with synced intents, process the default utterance) so the Android EmbeddingModel intent pipeline is exercised on-device.",
"validate": {
"workflow": {
"entry": "intent-screen-smoke",
Expand Down
7 changes: 7 additions & 0 deletions apps/playground/src/hooks/useMoonshineLiveSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
getMoonshineRuntimeConfig,
getBenchmarkModelStatus,
prepareBenchmarkModel,
prepareMoonshineDiarizationModels,
safeReleaseMoonshineTranscriber,
} from '../utils/asrBenchmarkRuntime'
import { supportsExternalMoonshineDiarizationModels } from '../utils/moonshineDiarizationRuntime'

const logger = baseLogger.extend('MoonshineLiveSession')

Expand Down Expand Up @@ -348,13 +350,18 @@ export function useMoonshineLiveSession(
await releaseLiveTranscriber()
const initStartedAt = Date.now()
const config = await getMoonshineRuntimeConfig(liveModelId, setStatusMessage)
const diarizationModelDir =
identifySpeakers && supportsExternalMoonshineDiarizationModels(Platform.OS)
? await prepareMoonshineDiarizationModels(setStatusMessage)
: undefined
// Speaker attribution in the recommendation workflow comes from
// Sherpa VAD + Speaker ID. Keep Moonshine speaker identification
// opt-in so Android does not run two independent speaker trackers
// by default.
const transcriberOptions = identifySpeakers
? {
...config.options,
diarizationModelDir,
identifySpeakers: true,
}
: config.options
Expand Down
35 changes: 35 additions & 0 deletions apps/playground/src/utils/asrBenchmarkRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ import {
type AsrBenchmarkModel,
} from './asrBenchmarkModels'
import { toNativePath } from './fileUtils'
import { supportsExternalMoonshineDiarizationModels } from './moonshineDiarizationRuntime'
import { pcm16ToArrayBuffer, readMonoPcm16Wav } from './wav'

const logger = baseLogger.extend('AsrBenchmarkRuntime')

const moonshineModelRoot = `${FileSystem.documentDirectory ?? ''}moonshine-models/`
const moonshineDiarizationRoot = `${moonshineModelRoot}diarization-community1`
const moonshineDiarizationBaseUrl =
'https://download.moonshine.ai/model/diarization-community1'
const moonshineDiarizationFiles = ['segmentation.ort', 'embedding.ort']
const whisperModelRoot = `${FileSystem.documentDirectory ?? ''}whisper-models/`
// Match the live Moonshine RN transport: the recorder can emit smaller waveform
// chunks, but ASR bridge calls are coalesced to reduce JS/native copies.
Expand Down Expand Up @@ -210,6 +215,30 @@ async function downloadToFile(
}
}

export async function prepareMoonshineDiarizationModels(
onStatus?: (message: string) => void,
): Promise<string> {
if (!supportsExternalMoonshineDiarizationModels(Platform.OS)) {
throw new Error('External Moonshine diarization models require Android 0.1.5')
}

await FileSystem.makeDirectoryAsync(moonshineDiarizationRoot, {
intermediates: true,
}).catch(() => {})
for (const fileName of moonshineDiarizationFiles) {
const targetPath = `${moonshineDiarizationRoot}/${fileName}`
const existing = await FileSystem.getInfoAsync(targetPath)
if (!existing.exists) {
await downloadToFile(
`${moonshineDiarizationBaseUrl}/${fileName}`,
targetPath,
onStatus,
)
}
}
return toNativePath(moonshineDiarizationRoot)
}

async function getValidatedMoonshineFileInfo(
targetPath: string,
expectedBytes: number,
Expand Down Expand Up @@ -425,11 +454,17 @@ export async function createMoonshineBenchmarkTranscriber(
transcriber: MoonshineTranscriber
}> {
const config = await getMoonshineRuntimeConfig(modelId, onStatus)
const diarizationModelDir =
optionsOverride?.identifySpeakers === true &&
supportsExternalMoonshineDiarizationModels(Platform.OS)
? await prepareMoonshineDiarizationModels(onStatus)
: undefined
const transcriber = await Moonshine.createTranscriberFromFiles({
...config,
options: {
...config.options,
...optionsOverride,
...(diarizationModelDir ? { diarizationModelDir } : {}),
},
})
return { config, transcriber }
Expand Down
Loading
Loading