diff --git a/.changeset/fn-9039-voice-runtime-interop.md b/.changeset/fn-9039-voice-runtime-interop.md new file mode 100644 index 0000000000..0e2f71dcf8 --- /dev/null +++ b/.changeset/fn-9039-voice-runtime-interop.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Voice input no longer reports a healthy speech runtime as incompatible. +category: fix +dev: Unwraps the sherpa CommonJS binding and adds POST /voice/runtime/recheck. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ed950c7e9e..ede18baa32 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -43,7 +43,7 @@ Settings form changes save automatically after a short pause. The footer no long ## Voice Input -**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Its upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` archive is about 465 MB and Fusion verifies its pinned SHA-256 before installing it; an unpinned or mismatched download is refused. Download progress is polled only while the model is downloading. The toggle becomes interactive only when the model is installed and Fusion can load the optional `sherpa-onnx-node` runtime. If Settings reports a missing module, a platform runtime load failure, or an incompatible runtime, reinstall a supported Fusion package for the current platform and reopen Settings. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available. +**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Its upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` archive is about 465 MB and Fusion verifies its pinned SHA-256 before installing it; an unpinned or mismatched download is refused. Download progress is polled only while the model is downloading. The toggle becomes interactive only when the model is installed and Fusion can load the optional `sherpa-onnx-node` runtime. Fusion unwraps its CommonJS binding automatically, so an incompatible-runtime message indicates a genuinely broken addon. If Settings reports a missing module, platform runtime load failure, or incompatible runtime, repair the Fusion installation and use **Re-check runtime** to retry a previously failed runtime import without restarting. Node caches successfully resolved native modules, so a resolved-but-broken addon still requires a Fusion restart. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available. When Voice Input is available, every microphone capture remains scoped to the dashboard's selected project: status, session creation, PCM transcription, finalization, and cleanup all use that project identity. The mic is shown only after that project's voice preference is enabled, the Parakeet model is installed, and the browser supports microphone and AudioWorklet capture. Unsupported browsers, denied microphone permission, unavailable runtime/model, and status failures show no microphone control. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index db9d89305a..fb12255f78 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -54,10 +54,13 @@ See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, a while disabled. `model` defaults to registry identifier `"parakeet-v3"` and `language` to `"en"`; unsupported values are rejected and never become URLs or paths. An installed model is not by itself sufficient: Project Settings enables the toggle only after the optional `sherpa-onnx-node` runtime -loads successfully. A missing module, platform-addon load failure, or incompatible runtime leaves -voice disabled with a recovery message; install or reinstall a supported Fusion package for the -current platform, then reopen Settings. The optional sherpa runtime and user-scoped cache degrade to -unavailable safely. Downloads are on demand and require a pinned SHA-256; +loads successfully. The runtime probe unwraps `sherpa-onnx-node`'s CommonJS binding, so +`runtime-incompatible` now indicates a genuinely broken addon rather than a healthy package's ESM +namespace shape. A missing module, platform-addon load failure, or incompatible runtime leaves voice +disabled with a recovery message. After repairing an install, use **Re-check runtime** in Settings to +retry a previously failed import without restarting; Node retains successfully resolved native modules, +so a resolved-but-broken addon still requires a Fusion restart. The optional sherpa runtime and +user-scoped cache degrade to unavailable safely. Downloads are on demand and require a pinned SHA-256; unpinned assets refuse download. The default asset is upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` (~465 MB), verified against its pinned SHA-256 before installation. Status polling reports `queued`/`downloading`; deleting fences an in-flight download. Voice chunks alone allow 2 MiB JSON, diff --git a/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.test.tsx b/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.test.tsx index 3399dc241c..053f2ae4f7 100644 --- a/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.test.tsx +++ b/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.test.tsx @@ -54,6 +54,53 @@ describe("VoiceInputSection", () => { expect(screen.queryByRole("button", { name: /Download|Remove/ })).not.toBeInTheDocument(); }); + it.each(["runtime-incompatible", "runtime-platform-load-failed", "runtime-module-missing"])('renders a re-check action for an installed %s runtime', async (unavailableReason) => { + renderSection({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason } }); + expect(await screen.findByRole("button", { name: "Re-check runtime" })).toBeInTheDocument(); + }); + + it("hides runtime re-check when the model, status, or runtime is not actionable", async () => { + const cases = [ + available("installed"), + { model: { status: "not-installed" }, runtime: { status: "unavailable", unavailableReason: "model-not-installed" } }, + {}, + ]; + for (const status of cases) { + const view = renderSection(status); + await screen.findByLabelText("Enable voice input"); + expect(screen.queryByRole("button", { name: /Re-check runtime/ })).not.toBeInTheDocument(); + view.unmount(); + } + }); + + it("re-checks the runtime and refreshes Settings status", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(response({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-incompatible" } })) + .mockResolvedValueOnce(response({ model: { status: "installed" }, runtime: { status: "available" } })) + .mockResolvedValueOnce(response(available("installed"))); + vi.stubGlobal("fetch", fetchMock); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Re-check runtime" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/voice/runtime/recheck", expect.objectContaining({ method: "POST" }))); + await waitFor(() => expect(screen.getByLabelText("Enable voice input")).toBeEnabled()); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("restores the re-check action after a rejected request", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(response({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-module-missing" } })) + .mockRejectedValueOnce(new Error("recheck failed")) + .mockResolvedValueOnce(response({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-module-missing" } })); + vi.stubGlobal("fetch", fetchMock); + render(); + + const button = await screen.findByRole("button", { name: "Re-check runtime" }); + fireEvent.click(button); + await waitFor(() => expect(screen.getByRole("button", { name: "Re-check runtime" })).toBeEnabled()); + expect(screen.getByTestId("voice-input-runtime-unavailable")).toBeInTheDocument(); + }); + it("fails closed for unavailable runtime without rewriting a persisted preference", async () => { const { setForm } = renderSection({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-module-missing" } }, { voiceInput: { enabled: true } }); const toggle = await screen.findByLabelText("Enable voice input"); diff --git a/packages/dashboard/app/components/settings/sections/VoiceInputSection.tsx b/packages/dashboard/app/components/settings/sections/VoiceInputSection.tsx index 95d1e79fa9..f4644493a6 100644 --- a/packages/dashboard/app/components/settings/sections/VoiceInputSection.tsx +++ b/packages/dashboard/app/components/settings/sections/VoiceInputSection.tsx @@ -31,11 +31,16 @@ function isVoiceStatus(value: unknown): value is VoiceStatus { * silently rewriting a preference that may become usable after runtime recovery. * Model controls stay in SettingsFieldRow slots to retain the shared settings-row * contract instead of introducing a parallel panel or row variant. + * + * FNXC:VoiceInput 2026-08-13-23:04: + * Healthy CommonJS runtimes now self-correct during probing. Re-check remains for + * residual repair-then-recover cases and refreshes status without enabling voice. */ export function VoiceInputSection({ form, setForm }: SectionBaseProps) { const { t } = useTranslation("app"); const [status, setStatus] = useState(null); const [statusUnavailable, setStatusUnavailable] = useState(false); + const [recheckingRuntime, setRecheckingRuntime] = useState(false); const mounted = useRef(true); const loadStatus = useCallback(async () => { @@ -97,6 +102,13 @@ export function VoiceInputSection({ form, setForm }: SectionBaseProps) { const performModelAction = async (path: string, method: "POST" | "DELETE") => { try { await api(path, { method }); } finally { await loadStatus(); } }; + const performRuntimeRecheck = async () => { + setRecheckingRuntime(true); + try { await api("/voice/runtime/recheck", { method: "POST" }); } catch {} finally { + await loadStatus(); + if (mounted.current) setRecheckingRuntime(false); + } + }; return

