diff --git a/docs/data-model.md b/docs/data-model.md index c11d7a65fe..ebdb61b14a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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--` 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. diff --git a/package-lock.json b/package-lock.json index b0eb94c977..05166d1218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/packages/server/package.json b/packages/server/package.json index 8dbbe6e9a6..6722c03415 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 95e0d4d00b..78028de0cc 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -841,6 +841,7 @@ export async function createPaseoDaemon( const speechService = createSpeechService({ logger, + paseoHome: config.paseoHome, openaiConfig: config.openai, speechConfig: config.speech, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c7f41183ca..c3f916d813 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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[], }; } diff --git a/packages/server/src/server/speech/providers/local/pocket/pocket-tts-onnx.ts b/packages/server/src/server/speech/providers/local/pocket/pocket-tts-onnx.ts index 6357651204..e1f10c187a 100644 --- a/packages/server/src/server/speech/providers/local/pocket/pocket-tts-onnx.ts +++ b/packages/server/src/server/speech/providers/local/pocket/pocket-tts-onnx.ts @@ -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 { - return import("onnxruntime-node"); + return loadLocalSpeechPackage("onnxruntime-node", { + requireFrom: import.meta.url, + }); } interface SentencePieceModule { diff --git a/packages/server/src/server/speech/providers/local/runtime/errors.ts b/packages/server/src/server/speech/providers/local/runtime/errors.ts new file mode 100644 index 0000000000..68f57c8c40 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/errors.ts @@ -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]; + } +} diff --git a/packages/server/src/server/speech/providers/local/runtime/index.ts b/packages/server/src/server/speech/providers/local/runtime/index.ts new file mode 100644 index 0000000000..8c22dd4a0f --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/index.ts @@ -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"; diff --git a/packages/server/src/server/speech/providers/local/runtime/install.test.ts b/packages/server/src/server/speech/providers/local/runtime/install.test.ts new file mode 100644 index 0000000000..ccaeb64a56 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/install.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/runtime/install.ts b/packages/server/src/server/speech/providers/local/runtime/install.ts new file mode 100644 index 0000000000..01fc324cfb --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/install.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/server/src/server/speech/providers/local/runtime/load-package.ts b/packages/server/src/server/speech/providers/local/runtime/load-package.ts new file mode 100644 index 0000000000..f3c1d5d20a --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/load-package.ts @@ -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( + 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); +} diff --git a/packages/server/src/server/speech/providers/local/runtime/package-specs.ts b/packages/server/src/server/speech/providers/local/runtime/package-specs.ts new file mode 100644 index 0000000000..68eeafc623 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/package-specs.ts @@ -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), + }, + ]; +} diff --git a/packages/server/src/server/speech/providers/local/runtime/paths.ts b/packages/server/src/server/speech/providers/local/runtime/paths.ts new file mode 100644 index 0000000000..79cb600f3c --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/paths.ts @@ -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); +} diff --git a/packages/server/src/server/speech/providers/local/runtime/status.ts b/packages/server/src/server/speech/providers/local/runtime/status.ts new file mode 100644 index 0000000000..66fa861e09 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/runtime/status.ts @@ -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 { + 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, + }; +} diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 232c55be5d..2a9dfdc084 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -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 { +export async function hasRequiredFiles( + modelDir: string, + requiredFiles: string[], +): Promise { 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 { +export async function downloadToFile(options: DownloadToFileOptions): Promise { const { url, outputPath } = options; const res = await fetch(url); if (!res.ok) { @@ -68,7 +71,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { } } -async function extractTarArchive(archivePath: string, destDir: string): Promise { +export async function extractTarArchive(archivePath: string, destDir: string): Promise { await mkdir(destDir, { recursive: true }); await new Promise((resolve, reject) => { @@ -83,7 +86,7 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise< }); } -async function isNonEmptyFile(filePath: string): Promise { +export async function isNonEmptyFile(filePath: string): Promise { try { const s = await stat(filePath); return s.isFile() && s.size > 0; diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-loader.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-loader.ts index a8c25ba5ff..bef6418c86 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-loader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-loader.ts @@ -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("sherpa-onnx", { + requireFrom: import.meta.url, + }); return cached; } diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.test.ts new file mode 100644 index 0000000000..b4e30ee9c0 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts index de92b0f176..7d57038b10 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts @@ -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("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) { diff --git a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts index 16c248b9d6..aa7deefb12 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts @@ -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; diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index 97d33923e0..85771dacd2 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -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[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); diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index f368d5977e..7fd3e1aaee 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -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 }, diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 8da70afd8d..7d5ee8fed6 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -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 { + 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; + install(missingIds: TMissingId[]): Promise; +} + +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>; + assets: Record; }): 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 = { + models: { + label: "models", + missingIds: [], + inProgress: false, + error: null, + }, + runtime: { + label: "local voice runtime", + missingIds: [], + inProgress: false, + error: null, + }, + }; let stopped = false; let monitorTimeout: ReturnType | null = null; let reconcileInFlight: Promise | null = null; @@ -410,6 +487,72 @@ export function createSpeechService(params: { rejectReady = reject; }); + const installableAssets: Array> = [ + { + 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 => { + 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 => { + 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 => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + const refreshAssetState = async (asset: InstallableAsset): Promise => { + assetStates[asset.kind].missingIds = await asset.getMissing(); + }; + + const refreshInstallableAssets = async (): Promise => { + await Promise.all(installableAssets.map(refreshAssetState)); }; const reconcileServices = async (): Promise => { + 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): 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 { diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 0a44802f7f..9759893231 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -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(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7695f7368a..8856acef0d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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; }