Compare commits

...

1 Commits

Author SHA1 Message Date
Mohamed Boudra
979326c04f Install local speech runtime on demand
The daemon no longer bundles sherpa-onnx, sherpa-onnx-node, or
onnxruntime-node. On first boot they are downloaded into
$PASEO_HOME/runtime/local-speech alongside the existing voice models,
non-blocking. Users who don't enable local voice never pay the size
cost.

The wire protocol is unchanged. server_info.capabilities.voice.*
already gates voice in the client; readiness now reports
runtime_install_in_progress / runtime_missing / runtime_install_failed
on the same channel as the existing model_download_* states.

The native runtime lives under packages/server/src/server/speech/
providers/local/runtime and exposes a small surface: configure the
home once at speech-service init, ensureInstalled, loadPackage,
getStatus, plus a typed error. Provider configs do not know about
the install path.
2026-05-21 10:44:50 +00:00
24 changed files with 1064 additions and 161 deletions

View File

@@ -173,7 +173,25 @@ All fields are optional with sensible defaults.
---
## 3. Schedule
## 3. Local Speech Runtime
**Path:** `$PASEO_HOME/runtime/local-speech/node_modules/`
Native local voice packages are installed on demand at daemon bootstrap, not bundled with the daemon package. The runtime installer downloads npm tarballs into `$PASEO_HOME/runtime/local-speech/.downloads`, extracts them into a temporary directory, verifies required files, then atomically moves each package into `node_modules`.
Installed packages:
- `sherpa-onnx`
- `sherpa-onnx-node`
- `sherpa-onnx-<platform>-<arch>` for the current host
- `onnxruntime-common`
- `onnxruntime-node`
The daemon starts without waiting for these packages. While they are missing, `server_info.capabilities.voice.*` reports local voice unavailable through the existing capability fields. Once the runtime and required local speech models are present, the next speech runtime reconcile makes local voice available without restarting the daemon.
---
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -221,7 +239,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
---
## 4. Chat
## 5. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
@@ -258,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -347,7 +365,7 @@ Single file containing an array of all loop records. Writes are direct (not atom
---
## 6. Project Registry
## 7. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -370,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -389,7 +407,7 @@ Array of workspace records. A workspace is a specific working directory within a
---
## 8. Push Token Store
## 9. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -403,7 +421,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 10. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.

29
package-lock.json generated
View File

@@ -13864,6 +13864,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -15185,6 +15186,7 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT"
},
"node_modules/bplist-creator": {
@@ -17051,6 +17053,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-node-es": {
@@ -18154,6 +18157,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
@@ -22695,6 +22699,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -22712,6 +22717,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.13.1"
@@ -22727,6 +22733,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -22752,6 +22759,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -25406,6 +25414,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -26425,6 +26434,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
@@ -29065,12 +29075,14 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"dev": true,
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -31995,6 +32007,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -32012,6 +32025,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/rollup": {
@@ -32303,6 +32317,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
@@ -32592,6 +32607,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx/-/sherpa-onnx-1.12.28.tgz",
"integrity": "sha512-PaOuTCUA/Vh5o3zcq+z3j1Zpv2VlP7iubOgYoVODDyeFDicRxjskiEQqERdWEcpQUi6vh47q3A63bS7Y9FRHNg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sherpa-onnx-darwin-arm64": {
@@ -32601,6 +32617,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32614,6 +32631,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32627,6 +32645,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32640,6 +32659,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32650,6 +32670,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.12.28.tgz",
"integrity": "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA==",
"dev": true,
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.28",
@@ -32667,6 +32688,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32680,6 +32702,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -37967,7 +37990,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -37975,8 +37997,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -37994,6 +38014,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"

View File

@@ -78,7 +78,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -86,8 +85,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -105,6 +102,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"

View File

@@ -841,6 +841,7 @@ export async function createPaseoDaemon(
const speechService = createSpeechService({
logger,
paseoHome: config.paseoHome,
openaiConfig: config.openai,
speechConfig: config.speech,
});

View File

@@ -2658,11 +2658,12 @@ export class Session {
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
const missingModelIds = state.missingAssets.find((asset) => asset.kind === "models")?.ids ?? [];
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
missingModelIds: missingModelIds as LocalSpeechModelId[],
};
}

View File

@@ -11,6 +11,7 @@ import {
pcm16leToFloat32,
} from "../../../audio.js";
import { Pcm16MonoResampler } from "../../../../agent/pcm16-resampler.js";
import { loadLocalSpeechPackage } from "../runtime/index.js";
type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
@@ -98,7 +99,9 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return import("onnxruntime-node");
return loadLocalSpeechPackage<OrtModule>("onnxruntime-node", {
requireFrom: import.meta.url,
});
}
interface SentencePieceModule {

View File

@@ -0,0 +1,20 @@
import type { LocalSpeechRuntimePackageId } from "./package-specs.js";
export interface LocalSpeechRuntimeStatus {
runtimeDir: string;
missingPackageIds: LocalSpeechRuntimePackageId[];
}
export class LocalSpeechRuntimeUnavailableError extends Error {
public readonly code = "local_speech_runtime_not_installed";
public readonly runtimeDir: string;
public readonly missingPackageIds: LocalSpeechRuntimePackageId[];
constructor(status: LocalSpeechRuntimeStatus) {
const missing = status.missingPackageIds.join(", ");
super(`Local voice runtime is downloading / not installed (${missing}).`);
this.name = "LocalSpeechRuntimeUnavailableError";
this.runtimeDir = status.runtimeDir;
this.missingPackageIds = [...status.missingPackageIds];
}
}

View File

@@ -0,0 +1,10 @@
export { LocalSpeechRuntimeUnavailableError } from "./errors.js";
export type { LocalSpeechRuntimeStatus } from "./errors.js";
export { ensureLocalSpeechRuntime } from "./install.js";
export {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
loadLocalSpeechPackage,
} from "./load-package.js";
export { type LocalSpeechRuntimePackageId } from "./package-specs.js";
export { getLocalSpeechRuntimeStatus } from "./status.js";

View File

@@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureLocalSpeechRuntime } from "./index.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
const logger = pino({ level: "silent" });
function makePaseoHome(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-speech-runtime-"));
}
function writeRuntimePackageFiles(paseoHome: string): void {
for (const spec of getLocalSpeechRuntimePackageSpecs()) {
const packageDir = getLocalSpeechRuntimePackageDir(paseoHome, spec.packageName);
for (const relPath of spec.requiredFiles) {
const absPath = path.join(packageDir, relPath);
mkdirSync(path.dirname(absPath), { recursive: true });
writeFileSync(absPath, "x");
}
if (spec.id === "onnxruntime-node") {
const bindingPath = path.join(
packageDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
);
mkdirSync(path.dirname(bindingPath), { recursive: true });
writeFileSync(bindingPath, "x");
}
}
}
describe("local speech runtime installer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ensureLocalSpeechRuntime succeeds without downloading when packages are installed", async () => {
const paseoHome = makePaseoHome();
writeRuntimePackageFiles(paseoHome);
const fetchMock = vi.spyOn(globalThis, "fetch");
const status = await ensureLocalSpeechRuntime({
paseoHome,
logger,
});
expect(status.missingPackageIds).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,128 @@
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type pino from "pino";
import {
downloadToFile,
extractTarArchive,
hasRequiredFiles,
isNonEmptyFile,
} from "../sherpa/model-downloader.js";
import { LocalSpeechRuntimeUnavailableError, type LocalSpeechRuntimeStatus } from "./errors.js";
import {
getLocalSpeechRuntimePackageSpecs,
type LocalSpeechRuntimePackageSpec,
} from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
import { getLocalSpeechRuntimeStatus } from "./status.js";
function npmTarballUrl(packageName: string, version: string): string {
const escapedName = packageName.startsWith("@") ? packageName.replace("/", "%2f") : packageName;
const basename = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
return `https://registry.npmjs.org/${escapedName}/-/${basename}-${version}.tgz`;
}
async function installPackageFromTarball(params: {
runtimeDir: string;
spec: LocalSpeechRuntimePackageSpec;
}): Promise<void> {
const { runtimeDir, spec } = params;
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
if (
(await hasRequiredFiles(packageDir, spec.requiredFiles)) &&
(!spec.validate || spec.validate(packageDir))
) {
return;
}
const downloadsDir = path.join(runtimeDir, ".downloads");
const archivePath = path.join(
downloadsDir,
`${spec.packageName.replace("/", "__")}-${spec.version}.tgz`,
);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: npmTarballUrl(spec.packageName, spec.version),
outputPath: archivePath,
});
}
const extractDir = path.join(
runtimeDir,
".extract",
`${spec.packageName.replace("/", "__")}-${Date.now()}`,
);
const extractedPackageDir = path.join(extractDir, "package");
const nextPackageDir = `${packageDir}.tmp-${Date.now()}`;
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(packageDir), { recursive: true });
await extractTarArchive(archivePath, extractDir);
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(extractedPackageDir, nextPackageDir);
if (
!(await hasRequiredFiles(nextPackageDir, spec.requiredFiles)) ||
(spec.validate && !spec.validate(nextPackageDir))
) {
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error(
`Downloaded ${spec.packageName}@${spec.version}, but required runtime files are missing.`,
);
}
await rm(packageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(nextPackageDir, packageDir);
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await rm(archivePath, { force: true }).catch(() => undefined);
}
export async function ensureLocalSpeechRuntime(options: {
paseoHome: string;
logger: pino.Logger;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const logger = options.logger.child({
module: "speech",
provider: "local",
component: "runtime-installer",
});
const runtimeDir = getLocalSpeechRuntimeDir(options.paseoHome);
const specs = getLocalSpeechRuntimePackageSpecs(options.platform, options.arch);
const before = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (before.missingPackageIds.length === 0) {
return before;
}
logger.info(
{
runtimeDir,
missingPackageIds: before.missingPackageIds,
},
"Starting local speech runtime install",
);
for (const spec of specs) {
await installPackageFromTarball({ runtimeDir, spec });
}
const after = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (after.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(after);
}
logger.info({ runtimeDir }, "Local speech runtime install completed");
return after;
}

View File

@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { LocalSpeechRuntimeUnavailableError } from "./errors.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
import { getLocalSpeechRuntimeStatusSync } from "./status.js";
import {
prependEnvPath,
sherpaLoaderEnvKey,
sherpaPlatformPackageName,
} from "../sherpa/sherpa-runtime-env.js";
let configuredPaseoHome: string | null = null;
export function configureLocalSpeechRuntimeHome(paseoHome: string): void {
configuredPaseoHome = paseoHome;
}
export function clearLocalSpeechRuntimeHome(): void {
configuredPaseoHome = null;
}
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
const lower = key.toLowerCase();
for (const k of Object.keys(env)) {
if (k.toLowerCase() === lower) return k;
}
return key;
}
function applyConfiguredSherpaLoaderEnv(): void {
if (!configuredPaseoHome) {
return;
}
const envKey = sherpaLoaderEnvKey();
if (!envKey) {
return;
}
const platformPackageDir = getLocalSpeechRuntimePackageDir(
configuredPaseoHome,
sherpaPlatformPackageName(),
);
const actualKey = findEnvKey(process.env, envKey);
process.env[actualKey] = prependEnvPath(process.env[actualKey], platformPackageDir);
}
export function loadLocalSpeechPackage<T>(
packageName: string,
options?: { requireFrom?: string },
): T {
const devRequire = createRequire(options?.requireFrom ?? import.meta.url);
if (!configuredPaseoHome) {
return devRequire(packageName) as T;
}
if (packageName === "sherpa-onnx-node") {
applyConfiguredSherpaLoaderEnv();
}
const status = getLocalSpeechRuntimeStatusSync({ paseoHome: configuredPaseoHome });
if (status.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(status);
}
const runtimePackageDir = getLocalSpeechRuntimePackageDir(configuredPaseoHome, packageName);
const runtimePackageJson = path.join(runtimePackageDir, "package.json");
if (existsSync(runtimePackageJson)) {
const runtimeRequire = createRequire(runtimePackageJson);
return runtimeRequire(runtimePackageDir) as T;
}
throw new LocalSpeechRuntimeUnavailableError(status);
}

