FN-9039: fix voice runtime detection and add recheck action

Correct voice runtime detection for CommonJS Sherpa bindings and let operators re-check a repaired runtime.

- Resolve default and module.exports CommonJS bindings before reporting compatibility.
- Add a runtime re-check API and disabled-state Settings action.
- Localize and document the recovery flow with route, service, and UI coverage.

Files changed:
 .changeset/fn-9039-voice-runtime-interop.md        |  7 ++++
 docs/dashboard-guide.md                            |  2 +-
 docs/settings-reference.md                         | 11 +++--
 .../settings/__tests__/VoiceInputSection.test.tsx  | 47 ++++++++++++++++++++++
 .../settings/sections/VoiceInputSection.tsx        | 13 ++++++
 packages/dashboard/src/routes/README.md            | 11 +++--
 .../routes/__tests__/register-voice-routes.test.ts | 30 ++++++++++++++
 .../dashboard/src/routes/register-voice-routes.ts  | 10 ++++-
 .../dashboard/src/stt/__tests__/voice-stt.test.ts  | 37 ++++++++++++++++-
 packages/dashboard/src/stt/parakeet-service.ts     | 30 ++++++++++++--
 packages/i18n/locales/en/app.json                  |  4 +-
 packages/i18n/locales/es/app.json                  |  4 +-
 packages/i18n/locales/fr/app.json                  |  4 +-
 packages/i18n/locales/ko/app.json                  |  4 +-
 packages/i18n/locales/pt-BR/app.json               |  4 +-
 packages/i18n/locales/zh-CN/app.json               |  4 +-
 packages/i18n/locales/zh-TW/app.json               |  4 +-
 packages/i18n/src/resources.d.ts                   |  4 +-
 18 files changed, 206 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-9039

Fusion-Task-Lineage: 50858063-5b0a-4fec-8c34-171d58a288b6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 16:38:59 -07:00
parent 7673f2e839
commit d1bb5f4eb0
18 changed files with 206 additions and 24 deletions

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:VoiceInputDocs 2026-08-04-07:37: Voice dictation is a project-scoped capture session, so the operator guide must describe the selected-project boundary and teardown guarantees verified by the client-to-route regression. -->
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.

View File

@@ -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,

View File

@@ -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(<VoiceInputSection form={{} as SettingsFormState} setForm={vi.fn()} />);
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(<VoiceInputSection form={{} as SettingsFormState} setForm={vi.fn()} />);
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");

View File

@@ -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<VoiceStatus | null>(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 <section className="voice-input-section" data-testid="voice-input-section">
<h4 className="settings-section-heading">{t("settings.voiceInput.title", "Voice Input")}</h4>
<div data-effective-enabled={effectiveEnabled ? "true" : "false"}>
@@ -134,6 +146,7 @@ export function VoiceInputSection({ form, setForm }: SectionBaseProps) {
<div className="voice-input-section__actions" data-testid="voice-input-model-actions">
{!statusUnavailable && (modelStatus === "not-installed" || modelStatus === "error") && <button type="button" className="btn btn-secondary" onClick={() => void performModelAction("/voice/model/download", "POST")}>{t("settings.voiceInput.download", "Download")}</button>}
{!statusUnavailable && modelStatus === "installed" && <button type="button" className="btn btn-secondary" onClick={() => void performModelAction("/voice/model", "DELETE")}>{t("settings.voiceInput.remove", "Remove")}</button>}
{!statusUnavailable && modelReady && runtimeUnavailable && <button type="button" className="btn btn-secondary" disabled={recheckingRuntime} onClick={() => void performRuntimeRecheck()}>{t(recheckingRuntime ? "settings.voiceInput.recheckingRuntime" : "settings.voiceInput.recheckRuntime", recheckingRuntime ? "Re-checking runtime…" : "Re-check runtime")}</button>}
</div>
</SettingsFieldRow>
</section>;

View File

@@ -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

View File

@@ -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<void>((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" });

View File

@@ -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.

View File

@@ -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<typeof createVoiceModelManager>;
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<typeof createVoiceModelManager>;
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<typeof createVoiceModelManager>;
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<typeof createVoiceModelManager>;
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() };

View File

@@ -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<ParakeetSession>; }
export interface ParakeetService { getRuntimeStatus(): Promise<{ status: VoiceRuntimeStatus; unavailableReason?: VoiceRuntimeUnavailableReason }>; resetRuntime(): void; createSession(options: { modelId: VoiceModelId; language: string }): Promise<ParakeetSession>; }
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<string, unknown>;
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.

View File

@@ -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}}%",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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}}%",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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",