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