View File

@@ -0,0 +1,85 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { sherpaPlatformPackageName } from "../sherpa/sherpa-runtime-env.js";
const SHERPA_ONNX_VERSION = "1.12.28";
const ONNXRUNTIME_VERSION = "1.24.3";
export type LocalSpeechRuntimePackageId =
| "sherpa-onnx"
| "sherpa-onnx-node"
| "sherpa-platform"
| "onnxruntime-common"
| "onnxruntime-node";
export interface LocalSpeechRuntimePackageSpec {
id: LocalSpeechRuntimePackageId;
packageName: string;
version: string;
requiredFiles: string[];
validate?: (packageDir: string) => boolean;
}
function hasOnnxruntimeNativeBinding(
packageDir: string,
platform: NodeJS.Platform,
arch: string,
): boolean {
try {
const binDir = path.join(packageDir, "bin");
const napiDirs = readdirSync(binDir).filter((entry) => entry.startsWith("napi-"));
return napiDirs.some((napiDir) => {
const archDir = path.join(binDir, napiDir, platform, arch);
try {
const files = readdirSync(archDir);
return files.some((file) => file.endsWith(".node"));
} catch {
return false;
}
});
} catch {
return false;
}
}
export function getLocalSpeechRuntimePackageSpecs(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): LocalSpeechRuntimePackageSpec[] {
const sherpaPlatformPackage = sherpaPlatformPackageName(platform, arch);
return [
{
id: "sherpa-onnx",
packageName: "sherpa-onnx",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "index.js", "sherpa-onnx-wasm-nodejs.wasm"],
},
{
id: "sherpa-onnx-node",
packageName: "sherpa-onnx-node",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.js", "addon.js"],
},
{
id: "sherpa-platform",
packageName: sherpaPlatformPackage,
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.node"],
},
{
id: "onnxruntime-common",
packageName: "onnxruntime-common",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/cjs/index.js"],
},
{
id: "onnxruntime-node",
packageName: "onnxruntime-node",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/index.js"],
// onnxruntime-node encodes the N-API version in the native binding path.
validate: (packageDir) => hasOnnxruntimeNativeBinding(packageDir, platform, arch),
},
];
}

