FN-8753: enable installed voice input in project settings

Make Voice Input available only after its local model and runtime are ready.

- Bundle the optional sherpa runtime with the published CLI.
- Gate the project setting on model installation and stable runtime status codes.
- Add localized recovery guidance, documentation, and coverage.

Files changed:
 .changeset/fn-8753-voice-input-enable.md           |  7 ++++
 docs/dashboard-guide.md                            |  2 +-
 docs/settings-reference.md                         |  8 +++--
 packages/cli/package.json                          |  3 ++
 packages/cli/src/__tests__/package-config.test.ts  | 16 +++++++++
 .../settings/__tests__/VoiceInputSection.test.tsx  | 36 ++++++++++++++++----
 .../settings/sections/VoiceInputSection.tsx        | 30 ++++++++++++-----
 packages/dashboard/package.json                    |  2 +-
 .../routes/__tests__/register-voice-routes.test.ts | 39 ++++++++++++++++++++++
 .../dashboard/src/stt/__tests__/voice-stt.test.ts  | 27 ++++++++++++---
 packages/dashboard/src/stt/parakeet-service.ts     | 26 ++++++++++-----
 packages/i18n/locales/en/app.json                  |  6 ++++
 pnpm-lock.yaml                                     |  6 +++-
 13 files changed, 175 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-8753

Fusion-Task-Lineage: db92b148-37f0-45d2-b616-ed2e3f2d54f6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 23:07:37 -07:00
parent 46583a4cb6
commit fb0863f660
13 changed files with 175 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Let supported installations enable Voice Input after its model is ready.
category: fix
dev: Publishes sherpa-onnx-node as an optional runtime dependency and reports stable readiness codes.

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. 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. 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.
## Reset Settings

View File

@@ -51,8 +51,12 @@ See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, a
`voiceInput: { enabled?, model?, language? }`; values resolve per request with project precedence.
`enabled` defaults to false and gates dictation only, so model status/download/delete remain available
while disabled. `model` defaults to registry identifier `"parakeet-v3"` and `language` to `"en"`;
unsupported values are rejected and never become URLs or paths. The optional sherpa runtime and
user-scoped cache degrade to unavailable safely. Downloads are on demand and require a pinned SHA-256;
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;
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

@@ -86,6 +86,9 @@
"ws": "^8.18.0",
"playwright-core": "^1.60.0"
},
"optionalDependencies": {
"sherpa-onnx-node": "1.13.4"
},
"peerDependencies": {
"typebox": "*"
},

View File

