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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/playground/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed
- `yarn build:deps` now also builds `@siteed/sherpa-onnx.rn` and `@siteed/moonshine.rn`. Yarn 4 skips workspace `prepare` scripts, so the playground bundled stale `lib/` output for both, and web runs missed source fixes.

## [2.4.0] - 2026-11-01

### Added
Expand Down
42 changes: 42 additions & 0 deletions apps/playground/config/build-deps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import test from 'node:test'

const appRoot = path.resolve(__dirname, '..')

function readJson(file: string) {
return JSON.parse(readFileSync(file, 'utf8'))
}

// Resolve each `cd <dir> && yarn <script>` step of build:deps to the package it runs in.
function buildDepsSteps() {
const script: string = readJson(path.join(appRoot, 'package.json')).scripts['build:deps']
let cwd = appRoot
const steps: { dir: string; script: string }[] = []
for (const part of script.split('&&').map((step) => step.trim())) {
const cd = part.match(/^cd (.+)$/)
if (cd) {
cwd = path.resolve(cwd, cd[1])
continue
}
const run = part.match(/^yarn (\S+)$/)
assert.ok(run, `unexpected build:deps step: ${part}`)
steps.push({ dir: cwd, script: run[1] })
}
return steps
}

// The web bundle imports these packages' lib/ output. Yarn 4 skips workspace
// prepare scripts on install, so build:deps is the only thing that rebuilds it.
for (const name of ['sherpa-onnx.rn', 'moonshine.rn']) {
test(`build:deps rebuilds the ${name} lib/ the playground imports`, () => {
const dir = path.resolve(appRoot, '../../packages', name)
const manifest = readJson(path.join(dir, 'package.json'))
const step = buildDepsSteps().find((entry) => entry.dir === dir)

assert.ok(step, `build:deps never builds packages/${name}`)
assert.match(manifest.scripts[step.script], /\bbob build\b/)
assert.match(manifest.main, /^\.\/lib\//)
})
}
4 changes: 2 additions & 2 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"eas-build-pre-install": "bash scripts/eas-build-pre-install.sh",
"postinstall": "patch-package",
"build:deps": "cd ../../packages/audio-studio && yarn build && yarn build:plugin && cd ../playgroundapi && yarn build && cd ../react-native-essentia && yarn prepare",
"build:deps": "cd ../../packages/audio-studio && yarn build && yarn build:plugin && cd ../playgroundapi && yarn build && cd ../react-native-essentia && yarn prepare && cd ../sherpa-onnx.rn && yarn prepare && cd ../moonshine.rn && yarn prepare",
"start": "NODE_ENV=development APP_VARIANT=development bash scripts/agentic/start-metro.sh && node scripts/agentic/start-interactive.mjs",
"stop": "bash scripts/agentic/stop-metro.sh",
"android": "APP_VARIANT=development PLATFORM=android HEADLESS=0 REBUILD=1 bash scripts/agentic/preflight.sh",
Expand All @@ -26,7 +26,7 @@
"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 src/utils/recordingAudioStream.test.ts",
"test:config": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' node -r ts-node/register --test config/validate-env.test.ts config/build-deps.test.ts src/utils/moonshineDiarizationRuntime.test.ts src/utils/moonshineIntentModelFiles.test.ts src/utils/recordingAudioStream.test.ts",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint:fix-nullish": "bash scripts/fix-nullish-coalescing.sh",
Expand Down
3 changes: 3 additions & 0 deletions apps/sherpa-voice/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Web: the ASR, speaker ID and audio tagging sample cards show the file size and duration instead of `0 Bytes` and `0:00`. `getInfoAsync` has no web implementation, so web now fetches the file to measure it.

## [1.2.0] - 2026-11-02

### Changed
Expand Down
7 changes: 3 additions & 4 deletions apps/sherpa-voice/src/hooks/useAsr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type AudioDataEvent,
} from '@siteed/audio-studio'
import { baseLogger } from '../config'
import { resolveModelDir } from '../utils/fileUtils'
import { getFileSize, resolveModelDir } from '../utils/fileUtils'
import { SAMPLE_AUDIO_FILES } from '../utils/asrSamples'
export { SAMPLE_AUDIO_FILES } from '../utils/asrSamples'