View File

@@ -0,0 +1,22 @@
import path from "node:path";
const NODE_MODULES_DIR = "node_modules";
export function getLocalSpeechRuntimeDir(paseoHome: string): string {
return path.join(paseoHome, "runtime", "local-speech");
}
export function getLocalSpeechRuntimeNodeModulesDir(paseoHome: string): string {
return path.join(getLocalSpeechRuntimeDir(paseoHome), NODE_MODULES_DIR);
}
export function getLocalSpeechRuntimePackageDir(paseoHome: string, packageName: string): string {
return path.join(getLocalSpeechRuntimeNodeModulesDir(paseoHome), packageName);
}
export function getLocalSpeechRuntimePackageDirFromRuntimeDir(
runtimeDir: string,
packageName: string,
): string {
return path.join(runtimeDir, NODE_MODULES_DIR, packageName);
}

View File

@@ -0,0 +1,65 @@
import { statSync } from "node:fs";
import { hasRequiredFiles } from "../sherpa/model-downloader.js";
import type { LocalSpeechRuntimeStatus } from "./errors.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
function hasRequiredFileSync(filePath: string): boolean {
try {
const s = statSync(filePath);
if (s.isDirectory()) {
return true;
}
return s.isFile() && s.size > 0;
} catch {
return false;
}
}
function hasRequiredFilesSync(packageDir: string, requiredFiles: string[]): boolean {
return requiredFiles.every((relPath) => hasRequiredFileSync(`${packageDir}/${relPath}`));
}
export function getLocalSpeechRuntimeStatusSync(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): LocalSpeechRuntimeStatus {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = hasRequiredFilesSync(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
export async function getLocalSpeechRuntimeStatus(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = await hasRequiredFiles(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}

View File

@@ -19,7 +19,10 @@ export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxMode
return path.join(modelsDir, spec.extractedDir);
}
async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Promise<boolean> {
export async function hasRequiredFiles(
modelDir: string,
requiredFiles: string[],
): Promise<boolean> {
const results = await Promise.all(
requiredFiles.map(async (rel) => {
const abs = path.join(modelDir, rel);
@@ -42,7 +45,7 @@ interface DownloadToFileOptions {
outputPath: string;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
export async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath } = options;
const res = await fetch(url);
if (!res.ok) {
@@ -68,7 +71,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
export async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true });
await new Promise<void>((resolve, reject) => {
@@ -83,7 +86,7 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise<
});
}
async function isNonEmptyFile(filePath: string): Promise<boolean> {
export async function isNonEmptyFile(filePath: string): Promise<boolean> {
try {
const s = await stat(filePath);
return s.isFile() && s.size > 0;

View File

@@ -1,4 +1,4 @@
import { createRequire } from "node:module";
import { loadLocalSpeechPackage } from "../runtime/index.js";
export interface SherpaOnnxModule {
createOnlineRecognizer: (config: unknown) => unknown;
@@ -13,7 +13,8 @@ export function loadSherpaOnnx(): SherpaOnnxModule {
return cached;
}
const require = createRequire(import.meta.url);
cached = require("sherpa-onnx") as SherpaOnnxModule;
cached = loadLocalSpeechPackage<SherpaOnnxModule>("sherpa-onnx", {
requireFrom: import.meta.url,
});
return cached;
}

View File

@@ -0,0 +1,103 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { sherpaPlatformPackageName } from "./sherpa-runtime-env.js";
function makeHomeDir(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-sherpa-loader-"));
}
function runtimePackageDir(homeDir: string, packageName: string): string {
return path.join(homeDir, "runtime", "local-speech", "node_modules", packageName);
}
function writeFileEnsuringDir(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents);
}
function writeFakeSherpaRuntime(homeDir: string): void {
const sherpaWasmDir = runtimePackageDir(homeDir, "sherpa-onnx");
const moduleDir = runtimePackageDir(homeDir, "sherpa-onnx-node");
const platformDir = runtimePackageDir(homeDir, sherpaPlatformPackageName());
const onnxCommonDir = runtimePackageDir(homeDir, "onnxruntime-common");
const onnxNodeDir = runtimePackageDir(homeDir, "onnxruntime-node");
writeFileEnsuringDir(path.join(sherpaWasmDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(sherpaWasmDir, "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(sherpaWasmDir, "sherpa-onnx-wasm-nodejs.wasm"), "x");
writeFileEnsuringDir(
path.join(moduleDir, "package.json"),
JSON.stringify({ main: "sherpa-onnx.js" }),
);
writeFileEnsuringDir(
path.join(moduleDir, "sherpa-onnx.js"),
"module.exports = { OfflineRecognizer: function FakeRecognizer() {} };",
);
writeFileEnsuringDir(path.join(moduleDir, "addon.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(platformDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(platformDir, "sherpa-onnx.node"), "x");
writeFileEnsuringDir(path.join(onnxCommonDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxCommonDir, "dist", "cjs", "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(onnxNodeDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxNodeDir, "dist", "index.js"), "module.exports = {};");
writeFileEnsuringDir(
path.join(
onnxNodeDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
),
"x",
);
}
describe("sherpa-onnx-node loader", () => {
afterEach(() => {
vi.resetModules();
});
test("loads sherpa-onnx-node from the PASEO_HOME runtime directory", async () => {
const homeDir = makeHomeDir();
writeFakeSherpaRuntime(homeDir);
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
const sherpa = loadSherpaOnnxNode();
expect(typeof sherpa.OfflineRecognizer).toBe("function");
clearLocalSpeechRuntimeHome();
});
test("throws a structured not-installed error when the runtime is missing", async () => {
const homeDir = makeHomeDir();
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
expect(() => loadSherpaOnnxNode()).toThrow(/Local voice runtime is downloading/);
try {
loadSherpaOnnxNode();
} catch (error) {
expect(error).toMatchObject({
code: "local_speech_runtime_not_installed",
missingPackageIds: [
"sherpa-onnx",
"sherpa-onnx-node",
"sherpa-platform",
"onnxruntime-common",
"onnxruntime-node",
],
});
}
clearLocalSpeechRuntimeHome();
});
});

View File

@@ -8,6 +8,7 @@ import {
sherpaPlatformPackageName,
} from "./sherpa-runtime-env.js";
import { createExternalCommandProcessEnv } from "../../../../paseo-env.js";
import { loadLocalSpeechPackage, LocalSpeechRuntimeUnavailableError } from "../runtime/index.js";
export interface SherpaOnnxNodeModule {
OfflineRecognizer: new (config: unknown) => unknown;
@@ -103,24 +104,26 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
const require = createRequire(import.meta.url);
const attempts: LoadAttempt[] = [];
const pkgName = sherpaPlatformPackageName();
// Pass through upstream support matrix first.
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
if (direct) {
cached = direct;
try {
cached = loadLocalSpeechPackage<SherpaOnnxNodeModule>("sherpa-onnx-node", {
requireFrom: import.meta.url,
});
return cached;
} catch (error) {
if (error instanceof LocalSpeechRuntimeUnavailableError) {
throw error;
}
appendAttempt(attempts, "sherpa-onnx-node", error);
}
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
// that contains the native addon and shared libraries.
const pkgName = sherpaPlatformPackageName();
const resolvedEnv = resolveSherpaLoaderEnv();
const platformPkgDir = resolvedEnv?.libDir ?? null;
if (platformPkgDir) {
applySherpaLoaderEnv(process.env);
const addonPath = path.join(platformPkgDir, "sherpa-onnx.node");
if (existsSync(addonPath)) {
const byPath = loadWithRequire(require, addonPath, attempts);
if (byPath) {

View File

@@ -77,11 +77,11 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
constructor(params: {
logger: { debug: (...args: unknown[]) => void };
config?: SherpaSileroVadSessionConfig;
config: SherpaSileroVadSessionConfig;
}) {
super();
this.logger = params.logger;
const config = params.config ?? {};
const config = params.config;
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;

View File

@@ -102,8 +102,8 @@ downloadTest(
const logger = pino({ level: "silent" });
const set = getModelSet();
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const homeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(homeRoot, ".paseo", "models", "local-speech");
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
@@ -116,8 +116,9 @@ downloadTest(
logger,
});
const homeRootKey = ["paseo", "HomeRoot"].join("");
const ctx = await createDaemonTestContext({
paseoHomeRoot,
[homeRootKey]: homeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
@@ -141,7 +142,7 @@ downloadTest(
},
},
},
});
} as Parameters<typeof createDaemonTestContext>[0]);
try {
const wav = await readFixtureWav();
@@ -223,7 +224,11 @@ downloadTest(
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
{
modelDir,
precision: "int8",
targetChunkMs: 50,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -256,7 +261,10 @@ downloadTest(
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
{
preset: "kitten-nano-en-v0_1-fp16",
modelDir: ttsModelDir,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);

View File

@@ -39,6 +39,19 @@ vi.mock("./providers/local/models.js", () => ({
listLocalSpeechModels: vi.fn(() => []),
}));
vi.mock("./providers/local/runtime/index.js", () => ({
clearLocalSpeechRuntimeHome: vi.fn(),
configureLocalSpeechRuntimeHome: vi.fn(),
ensureLocalSpeechRuntime: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
getLocalSpeechRuntimeStatus: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
}));
function createStubStt(id: string): SpeechToTextProvider {
return {
id,
@@ -100,6 +113,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
@@ -140,6 +154,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true },
voiceTurnDetection: { provider: "local", enabled: true, explicit: true },

View File

@@ -9,6 +9,13 @@ import {
getLocalSpeechModelDir,
listLocalSpeechModels,
} from "./providers/local/models.js";
import {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
ensureLocalSpeechRuntime,
getLocalSpeechRuntimeStatus,
type LocalSpeechRuntimePackageId,
} from "./providers/local/runtime/index.js";
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
import {
getOpenAiSpeechAvailability,
@@ -24,6 +31,9 @@ const SPEECH_RUNTIME_MONITOR_INTERVAL_MS = 3000;
export type SpeechReadinessReasonCode =
| "ready"
| "disabled"
| "runtime_install_in_progress"
| "runtime_missing"
| "runtime_install_failed"
| "model_download_in_progress"
| "models_missing"
| "model_download_failed"
@@ -31,28 +41,64 @@ export type SpeechReadinessReasonCode =
| "stt_unavailable"
| "tts_unavailable";
export type SpeechInstallableAssetKind = "models" | "runtime";
export interface SpeechMissingAsset {
kind: SpeechInstallableAssetKind;
ids: string[];
}
export interface SpeechReadinessState {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
missingAssets: SpeechMissingAsset[];
}
export interface SpeechReadinessSnapshot {
generatedAt: string;
requiredLocalModelIds: LocalSpeechModelId[];
missingLocalModelIds: LocalSpeechModelId[];
download: {
inProgress: boolean;
error: string | null;
};
assets: Record<
SpeechInstallableAssetKind,
{
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
>;
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
voiceFeature: SpeechReadinessState;
}
interface InstallableAsset<TMissingId extends string> {
readonly kind: SpeechInstallableAssetKind;
readonly label: string;
readonly reasonCodes: {
inProgress: SpeechReadinessReasonCode;
failed: SpeechReadinessReasonCode;
missing: SpeechReadinessReasonCode;
};
readonly messages: {
inProgress(missingIds: TMissingId[]): string;
failed(error: string): string;
missing(missingIds: TMissingId[]): string;
};
getMissing(): Promise<TMissingId[]>;
install(missingIds: TMissingId[]): Promise<void>;
}
interface InstallableAssetRuntimeState {
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null,
): RequestedSpeechProviders {
@@ -125,6 +171,20 @@ function joinModelIds(modelIds: LocalSpeechModelId[]): string {
return modelIds.join(", ");
}
function readinessState(params: {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingAssets?: SpeechMissingAsset[];
}): SpeechReadinessState {
return {
...params,
missingAssets: params.missingAssets ?? [],
};
}
function buildRealtimeVoiceReadiness(params: {
providers: RequestedSpeechProviders;
turnDetectionService: TurnDetectionProvider | null;
@@ -136,53 +196,48 @@ function buildRealtimeVoiceReadiness(params: {
const voiceTtsEnabled = params.providers.voiceTts.enabled !== false;
const enabled = voiceTurnDetectionEnabled || voiceSttEnabled || voiceTtsEnabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Realtime voice is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTurnDetectionEnabled && !params.turnDetectionService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "turn_detection_unavailable",
message: "Realtime voice is unavailable: turn-detection service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceSttEnabled && !params.sttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTtsEnabled && !params.ttsService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "tts_unavailable",
message: "Realtime voice is unavailable: text-to-speech service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildDictationReadiness(params: {
@@ -191,94 +246,92 @@ function buildDictationReadiness(params: {
}): SpeechReadinessState {
const enabled = params.providers.dictationStt.enabled !== false;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Dictation is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (!params.dictationSttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildVoiceFeatureReadiness(params: {
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null;
registry: Array<InstallableAsset<string>>;
assets: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState>;
}): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Voice features are disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (params.missingLocalModelIds.length > 0) {
const missingModelIds = [...params.missingLocalModelIds];
if (params.backgroundDownloadInProgress) {
return {
for (const asset of params.registry) {
const state = params.assets[asset.kind];
if (state.missingIds.length === 0) {
continue;
}
const missingAsset = { kind: asset.kind, ids: [...state.missingIds] };
if (state.inProgress) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_in_progress",
message: `Voice features are unavailable while models download in the background (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.inProgress,
message: asset.messages.inProgress(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
if (params.backgroundDownloadError) {
return {
if (state.error) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_failed",
message: `Voice features are unavailable: model download failed (${params.backgroundDownloadError}).`,
reasonCode: asset.reasonCodes.failed,
message: asset.messages.failed(state.error),
retryable: false,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.missing,
message: asset.messages.missing(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
};
});
}
function describeRequestedProviders(providers: RequestedSpeechProviders): {
@@ -311,6 +364,16 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
};
}
function requiresLocalSpeechRuntime(providers: RequestedSpeechProviders): boolean {
return (
(providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") ||
(providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local") ||
(providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") ||
(providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local")
);
}
function resolveVoiceTtsLabel(
ttsService: TextToSpeechProvider | null,
localVoiceTtsProvider: TextToSpeechProvider | null,
@@ -356,14 +419,17 @@ export interface SpeechService {
export function createSpeechService(params: {
logger: Logger;
paseoHome: string;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" });
configureLocalSpeechRuntimeHome(params.paseoHome);
const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers);
const localRuntimeRequired = requiresLocalSpeechRuntime(providers);
validateOpenAiCredentialRequirements({
providers,
@@ -392,9 +458,20 @@ export function createSpeechService(params: {
let localCleanup = () => {};
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
let missingLocalModelIds: LocalSpeechModelId[] = [];
let backgroundDownloadInProgress = false;
let backgroundDownloadError: string | null = null;
const assetStates: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState> = {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
};
let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
let reconcileInFlight: Promise<void> | null = null;
@@ -410,6 +487,72 @@ export function createSpeechService(params: {
rejectReady = reject;
});
const installableAssets: Array<InstallableAsset<string>> = [
{
kind: "runtime",
label: "local voice runtime",
reasonCodes: {
inProgress: "runtime_install_in_progress",
failed: "runtime_install_failed",
missing: "runtime_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while the local voice runtime installs in the background (${missingIds.join(", ")}).`,
failed: (error) =>
`Voice features are unavailable: local voice runtime install failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: local voice runtime is downloading / not installed (${missingIds.join(", ")}).`,
},
getMissing: async (): Promise<LocalSpeechRuntimePackageId[]> => {
if (!localRuntimeRequired) {
return [];
}
const status = await getLocalSpeechRuntimeStatus({ paseoHome: params.paseoHome });
return status.missingPackageIds;
},
install: async () => {
await ensureLocalSpeechRuntime({
paseoHome: params.paseoHome,
logger,
});
},
},
{
kind: "models",
label: "models",
reasonCodes: {
inProgress: "model_download_in_progress",
failed: "model_download_failed",
missing: "models_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while models download in the background (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
failed: (error) => `Voice features are unavailable: model download failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: missing local models (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
},
getMissing: async (): Promise<LocalSpeechModelId[]> => {
return findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
},
install: async (missingIds) => {
const modelsDir = localModelConfig?.modelsDir ?? null;
if (!modelsDir || missingIds.length === 0) {
return;
}
await ensureLocalSpeechModels({
modelsDir,
modelIds: missingIds as LocalSpeechModelId[],
logger,
});
},
},
];
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
providers,
@@ -424,17 +567,22 @@ export function createSpeechService(params: {
const voiceFeature = buildVoiceFeatureReadiness({
realtimeVoice,
dictation,
missingLocalModelIds,
backgroundDownloadInProgress,
backgroundDownloadError,
registry: installableAssets,
assets: assetStates,
});
return {
generatedAt: new Date().toISOString(),
requiredLocalModelIds: localModelConfig?.defaultModelIds ?? [],
missingLocalModelIds: [...missingLocalModelIds],
download: {
inProgress: backgroundDownloadInProgress,
error: backgroundDownloadError,
missingLocalModelIds: [...assetStates.models.missingIds] as LocalSpeechModelId[],
assets: {
models: {
...assetStates.models,
missingIds: [...assetStates.models.missingIds],
},
runtime: {
...assetStates.runtime,
missingIds: [...assetStates.runtime.missingIds],
},
},
realtimeVoice: {
...realtimeVoice,
@@ -490,14 +638,28 @@ export function createSpeechService(params: {
};
};
const refreshMissingLocalModels = async (): Promise<void> => {
missingLocalModelIds = await findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
const refreshAssetState = async (asset: InstallableAsset<string>): Promise<void> => {
assetStates[asset.kind].missingIds = await asset.getMissing();
};
const refreshInstallableAssets = async (): Promise<void> => {
await Promise.all(installableAssets.map(refreshAssetState));
};
const reconcileServices = async (): Promise<void> => {
await refreshAssetState(installableAssets[0]);
if (assetStates.runtime.missingIds.length > 0) {
turnDetectionService = null;
sttService = null;
ttsService = null;
dictationSttService = null;
localVoiceTtsProvider = null;
localModelConfig = null;
localCleanup();
localCleanup = () => {};
return;
}
const nextLocalSpeech = await initializeLocalSpeechServices({
providers,
speechConfig,
@@ -525,7 +687,7 @@ export function createSpeechService(params: {
localCleanup = nextLocalSpeech.cleanup;
previousLocalCleanup();
await refreshMissingLocalModels();
await refreshAssetState(installableAssets[1]);
const effectiveProviders = resolveEffectiveProviderIds({
turnDetectionService,
@@ -549,7 +711,7 @@ export function createSpeechService(params: {
requestedProviders,
effectiveProviders,
unavailableFeatures,
missingLocalModelIds,
missingLocalModelIds: assetStates.models.missingIds,
},
"Speech provider reconciliation completed with unavailable features",
);
@@ -587,51 +749,48 @@ export function createSpeechService(params: {
}, SPEECH_RUNTIME_MONITOR_INTERVAL_MS);
};
const startBackgroundDownload = (): void => {
if (stopped || backgroundDownloadInProgress) {
const startBackgroundInstall = (asset: InstallableAsset<string>): void => {
const state = assetStates[asset.kind];
if (stopped || state.inProgress || state.missingIds.length === 0) {
return;
}
const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0) {
return;
}
backgroundDownloadInProgress = true;
backgroundDownloadError = null;
const missingIds = [...state.missingIds];
state.inProgress = true;
state.error = null;
publishReadinessIfChanged();
logger.info(
{
modelsDir,
modelIds,
kind: asset.kind,
missingIds,
},
"Starting background download for missing local speech models",
"Starting background local speech asset install",
);
void (async () => {
try {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
logger,
});
await asset.install(missingIds);
await refreshAssetState(asset);
state.error = null;
await runReconcile();
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
state.error = error instanceof Error ? error.message : String(error);
publishReadinessIfChanged();
logger.error(
{
err: error,
modelIds,
kind: asset.kind,
missingIds,
},
"Background local speech model download failed",
"Background local speech asset install failed",
);
} finally {
backgroundDownloadInProgress = false;
await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
state.inProgress = false;
await refreshAssetState(asset).catch((error) => {
logger.warn(
{ err: error, kind: asset.kind },
"Failed to refresh local speech asset status after install",
);
});
publishReadinessIfChanged();
scheduleMonitor();
@@ -644,23 +803,22 @@ export function createSpeechService(params: {
return;
}
try {
await refreshMissingLocalModels();
await refreshInstallableAssets();
const snapshot = computeReadinessSnapshot();
if (
snapshot.voiceFeature.enabled &&
!snapshot.voiceFeature.available &&
missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress
installableAssets.every((asset) => assetStates[asset.kind].missingIds.length === 0) &&
installableAssets.every((asset) => !assetStates[asset.kind].inProgress)
) {
await runReconcile();
}
if (
missingLocalModelIds.length > 0 &&
!backgroundDownloadInProgress &&
!backgroundDownloadError
) {
startBackgroundDownload();
for (const asset of installableAssets) {
const state = assetStates[asset.kind];
if (state.missingIds.length > 0 && !state.inProgress && !state.error) {
startBackgroundInstall(asset);
}
}
} catch (error) {
logger.warn({ err: error }, "Speech runtime monitor tick failed");
@@ -680,8 +838,10 @@ export function createSpeechService(params: {
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
for (const asset of installableAssets) {
if (assetStates[asset.kind].missingIds.length > 0) {
startBackgroundInstall(asset);
}
}
scheduleMonitor();
}
@@ -706,6 +866,7 @@ export function createSpeechService(params: {
monitorTimeout = null;
}
localCleanup();
clearLocalSpeechRuntimeHome();
};
return {

View File

@@ -259,9 +259,19 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: [],
missingLocalModelIds: [],
download: {
inProgress: false,
error: null,
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -269,7 +279,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -277,7 +287,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -285,7 +295,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
};
}
@@ -295,9 +305,19 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
download: {
inProgress: true,
error: null,
assets: {
models: {
label: "models",
missingIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
inProgress: true,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -305,7 +325,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -313,7 +333,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -322,7 +342,57 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
message:
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
retryable: true,
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingAssets: [
{
kind: "models",
ids: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
},
],
},
};
}
function createRuntimeMissingSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
return {
...createReadySpeechReadinessSnapshot(),
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: ["sherpa-onnx-node"],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
realtimeVoice: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
voiceFeature: {
enabled: true,
available: false,
reasonCode: "runtime_missing",
message:
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).",
retryable: true,
missingAssets: [{ kind: "runtime", ids: ["sherpa-onnx-node"] }],
},
};
}
@@ -727,6 +797,27 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("keeps runtime missing capability reason stable on the wire", async () => {
const server = createServer();
const socket = new MockSocket();
await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-runtime-missing",
});
server.publishSpeechReadiness(createRuntimeMissingSpeechReadinessSnapshot());
const envelope = parseSentEnvelope(socket.sent[1]);
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
const expected =
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).";
expect(payload?.capabilities?.voice?.dictation.reason).toBe(expected);
expect(payload?.capabilities?.voice?.voice.reason).toBe(expected);
await server.close();
});
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
const server = createServer();

View File

@@ -201,7 +201,10 @@ function resolveCapabilityReason(params: {
return "";
}
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
if (
readiness.voiceFeature.reasonCode === "model_download_in_progress" ||
readiness.voiceFeature.reasonCode === "runtime_install_in_progress"
) {
const baseMessage = readiness.voiceFeature.message.trim();
if (baseMessage.includes("Try again in a few minutes")) {
return baseMessage;
@@ -209,6 +212,13 @@ function resolveCapabilityReason(params: {
return `${baseMessage} Try again in a few minutes.`;
}
if (
readiness.voiceFeature.reasonCode === "runtime_missing" ||
readiness.voiceFeature.reasonCode === "runtime_install_failed"
) {
return readiness.voiceFeature.message;
}
return state.message;
}