{t("settings.voiceInput.title", "Voice Input")}

@@ -134,6 +146,7 @@ export function VoiceInputSection({ form, setForm }: SectionBaseProps) {
{!statusUnavailable && (modelStatus === "not-installed" || modelStatus === "error") && } {!statusUnavailable && modelStatus === "installed" && } + {!statusUnavailable && modelReady && runtimeUnavailable && }
; diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index ce3a8b0e68..0a86cf7599 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -160,11 +160,14 @@ pnpm --filter @fusion/dashboard exec vitest run src/routes/__tests__/create-api- ## Voice transcription -`registerVoiceRoutes` exposes `GET /voice/status`, `POST`/`DELETE /voice/model`, and dictation -`POST /voice/session`, `POST /voice/transcribe`, and `DELETE /voice/session/:id`. Settings are +`registerVoiceRoutes` exposes `GET /voice/status`, `POST`/`DELETE /voice/model`, +`POST /voice/runtime/recheck`, and dictation `POST /voice/session`, `POST /voice/transcribe`, and +`DELETE /voice/session/:id`. Re-check clears the memoized runtime attempt and returns the same +model/runtime status shape as `GET /voice/status`; it does not close active sessions. Settings are resolved per request through `getScopedStore(req)` with project-over-global precedence. Voice is -opt-in: only dictation endpoints require `voiceInput.enabled`; model inspection, download, and -delete remain available while off because the user-scoped model cache is shared by projects. +opt-in: only dictation endpoints require `voiceInput.enabled`; model inspection, download, delete, +and runtime re-check remain available while off because the user-scoped model cache is shared by +projects. Audio chunks are base64 raw 16 kHz mono signed-16-bit little-endian PCM. Chunks are ordered, limited to 1 MiB (2 MiB JSON body), and sessions are project-bound. Active sessions become diff --git a/packages/dashboard/src/routes/__tests__/register-voice-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-voice-routes.test.ts index f353ac2f11..9bde967488 100644 --- a/packages/dashboard/src/routes/__tests__/register-voice-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-voice-routes.test.ts @@ -58,6 +58,36 @@ describe("voice route authorization split", () => { }); }); + it("re-checks an unavailable installed runtime without requiring voice to be enabled or closing sessions", async () => { + const app = express(); const router = express.Router(); app.use(router); + const resetRuntime = vi.fn(); + const close = vi.fn(); + let available = true; + let enabled = true; + const service = { + getRuntimeStatus: async () => available ? { status: "available" as const } : { status: "unavailable" as const, unavailableReason: "runtime-module-missing" }, + resetRuntime: () => { resetRuntime(); available = true; }, + createSession: async () => ({ acceptChunk: () => ({ partial: "ok" }), finish: () => ({ text: "ok" }), close }), + }; + createRegisterVoiceRoutes({ + manager: { getState: async () => ({ status: "installed" as const, installedPath: "/model" }), peekState: () => ({ status: "installed" as const, installedPath: "/model" }), scheduleDownload: () => ({ accepted: true as const, state: { status: "installed" as const } }), remove: async () => {}, download: async () => ({ status: "installed" as const }), subscribe: () => () => {} }, + service, + })({ router, getScopedStore: async () => ({ getSettings: async () => ({ voiceInput: { enabled } }), getGlobalSettingsStore: () => ({ getSettings: async () => ({}) }) }), getProjectIdFromRequest: () => "recheck-project" } as unknown as ApiRoutesContext); + const server = app.listen(0); servers.push(server); + await new Promise((resolve) => server.once("listening", resolve)); + const request = (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}${path}`, init); + + const session = await request("/voice/session", { method: "POST" }); + expect(session.status).toBe(201); + available = false; + enabled = false; + const recheck = await request("/voice/runtime/recheck", { method: "POST" }); + expect(recheck.status).toBe(200); + await expect(recheck.json()).resolves.toMatchObject({ enabled: false, model: { status: "installed" }, runtime: { status: "available" } }); + expect(resetRuntime).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + }); + it("keeps an ordered partial/final session in its owning project through cleanup", async () => { const app = express(); const router = express.Router(); app.use(router); const acceptChunk = vi.fn((_audio: Buffer, options: { final: boolean }) => options.final ? { text: "final transcript" } : { partial: "partial transcript" }); diff --git a/packages/dashboard/src/routes/register-voice-routes.ts b/packages/dashboard/src/routes/register-voice-routes.ts index 682265f8e7..21334fac31 100644 --- a/packages/dashboard/src/routes/register-voice-routes.ts +++ b/packages/dashboard/src/routes/register-voice-routes.ts @@ -47,7 +47,15 @@ export function createRegisterVoiceRoutes(deps: { manager?: typeof defaultManage const service = deps.service ?? defaultService; return (ctx) => { const { router } = ctx; - router.get("/voice/status", async (req, res) => { const { voice } = await settingsFor(ctx, req); const model = resolveVoiceModelId(voice.model); const language = resolveVoiceLanguage(voice.language); const modelState = manager.peekState().status === "queued" || manager.peekState().status === "downloading" ? manager.peekState() : await manager.getState(); res.json({ enabled: voice.enabled === true, modelId: "id" in model ? model.id : undefined, language: "language" in language ? language.language : undefined, unsupportedModel: "unsupported" in model ? model.unsupported : undefined, unsupportedLanguage: "unsupported" in language ? language.unsupported : undefined, model: modelState, runtime: await service.getRuntimeStatus() }); }); + const voiceStatus = async (req: express.Request) => { + const { voice } = await settingsFor(ctx, req); + const model = resolveVoiceModelId(voice.model); + const language = resolveVoiceLanguage(voice.language); + const modelState = manager.peekState().status === "queued" || manager.peekState().status === "downloading" ? manager.peekState() : await manager.getState(); + return { enabled: voice.enabled === true, modelId: "id" in model ? model.id : undefined, language: "language" in language ? language.language : undefined, unsupportedModel: "unsupported" in model ? model.unsupported : undefined, unsupportedLanguage: "unsupported" in language ? language.unsupported : undefined, model: modelState, runtime: await service.getRuntimeStatus() }; + }; + router.get("/voice/status", async (req, res) => { res.json(await voiceStatus(req)); }); + router.post("/voice/runtime/recheck", async (req, res) => { service.resetRuntime(); res.json(await voiceStatus(req)); }); router.post("/voice/model/download", async (req, res) => { const { voice } = await settingsFor(ctx, req); const model = resolveVoiceModelId(voice.model); if ("unsupported" in model) return error(res, 400, { error: "unsupported-model", value: model.unsupported, supported: [DEFAULT_VOICE_MODEL_ID] }); const scheduled = manager.scheduleDownload(); if (!scheduled.accepted) return error(res, 409, { error: scheduled.state.errorReason }); res.status(202).json({ state: scheduled.state }); }); router.delete("/voice/model", async (_req, res) => { // Increment before awaiting cleanup so pending createSession() calls are fenced immediately. diff --git a/packages/dashboard/src/stt/__tests__/voice-stt.test.ts b/packages/dashboard/src/stt/__tests__/voice-stt.test.ts index 42d1b5398a..3aeb5f09b5 100644 --- a/packages/dashboard/src/stt/__tests__/voice-stt.test.ts +++ b/packages/dashboard/src/stt/__tests__/voice-stt.test.ts @@ -72,12 +72,45 @@ describe("voice STT graceful degradation", () => { await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-platform-load-failed" }); }); - it("reports a loaded but incompatible native binding as unavailable", async () => { + it.each([{}, { default: {} }, null])("reports an incompatible native binding shape as unavailable", async (binding) => { const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType; - const service = createParakeetService({ manager, loadBinding: async () => ({}) }); + const service = createParakeetService({ manager, loadBinding: async () => binding as never }); await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-incompatible" }); }); + it("unwraps the real CommonJS namespace shape for status and sessions", async () => { + const stream = { acceptWaveform: vi.fn() }; + const recognizer = { createStream: vi.fn(() => stream), decode: vi.fn(), getResult: vi.fn(() => ({ text: "fresh" })) }; + const OfflineRecognizer = vi.fn(function () { return recognizer; }); + const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType; + const service = createParakeetService({ manager, loadBinding: async () => ({ OnlineRecognizer: vi.fn(), default: { OfflineRecognizer }, "module.exports": { OfflineRecognizer } } as never) }); + + await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "available" }); + await expect(service.createSession({ modelId: "parakeet-v3", language: "en" })).resolves.toMatchObject({ acceptChunk: expect.any(Function) }); + expect(OfflineRecognizer).toHaveBeenCalledOnce(); + }); + + it("unwraps a module.exports-only CommonJS namespace", async () => { + const OfflineRecognizer = vi.fn(function () { return { createStream: () => ({ acceptWaveform: () => {} }), decode: () => {}, getResult: () => ({ text: "" }) }; }); + const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType; + const service = createParakeetService({ manager, loadBinding: async () => ({ "module.exports": { OfflineRecognizer } } as never) }); + await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "available" }); + }); + + it("retries a failed runtime load after reset without affecting created sessions", async () => { + const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType; + const OfflineRecognizer = vi.fn(function () { return { createStream: () => ({ acceptWaveform: () => {} }), decode: () => {}, getResult: () => ({ text: "" }) }; }); + const loadBinding = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ERR_MODULE_NOT_FOUND" })) + .mockResolvedValueOnce({ OfflineRecognizer }); + const service = createParakeetService({ manager, loadBinding }); + + await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-module-missing" }); + service.resetRuntime(); + await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "available" }); + expect(loadBinding).toHaveBeenCalledTimes(2); + }); + it("uses sherpa's OfflineRecognizer and stream API for incremental decoding", async () => { let decoded = false; const stream = { acceptWaveform: vi.fn(), free: vi.fn() }; diff --git a/packages/dashboard/src/stt/parakeet-service.ts b/packages/dashboard/src/stt/parakeet-service.ts index f39013825a..b34a7d0c38 100644 --- a/packages/dashboard/src/stt/parakeet-service.ts +++ b/packages/dashboard/src/stt/parakeet-service.ts @@ -3,7 +3,7 @@ import { resolveVoiceLanguage, type VoiceModelId, type VoiceRuntimeStatus } from export class VoiceInputError extends Error { constructor(public readonly code: "unsupported-language" | "invalid-audio" | "unavailable", message: string) { super(message); } } export type VoiceRuntimeUnavailableReason = "model-not-installed" | "runtime-module-missing" | "runtime-platform-load-failed" | "runtime-incompatible"; -export interface ParakeetService { getRuntimeStatus(): Promise<{ status: VoiceRuntimeStatus; unavailableReason?: VoiceRuntimeUnavailableReason }>; createSession(options: { modelId: VoiceModelId; language: string }): Promise; } +export interface ParakeetService { getRuntimeStatus(): Promise<{ status: VoiceRuntimeStatus; unavailableReason?: VoiceRuntimeUnavailableReason }>; resetRuntime(): void; createSession(options: { modelId: VoiceModelId; language: string }): Promise; } export interface ParakeetSession { acceptChunk(pcm: Int16Array | Buffer, options: { final: boolean }): { partial?: string; text?: string; final?: true }; finish(): { text: string }; close(): void; } interface SherpaStream { acceptWaveform(options: { sampleRate: number; samples: Float32Array }): void; free?(): void; close?(): void; } interface SherpaRecognizer { createStream(): SherpaStream; getResult(stream: SherpaStream): { text?: string }; decode(stream: SherpaStream): void; free?(): void; close?(): void; } @@ -17,6 +17,21 @@ function runtimeUnavailableReason(error: unknown): VoiceRuntimeUnavailableReason return "runtime-platform-load-failed"; } +/** + * FNXC:VoiceInput 2026-08-13-23:04: + * sherpa-onnx-node is CommonJS, but cjs-module-lexer only exposes OnlineRecognizer + * as a named ESM export. Resolve its default/module.exports values so healthy native + * installs do not appear incompatible when OfflineRecognizer is namespace-hidden. + */ +function resolveSherpaBinding(module: unknown): SherpaBinding | undefined { + if (!module || typeof module !== "object") return undefined; + const namespace = module as Record; + for (const candidate of [namespace, namespace.default, namespace["module.exports"]]) { + if (candidate && typeof candidate === "object" && typeof (candidate as SherpaBinding).OfflineRecognizer === "function") return candidate as SherpaBinding; + } + return undefined; +} + /** * FNXC:VoiceInput 2026-08-03-05:45: * FN-8753 keeps the sherpa addon lazy and fail-closed, but converts native import @@ -33,19 +48,26 @@ export function createParakeetService(options: ParakeetServiceOptions): Parakeet try { // A module resolving is not sufficient: a platform-mismatched or incompatible addon // can load without exporting the recognizer API required for transcription. - if (!(await binding()).OfflineRecognizer) return { status: "unavailable" as const, unavailableReason: "runtime-incompatible" }; + if (!resolveSherpaBinding(await binding())) return { status: "unavailable" as const, unavailableReason: "runtime-incompatible" }; return { status: "available" as const }; } catch (error) { return { status: "unavailable" as const, unavailableReason: runtimeUnavailableReason(error) }; } }; return { getRuntimeStatus, + /** + * FNXC:VoiceInput 2026-08-13-23:04: + * Re-check drops a failed import attempt after an operator repairs an install. Node + * retains successfully resolved modules, so a resolved broken native addon still + * requires a Fusion restart; active sessions retain their constructed recognizers. + */ + resetRuntime() { bindingPromise = undefined; }, async createSession({ modelId: _modelId, language }) { if ("unsupported" in resolveVoiceLanguage(language)) throw new VoiceInputError("unsupported-language", "Unsupported language"); const model = await options.manager.getState(); const status = await getRuntimeStatus(); if (status.status !== "available" || !model.installedPath) throw new VoiceInputError("unavailable", status.unavailableReason ?? "unavailable"); - const addon = await binding(); - const OfflineRecognizer = addon.OfflineRecognizer; + const addon = resolveSherpaBinding(await binding()); + const OfflineRecognizer = addon?.OfflineRecognizer; if (!OfflineRecognizer) throw new VoiceInputError("unavailable", "OfflineRecognizer unavailable"); // FNXC:VoiceInput 2026-07-21-20:30: sherpa-onnx-node's offline API owns waveform // ingestion on a stream, then decodes and reads that stream through its recognizer. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index deace1a49a..7e1c401c85 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6982,7 +6982,9 @@ "modelFailed": "Fix or retry the model installation before enabling voice input.", "runtimeModuleMissing": "Install a Fusion release that includes the optional voice runtime, then reopen Settings.", "runtimePlatformLoadFailed": "Reinstall Fusion for this platform so the optional voice runtime can load.", - "runtimeIncompatible": "Update or reinstall Fusion because the installed voice runtime is incompatible.", + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…", "unknown": "Status unavailable", "downloading": "Downloading model…", "downloadingProgress": "Downloading: {{progress}}%", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index d575452a6c..5d84fe82c2 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6980,7 +6980,9 @@ "modelFailed": "", "runtimeModuleMissing": "", "runtimePlatformLoadFailed": "", - "runtimeIncompatible": "" + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…" } }, "setup": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index ddd3e27df1..1e057facf9 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6980,7 +6980,9 @@ "modelFailed": "", "runtimeModuleMissing": "", "runtimePlatformLoadFailed": "", - "runtimeIncompatible": "" + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…" } }, "setup": { diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index f4c59fc83b..03abf6a4a6 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6980,7 +6980,9 @@ "modelFailed": "", "runtimeModuleMissing": "", "runtimePlatformLoadFailed": "", - "runtimeIncompatible": "" + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…" } }, "setup": { diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index afef0e9270..cfa096f818 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -6938,7 +6938,9 @@ "modelFailed": "Corrija ou tente novamente a instalação do modelo antes de ativar a entrada de voz.", "runtimeModuleMissing": "Instale uma versão do Fusion que inclua o runtime de voz opcional e reabra as Configurações.", "runtimePlatformLoadFailed": "Reinstale o Fusion para esta plataforma para que o runtime de voz opcional possa carregar.", - "runtimeIncompatible": "Atualize ou reinstale o Fusion porque o runtime de voz instalado é incompatível.", + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…", "unknown": "Status indisponível", "downloading": "Baixando modelo…", "downloadingProgress": "Baixando: {{progress}}%", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 58039068f0..9c42bd0c87 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6980,7 +6980,9 @@ "modelFailed": "", "runtimeModuleMissing": "", "runtimePlatformLoadFailed": "", - "runtimeIncompatible": "" + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…" } }, "setup": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 8babe97d05..f997184b86 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6980,7 +6980,9 @@ "modelFailed": "", "runtimeModuleMissing": "", "runtimePlatformLoadFailed": "", - "runtimeIncompatible": "" + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…" } }, "setup": { diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index b60c85f7b3..0c2746aba5 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6935,8 +6935,10 @@ export default interface Resources { "modelStatus": "Parakeet v3 model status", "modelStatusHelp": "The speech model is installed and managed locally on this device.", "notInstalled": "Not installed", + "recheckRuntime": "Re-check runtime", + "recheckingRuntime": "Re-checking runtime…", "remove": "Remove", - "runtimeIncompatible": "Update or reinstall Fusion because the installed voice runtime is incompatible.", + "runtimeIncompatible": "The installed voice runtime is incompatible. Repair it, then re-check the runtime.", "runtimeModuleMissing": "Install a Fusion release that includes the optional voice runtime, then reopen Settings.", "runtimePlatformLoadFailed": "Reinstall Fusion for this platform so the optional voice runtime can load.", "runtimeUnavailable": "Voice runtime unavailable",