Expand All @@ -14,7 +14,6 @@ import { makeWebProgressHandler, getWebModelBaseUrl } from '../utils/webModelUti
import { getWebAsrBackend } from '../config/webFeatures'
import { Asset } from 'expo-asset'
import { createAudioPlayer } from 'expo-audio'
import * as FileSystem from 'expo-file-system/legacy'
import { useLocalSearchParams } from 'expo-router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAsrModels, useAsrModelWithConfig } from './useModelWithConfig'
Expand Down Expand Up @@ -239,7 +238,7 @@ export function useAsr() {
setSelectedAudio(audioItem)
setAudioMetadata({ isLoading: true })
try {
const info = await FileSystem.getInfoAsync(audioItem.localUri)
const size = await getFileSize(audioItem.localUri)
let duration = 0
try {
const tempPlayer = createAudioPlayer({
Expand All @@ -250,7 +249,7 @@ export function useAsr() {
tempPlayer.remove()
} catch (_) {}
setAudioMetadata({
size: info.exists ? info.size : 0,
size,
duration,
isLoading: false,
})
Expand Down
8 changes: 2 additions & 6 deletions apps/sherpa-voice/src/hooks/useAudioTagging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAudioPlayer } from 'expo-audio'
import * as FileSystem from 'expo-file-system/legacy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Alert } from 'react-native'
import { getFileSize } from '../utils/fileUtils'
import { makeWebProgressHandler, getWebModelBaseUrl } from '../utils/webModelUtils'
import {
useAudioTaggingModels,
Expand Down Expand Up @@ -343,12 +344,7 @@ export function useAudioTagging() {

if (audioItem.localUri) {
try {
const fileInfo = await FileSystem.getInfoAsync(
audioItem.localUri
)
const size = fileInfo.exists
? (fileInfo as any).size || 0
: 0
const size = await getFileSize(audioItem.localUri)
const tempPlayer = createAudioPlayer({
uri: audioItem.localUri,
})
Expand Down
5 changes: 2 additions & 3 deletions apps/sherpa-voice/src/hooks/useSpeakerId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
} from '@siteed/sherpa-onnx.rn'
import { Asset } from 'expo-asset'
import { createAudioPlayer } from 'expo-audio'
import * as FileSystem from 'expo-file-system/legacy'
import { useEffect, useState } from 'react'
import { Alert } from 'react-native'
import { getFileSize } from '../utils/fileUtils'
import { makeWebProgressHandler, getWebModelBaseUrl } from '../utils/webModelUtils'
import {
useSpeakerIdModelWithConfig,
Expand Down Expand Up @@ -341,8 +341,7 @@ export function useSpeakerId() {
setIdentifyResult(null)
setAudioMetadata({ isLoading: true })
try {
const fileInfo = await FileSystem.getInfoAsync(audioItem.localUri)
const fileSize = fileInfo.exists ? (fileInfo as any).size || 0 : 0
const fileSize = await getFileSize(audioItem.localUri)
const tempPlayer = createAudioPlayer({ uri: audioItem.localUri })
await new Promise((r) => setTimeout(r, 500))
const durationMs = (tempPlayer.duration || 0) * 1000
Expand Down
62 changes: 62 additions & 0 deletions apps/sherpa-voice/src/utils/__tests__/fileUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/// <reference types="jest" />
import { Platform } from 'react-native'
import * as FileSystem from 'expo-file-system/legacy'
import { getFileSize } from '../fileUtils'

jest.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
jest.mock('../../config', () => ({
baseLogger: { extend: () => ({ info: jest.fn(), warn: jest.fn() }) },
}))
jest.mock('expo-file-system/legacy', () => ({ getInfoAsync: jest.fn() }))

const getInfoAsync = FileSystem.getInfoAsync as jest.Mock
const SAMPLE = '/assets/?unstable_path=.%2Fassets%2Fjfk.wav'

describe('getFileSize', () => {
afterEach(() => {
jest.resetAllMocks()
Platform.OS = 'ios'
})

describe('on web', () => {
beforeEach(() => {
Platform.OS = 'web'
// expo-file-system has no web implementation of getInfoAsync.
getInfoAsync.mockRejectedValue(
new Error('ExponentFileSystem.getInfoAsync is not available on web')
)
})

it('measures the fetched body instead of calling getInfoAsync', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve({ size: 352044 }),
})

await expect(getFileSize(SAMPLE)).resolves.toBe(352044)
expect(global.fetch).toHaveBeenCalledWith(SAMPLE)
expect(getInfoAsync).not.toHaveBeenCalled()
})

it('returns 0 when the file is missing', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 })

await expect(getFileSize(SAMPLE)).resolves.toBe(0)
})
})

describe('on native', () => {
it('reads the size from getInfoAsync', async () => {
getInfoAsync.mockResolvedValue({ exists: true, size: 1234 })

await expect(getFileSize('file:///data/jfk.wav')).resolves.toBe(1234)
expect(getInfoAsync).toHaveBeenCalledWith('file:///data/jfk.wav')
})

it('returns 0 when the file is missing', async () => {
getInfoAsync.mockResolvedValue({ exists: false })

await expect(getFileSize('file:///data/missing.wav')).resolves.toBe(0)
})
})
})
15 changes: 15 additions & 0 deletions apps/sherpa-voice/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ export async function fileExists(uri: string): Promise<boolean> {
return info.exists;
}

/**
* Size of a file in bytes, or 0 when it is missing.
* On web: fetch the URL and measure the body (getInfoAsync is not available).
* On native: FileSystem.getInfoAsync.
*/
export async function getFileSize(uri: string): Promise<number> {
if (Platform.OS === 'web') {
const response = await fetch(uri);
if (!response.ok) return 0;
return (await response.blob()).size;
}
const info = await FileSystem.getInfoAsync(uri);
return info.exists ? info.size : 0;
}

/**
* Resolve the effective model directory path.
* Sherpa-onnx tarballs extract into a `sherpa-onnx-*` subdirectory — descend
Expand Down
7 changes: 7 additions & 0 deletions packages/sherpa-onnx.rn/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Web `initTts` fails with `Failed to load TTS model files: <url>` when `model.onnx`, `tokens.txt` or a configured `lexiconFile` cannot be fetched, including when none of them loads, and removes the files that did load along with their directory. Before, a missing lexicon aborted engine creation with `RuntimeError: memory access out of bounds`, a missing model or tokens file gave `Missing required files for VITS model configuration` without naming it, and a total download failure gave a bare `Failed to load TTS model files`.
- Web `initTts` rejects non-VITS models with `Web TTS supports VITS models only, not <type>`. The WASM glue builds no engine config for matcha or kokoro, so those crashed during engine creation.
- Web `FileSystem.removePath` removes directories. `isDirectory` read an `isDir` flag that Emscripten `stat` results do not have, so every directory was treated as a file and could not be removed.
- The web loader no longer logs `Module loading timeout reached` 30 s after the modules loaded. It now clears its backup timer once loading settles, and a load that really times out warns once instead of twice.

## [2.0.0] - 2026-11-02

### Migration
Expand Down
2 changes: 2 additions & 0 deletions packages/sherpa-onnx.rn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ if (Platform.OS === 'web') {

Self-hosting (optional): copy files from `node_modules/@siteed/sherpa-onnx.rn/wasm/` and call `configureSherpaOnnx({ wasmBasePath: '/your/wasm/path/' })`.

Web TTS supports VITS models only; matcha and kokoro need iOS or Android. It fetches `model.onnx`, `tokens.txt` and `lexiconFile` from `modelBaseUrl`, so host the lexicon (for English, upstream `tts-models/lexicon-en-us.txt`) next to the voice files. The browser fetch needs the same origin or CORS headers; GitHub release download URLs send none. If a file is missing, `initTts` fails with `Failed to load TTS model files: <url>`.

See **[Getting Started: Web](docs/GETTING_STARTED_WEB.md)** for the full guide including `onProgress`, loading indicators, and model configuration.

## Usage
Expand Down
79 changes: 79 additions & 0 deletions packages/sherpa-onnx.rn/src/__tests__/webCombinedLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const TIMEOUT_WARNING = 'Module loading timeout reached';

type FakeScript = { src?: string; onload?: () => void; onerror?: () => void };
type LoaderGlobals = {
window?: unknown;
document?: unknown;
SherpaOnnx?: unknown;
Module?: unknown;
sherpaOnnxModulePaths?: string[];
onModuleReady?: () => void;
};

const g = globalThis as unknown as LoaderGlobals;

// Load the browser loader with a fake DOM whose <script> tags either load at
// once or never settle.
function loadCombinedLoader(scriptsSettle: boolean) {
g.window = globalThis;
g.sherpaOnnxModulePaths = ['core.js', 'tts.js'];
g.document = {
getElementsByTagName: () => [],
createElement: (): FakeScript => ({}),
head: {
appendChild: (script: FakeScript) => {
if (!scriptsSettle) return;
if (script.src === 'core.js') g.SherpaOnnx = {};
script.onload?.();
},
},
};
jest.isolateModules(() => {
require('../../wasm-src/sherpa-onnx-combined.js');
});
}

function timeoutWarnings(warn: jest.SpyInstance) {
return warn.mock.calls.filter(([message]) =>
String(message).includes(TIMEOUT_WARNING)
);
}

describe('web combined loader backup timeout', () => {
let warn: jest.SpyInstance;

beforeEach(() => {
jest.useFakeTimers();
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
delete g.document;
delete g.SherpaOnnx;
delete g.Module;
delete g.sherpaOnnxModulePaths;
delete g.onModuleReady;
});

it('does not warn after the modules finished loading', async () => {
loadCombinedLoader(true);

g.onModuleReady?.();
await jest.advanceTimersByTimeAsync(31000);

expect(timeoutWarnings(warn)).toHaveLength(0);
});

it('still warns when loading takes longer than 30 seconds', async () => {
loadCombinedLoader(false);

g.onModuleReady?.();
await jest.advanceTimersByTimeAsync(31000);

expect(timeoutWarnings(warn)).toHaveLength(1);
});
});
Loading
Loading