@@ -177,6 +177,22 @@ describe("CLI package.json publishing config", () => {
expect(publishedPkg.files).toContain("agent-browser.mjs");
});
/*
FNXC:VoiceInput 2026-08-03-05:43:
FN-8753 requires the published CLI manifest to own the lazy sherpa native
addon. The private dashboard workspace dependency is not present after an
npm install, so retain the pinned optional dependency through prepack while
keeping private workspace tooling out of the consumer manifest.
*/
it("keeps the optional voice runtime in the published manifest", () => {
const publishedPkg = applyPrepackTransform(pkg);
expect(pkg.optionalDependencies).toHaveProperty("sherpa-onnx-node", "1.13.4");
expect(publishedPkg.optionalDependencies).toHaveProperty("sherpa-onnx-node", "1.13.4");
expect(publishedPkg.devDependencies).not.toHaveProperty("@fusion/dashboard");
expect(publishedPkg.dependencies).not.toHaveProperty("@fusion/dashboard");
});
/**
* FNXC:Packaging 2026-06-13-16:36:
* Standalone npm/pnpm installs may omit a package when the published manifest declares it as both a runtime dependency and an optional peer. Keep the pi runtime packages as plain dependencies so dist/bin.js and dist/extension.js can resolve their static imports outside the monorepo, while leaving typebox as the optional-peer control because Fusion does not import it at runtime.

View File

@@ -21,12 +21,20 @@ function renderSection(status: unknown, formOverrides: Partial<Settings> = {}) {
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe("VoiceInputSection", () => {
it("persists the opt-in toggle through the Settings form", async () => {
const { setForm, getForm } = renderSection(available("not-installed"));
await screen.findByText("Not installed");
fireEvent.click(screen.getByLabelText("Enable voice input"));
it("persists the opt-in toggle through the project Settings form without clobbering model fields", async () => {
const { setForm, getForm } = renderSection(available("installed"), { voiceInput: { model: "parakeet-v3", language: "en" } });
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeEnabled();
fireEvent.click(toggle);
expect(setForm).toHaveBeenCalledOnce();
expect(getForm().voiceInput?.enabled).toBe(true);
expect(getForm().voiceInput).toEqual({ model: "parakeet-v3", language: "en", enabled: true });
});
it("keeps the toggle disabled until the locally managed model is installed", async () => {
renderSection(available("not-installed"));
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeDisabled();
expect(screen.getByTestId("voice-input-runtime-unavailable")).toHaveTextContent("Download the Parakeet model");
});
it.each([
@@ -47,15 +55,29 @@ describe("VoiceInputSection", () => {
});
it("fails closed for unavailable runtime without rewriting a persisted preference", async () => {
const { setForm } = renderSection({ model: { status: "not-installed" }, runtime: { status: "unavailable" } }, { voiceInput: { enabled: true } });
const { setForm } = renderSection({ model: { status: "installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-module-missing" } }, { voiceInput: { enabled: true } });
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeDisabled();
expect(toggle).not.toBeChecked();
expect(toggle.closest("div[data-effective-enabled]")).toHaveAttribute("data-effective-enabled", "false");
expect(screen.getByTestId("voice-input-runtime-unavailable")).toHaveTextContent("saved preference remains on");
expect(screen.getByTestId("voice-input-runtime-unavailable")).toHaveTextContent("includes the optional voice runtime");
expect(setForm).not.toHaveBeenCalled();
});
it("removes the unavailable alert when model management refreshes to an installed compatible runtime", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(response({ model: { status: "not-installed" }, runtime: { status: "unavailable", unavailableReason: "runtime-module-missing" } }))
.mockResolvedValueOnce(response({}))
.mockResolvedValueOnce(response(available("installed")));
vi.stubGlobal("fetch", fetchMock);
render(<VoiceInputSection form={{} as SettingsFormState} setForm={vi.fn()} />);
await screen.findByTestId("voice-input-runtime-unavailable");
fireEvent.click(screen.getByRole("button", { name: "Download" }));
await waitFor(() => expect(screen.getByLabelText("Enable voice input")).toBeEnabled());
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([undefined, { nonsense: true }])("fails closed when status cannot be parsed", async (body) => {
const { setForm } = renderSection(body);
const toggle = await screen.findByLabelText("Enable voice input");

View File

@@ -67,22 +67,36 @@ export function VoiceInputSection({ form, setForm }: SectionBaseProps) {
return () => window.clearInterval(interval);
}, [loadStatus, modelStatus]);
const downloading = modelStatus === "downloading" || modelStatus === "queued";
const progress = status?.model.progress;
const statusLoading = status === null && !statusUnavailable;
const modelReady = modelStatus === "installed";
const runtimeUnavailable = status?.runtime.status === "unavailable";
const unavailable = statusUnavailable || runtimeUnavailable;
const unavailable = statusLoading || statusUnavailable || !modelReady || runtimeUnavailable;
const storedEnabled = form.voiceInput?.enabled === true;
const effectiveEnabled = storedEnabled && !unavailable;
const runtimeReason = status?.runtime.unavailableReason;
const unavailableMessage = statusUnavailable
? t("settings.voiceInput.statusUnavailable", "Voice runtime status could not be determined; voice mode stays disabled.")
: t("settings.voiceInput.runtimeUnavailable", storedEnabled
? "Voice mode is inactive because the sherpa-onnx runtime is unavailable. Your saved preference remains on."
: "The sherpa-onnx runtime is unavailable, so voice mode stays disabled.");
: !modelReady
? (downloading
? t("settings.voiceInput.modelPreparing", "Voice input becomes available after the model installation finishes.")
: modelStatus === "error"
? t("settings.voiceInput.modelFailed", "Fix or retry the model installation before enabling voice input.")
: t("settings.voiceInput.modelRequired", "Download the Parakeet model before enabling voice input."))
: runtimeReason === "runtime-module-missing"
? t("settings.voiceInput.runtimeModuleMissing", "Install a Fusion release that includes the optional voice runtime, then reopen Settings.")
: runtimeReason === "runtime-platform-load-failed"
? t("settings.voiceInput.runtimePlatformLoadFailed", "Reinstall Fusion for this platform so the optional voice runtime can load.")
: runtimeReason === "runtime-incompatible"
? t("settings.voiceInput.runtimeIncompatible", "Update or reinstall Fusion because the installed voice runtime is incompatible.")
: t("settings.voiceInput.runtimeUnavailable", storedEnabled
? "Voice mode is inactive because the sherpa-onnx runtime is unavailable. Your saved preference remains on."
: "The sherpa-onnx runtime is unavailable, so voice mode stays disabled.");
const performModelAction = async (path: string, method: "POST" | "DELETE") => {
try { await api(path, { method }); } finally { await loadStatus(); }
};
const downloading = modelStatus === "downloading" || modelStatus === "queued";
const progress = status?.model.progress;
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"}>
@@ -98,7 +112,7 @@ export function VoiceInputSection({ form, setForm }: SectionBaseProps) {
onChange={(enabled) => setForm((current) => ({ ...current, voiceInput: { ...(current.voiceInput ?? {}), enabled: enabled === true } }))}
/>
</div>
{unavailable && <p className="voice-input-section__message" role="alert" data-testid={statusUnavailable ? "voice-input-status-unavailable" : "voice-input-runtime-unavailable"}>{unavailableMessage}</p>}
{!statusLoading && unavailable && <p className="voice-input-section__message" role="alert" data-testid={statusUnavailable ? "voice-input-status-unavailable" : "voice-input-runtime-unavailable"}>{unavailableMessage}</p>}
<SettingsFieldRow
label={t("settings.voiceInput.modelStatus", "Parakeet v3 model status")}
help={t("settings.voiceInput.modelStatusHelp", "The speech model is installed and managed locally on this device.")}

View File

@@ -157,7 +157,7 @@
"zod": "^3.25.76"
},
"optionalDependencies": {
"sherpa-onnx-node": "^1.13.4"
"sherpa-onnx-node": "1.13.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",

View File

@@ -19,6 +19,45 @@ async function harness(enabled: boolean, ready = false, projectId = "project-a")
afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))); });
describe("voice route authorization split", () => {
it("reports a loadable installed runtime and honors the project voice override", async () => {
const app = express();
const router = express.Router();
app.use(router);
const 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: () => () => {},
};
const service = {
getRuntimeStatus: async () => ({ status: "available" as const }),
createSession: async () => ({ acceptChunk: () => ({ partial: "ok" }), finish: () => ({ text: "ok" }), close: () => {} }),
};
createRegisterVoiceRoutes({ manager, service })({
router,
getScopedStore: async () => ({
getSettings: async () => ({ voiceInput: { enabled: true, language: "en" } }),
getGlobalSettingsStore: () => ({ getSettings: async () => ({ voiceInput: { enabled: false, model: "parakeet-v3" } }) }),
}),
getProjectIdFromRequest: () => "project-voice-override",
} as unknown as ApiRoutesContext);
const server = app.listen(0); servers.push(server);
await new Promise<void>((resolve) => server.once("listening", resolve));
const port = (server.address() as AddressInfo).port;
const response = await fetch(`http://127.0.0.1:${port}/voice/status`);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
enabled: true,
modelId: "parakeet-v3",
language: "en",
model: { status: "installed" },
runtime: { status: "available" },
});
});
it("allows lifecycle inspection while dictation is disabled", async () => {
const request = await harness(false);
expect((await request("/voice/status")).status).toBe(200);

View File

@@ -47,16 +47,35 @@ describe("voice STT graceful degradation", () => {
expect(resolveVoiceLanguage(undefined)).toEqual({ language: "en" });
expect(resolveVoiceLanguage("fr")).toEqual({ unsupported: "fr" });
});
it("reports an uninstalled model without attempting to load the native binding", async () => {
const loadBinding = vi.fn();
const manager = { getState: async () => ({ status: "not-installed" as const }) } as ReturnType<typeof createVoiceModelManager>;
const service = createParakeetService({ manager, loadBinding });
await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "model-not-installed" });
expect(loadBinding).not.toHaveBeenCalled();
});
it("reports a missing native binding as unavailable without throwing", async () => {
const manager = createVoiceModelManager({ cacheDir: "/unused" });
const service = createParakeetService({ manager, loadBinding: async () => { throw new Error("ERR_MODULE_NOT_FOUND"); } });
await expect(service.getRuntimeStatus()).resolves.toMatchObject({ status: "unavailable" });
const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType<typeof createVoiceModelManager>;
const missingModule = Object.assign(new Error("not exposed to operators"), { code: "ERR_MODULE_NOT_FOUND" });
const service = createParakeetService({ manager, loadBinding: async () => { throw missingModule; } });
await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-module-missing" });
});
it("reports a platform addon load failure without leaking the loader error", async () => {
const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType<typeof createVoiceModelManager>;
const platformFailure = Object.assign(new Error("dlopen /private/operator/path"), { code: "ERR_DLOPEN_FAILED" });
const service = createParakeetService({ manager, loadBinding: async () => { throw platformFailure; } });
await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-platform-load-failed" });
});
it("reports a loaded but incompatible native binding as unavailable", async () => {
const manager = { getState: async () => ({ status: "installed" as const, installedPath: "/model" }) } as ReturnType<typeof createVoiceModelManager>;
const service = createParakeetService({ manager, loadBinding: async () => ({}) });
await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "OfflineRecognizer unavailable" });
await expect(service.getRuntimeStatus()).resolves.toEqual({ status: "unavailable", unavailableReason: "runtime-incompatible" });
});
it("uses sherpa's OfflineRecognizer and stream API for incremental decoding", async () => {

View File

@@ -2,7 +2,8 @@ import type { VoiceModelManager } from "./model-manager.js";
import { resolveVoiceLanguage, type VoiceModelId, type VoiceRuntimeStatus } from "./types.js";
export class VoiceInputError extends Error { constructor(public readonly code: "unsupported-language" | "invalid-audio" | "unavailable", message: string) { super(message); } }
export interface ParakeetService { getRuntimeStatus(): Promise<{ status: VoiceRuntimeStatus; unavailableReason?: string }>; createSession(options: { modelId: VoiceModelId; language: string }): Promise<ParakeetSession>; }
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 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; }
@@ -10,24 +11,31 @@ interface SherpaOfflineRecognizerConstructor { new(config: { modelConfig: { tran
interface SherpaBinding { OfflineRecognizer?: SherpaOfflineRecognizerConstructor; }
export interface ParakeetServiceOptions { manager: VoiceModelManager; loadBinding?: () => Promise<SherpaBinding>; }
function runtimeUnavailableReason(error: unknown): VoiceRuntimeUnavailableReason {
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return "runtime-module-missing";
return "runtime-platform-load-failed";
}
/**
* FNXC:VoiceInput 2026-07-21-17:20:
* Voice is opt-in and the sherpa addon is an optional, lazy runtime. Its fixed input is 16 kHz,
* mono signed-16-bit little-endian PCM. Resolved model/language values are checked before use;
* missing addon or model reports unavailable rather than preventing dashboard or engine boot.
* FNXC:VoiceInput 2026-08-03-05:45:
* FN-8753 keeps the sherpa addon lazy and fail-closed, but converts native import
* failures into stable operator-safe codes. Raw loader errors can disclose paths
* and differ by platform; Settings needs to distinguish an absent module, a
* platform addon failure, and an incompatible export without exposing either.
*/
export function createParakeetService(options: ParakeetServiceOptions): ParakeetService {
let bindingPromise: Promise<SherpaBinding> | undefined;
const binding = () => bindingPromise ??= (options.loadBinding ? options.loadBinding() : new Function("specifier", "return import(specifier)")("sherpa-onnx-node") as Promise<SherpaBinding>);
const getRuntimeStatus = async () => {
const getRuntimeStatus = async (): Promise<{ status: VoiceRuntimeStatus; unavailableReason?: VoiceRuntimeUnavailableReason }> => {
const model = await options.manager.getState();
if (model.status !== "installed" || !model.installedPath) return { status: "unavailable" as const, unavailableReason: model.errorReason ?? model.status };
if (model.status !== "installed" || !model.installedPath) return { status: "unavailable" as const, unavailableReason: "model-not-installed" };
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: "OfflineRecognizer unavailable" };
if (!(await binding()).OfflineRecognizer) return { status: "unavailable" as const, unavailableReason: "runtime-incompatible" };
return { status: "available" as const };
} catch (error) { return { status: "unavailable" as const, unavailableReason: error instanceof Error ? error.message : "runtime-unavailable" }; }
} catch (error) { return { status: "unavailable" as const, unavailableReason: runtimeUnavailableReason(error) }; }
};
return {
getRuntimeStatus,

View File

@@ -6924,6 +6924,12 @@
"modelActionsHelp": "Download or remove the Parakeet v3 speech model.",
"statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.",
"runtimeUnavailable": "Voice runtime unavailable",
"modelRequired": "Download the Parakeet model before enabling voice input.",
"modelPreparing": "Voice input becomes available after the model installation finishes.",
"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.",
"unknown": "Status unavailable",
"downloading": "Downloading model…",
"downloadingProgress": "Downloading: {{progress}}%",

6
pnpm-lock.yaml generated
View File

@@ -170,6 +170,10 @@ importers:
yaml:
specifier: ^2.8.3
version: 2.9.0
optionalDependencies:
sherpa-onnx-node:
specifier: 1.13.4
version: 1.13.4
packages/cli-alias:
dependencies:
@@ -457,7 +461,7 @@ importers:
version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@25.9.5)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
optionalDependencies:
sherpa-onnx-node:
specifier: ^1.13.4
specifier: 1.13.4
version: 1.13.4
packages/desktop: