Spaces:
Running
Running
Sync from felladrin/MiniSearch@998b6c8
Browse files- agents.md +1 -1
- client/components/Pages/Main/Menu/VoiceSettingsForm.test.tsx +28 -0
- client/components/Pages/Main/Menu/VoiceSettingsForm.tsx +10 -1
- client/components/Search/Form/DictationButton.test.tsx +32 -0
- client/components/Search/Form/DictationButton.tsx +43 -35
- client/modules/settings.test.ts +53 -0
- client/modules/settings.ts +18 -0
- client/modules/speechToText.test.ts +137 -43
- client/modules/speechToText.ts +25 -9
- docs/configuration.md +2 -1
- docs/failure-injection.md +2 -1
- docs/project-structure.md +1 -1
- docs/security.md +1 -1
- docs/ui-components.md +1 -1
agents.md
CHANGED
|
@@ -128,7 +128,7 @@ Need to:
|
|
| 128 |
- `client/modules/textToSpeechWorker.ts` - Synthesizes sentences off the main thread
|
| 129 |
- `client/modules/textToSpeechWorkerProtocol.ts` - Message types shared by both sides of the worker
|
| 130 |
- `client/modules/piper.ts` - The only module that imports `@diffusionstudio/vits-web`
|
| 131 |
-
- `client/modules/speechToText.ts` - Dictation: microphone capture, the worker's lifecycle, and the `SpeechRecognition`
|
| 132 |
- `client/modules/speechToTextWorker.ts` - Runs the Moonshine transcriber off the main thread
|
| 133 |
- `client/modules/speechToTextWorkerProtocol.ts` - Message types shared by both sides of the dictation worker
|
| 134 |
- `client/modules/moonshine.ts` - The only module that imports `@moonshine-ai/moonshine-wasm`
|
|
|
|
| 128 |
- `client/modules/textToSpeechWorker.ts` - Synthesizes sentences off the main thread
|
| 129 |
- `client/modules/textToSpeechWorkerProtocol.ts` - Message types shared by both sides of the worker
|
| 130 |
- `client/modules/piper.ts` - The only module that imports `@diffusionstudio/vits-web`
|
| 131 |
+
- `client/modules/speechToText.ts` - Dictation: microphone capture, the worker's lifecycle, and engine selection between the on-device model and `SpeechRecognition`
|
| 132 |
- `client/modules/speechToTextWorker.ts` - Runs the Moonshine transcriber off the main thread
|
| 133 |
- `client/modules/speechToTextWorkerProtocol.ts` - Message types shared by both sides of the dictation worker
|
| 134 |
- `client/modules/moonshine.ts` - The only module that imports `@moonshine-ai/moonshine-wasm`
|
client/components/Pages/Main/Menu/VoiceSettingsForm.test.tsx
CHANGED
|
@@ -22,6 +22,7 @@ const { settings, setSettings, listVoices } = vi.hoisted(() => ({
|
|
| 22 |
settings: {
|
| 23 |
current: {
|
| 24 |
enableDictation: false,
|
|
|
|
| 25 |
selectedVoiceId: "",
|
| 26 |
textToSpeechEngine: "local" as "local" | "system",
|
| 27 |
},
|
|
@@ -72,6 +73,7 @@ beforeEach(() => {
|
|
| 72 |
vi.clearAllMocks();
|
| 73 |
settings.current = {
|
| 74 |
enableDictation: false,
|
|
|
|
| 75 |
selectedVoiceId: "",
|
| 76 |
textToSpeechEngine: "local",
|
| 77 |
};
|
|
@@ -111,6 +113,32 @@ describe("VoiceSettingsForm", () => {
|
|
| 111 |
]);
|
| 112 |
});
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
it("goes back to auto-detection when the engine changes", async () => {
|
| 115 |
settings.current.selectedVoiceId = localVoice.value;
|
| 116 |
renderForm();
|
|
|
|
| 22 |
settings: {
|
| 23 |
current: {
|
| 24 |
enableDictation: false,
|
| 25 |
+
enableLocalDictationModel: true,
|
| 26 |
selectedVoiceId: "",
|
| 27 |
textToSpeechEngine: "local" as "local" | "system",
|
| 28 |
},
|
|
|
|
| 73 |
vi.clearAllMocks();
|
| 74 |
settings.current = {
|
| 75 |
enableDictation: false,
|
| 76 |
+
enableLocalDictationModel: true,
|
| 77 |
selectedVoiceId: "",
|
| 78 |
textToSpeechEngine: "local",
|
| 79 |
};
|
|
|
|
| 113 |
]);
|
| 114 |
});
|
| 115 |
|
| 116 |
+
it("disables the on-device model switch while dictation itself is off", () => {
|
| 117 |
+
renderForm();
|
| 118 |
+
expect(
|
| 119 |
+
screen.getByRole("switch", {
|
| 120 |
+
name: /On-device dictation model/i,
|
| 121 |
+
}),
|
| 122 |
+
).toBeDisabled();
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("persists toggling the on-device dictation model while dictation is on", () => {
|
| 126 |
+
settings.current.enableDictation = true;
|
| 127 |
+
renderForm();
|
| 128 |
+
|
| 129 |
+
const switchInput = screen.getByRole("switch", {
|
| 130 |
+
name: /On-device dictation model/i,
|
| 131 |
+
}) as HTMLInputElement;
|
| 132 |
+
expect(switchInput).not.toBeDisabled();
|
| 133 |
+
|
| 134 |
+
fireEvent.click(switchInput);
|
| 135 |
+
|
| 136 |
+
expect(setSettings).toHaveBeenCalledWith(
|
| 137 |
+
expect.objectContaining({ enableLocalDictationModel: false }),
|
| 138 |
+
expect.anything(),
|
| 139 |
+
);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
it("goes back to auto-detection when the engine changes", async () => {
|
| 143 |
settings.current.selectedVoiceId = localVoice.value;
|
| 144 |
renderForm();
|
client/components/Pages/Main/Menu/VoiceSettingsForm.tsx
CHANGED
|
@@ -57,9 +57,18 @@ export default function VoiceSettingsForm() {
|
|
| 57 |
<Switch
|
| 58 |
{...form.getInputProps("enableDictation", { type: "checkbox" })}
|
| 59 |
label="Dictate the search query"
|
| 60 |
-
description="Shows a
|
| 61 |
labelPosition="left"
|
| 62 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<Text size="sm">Speech Engine</Text>
|
| 64 |
<Text size="xs" c="dimmed">
|
| 65 |
The local engine downloads a small neural voice on the first use and
|
|
|
|
| 57 |
<Switch
|
| 58 |
{...form.getInputProps("enableDictation", { type: "checkbox" })}
|
| 59 |
label="Dictate the search query"
|
| 60 |
+
description="Shows a Dictate button next to the search field. Which engine transcribes the speech is decided by the setting below."
|
| 61 |
labelPosition="left"
|
| 62 |
/>
|
| 63 |
+
<Switch
|
| 64 |
+
{...form.getInputProps("enableLocalDictationModel", {
|
| 65 |
+
type: "checkbox",
|
| 66 |
+
})}
|
| 67 |
+
label="On-device dictation model (English-only)"
|
| 68 |
+
description="Downloads a ~51 MB model on first use and transcribes without the audio leaving this device, but it only understands English. Turn it off to use your browser's built-in speech recognition instead, which handles other languages but may send the audio to your browser vendor. On browsers without built-in speech recognition, the on-device model is used regardless."
|
| 69 |
+
labelPosition="left"
|
| 70 |
+
disabled={!settings.enableDictation}
|
| 71 |
+
/>
|
| 72 |
<Text size="sm">Speech Engine</Text>
|
| 73 |
<Text size="xs" c="dimmed">
|
| 74 |
The local engine downloads a small neural voice on the first use and
|
client/components/Search/Form/DictationButton.test.tsx
CHANGED
|
@@ -94,6 +94,30 @@ it("hides when the setting is off", async () => {
|
|
| 94 |
}
|
| 95 |
});
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
it("switches to the recording state and stops on the second press", async () => {
|
| 98 |
const user = userEvent.setup();
|
| 99 |
renderButton();
|
|
@@ -102,6 +126,14 @@ it("switches to the recording state and stops on the second press", async () =>
|
|
| 102 |
screen.getByRole("button", { name: "Dictate the search query" }),
|
| 103 |
);
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
await waitFor(() =>
|
| 106 |
expect(
|
| 107 |
screen.getByRole("button", { name: "Stop dictating the search query" }),
|
|
|
|
| 94 |
}
|
| 95 |
});
|
| 96 |
|
| 97 |
+
it("passes the local-model preference through to the engine", async () => {
|
| 98 |
+
const { settingsPubSub } = await import("@/modules/pubSub");
|
| 99 |
+
settingsPubSub[0]({
|
| 100 |
+
...(settingsPubSub[2]?.() ?? {}),
|
| 101 |
+
enableLocalDictationModel: false,
|
| 102 |
+
});
|
| 103 |
+
try {
|
| 104 |
+
const user = userEvent.setup();
|
| 105 |
+
renderButton();
|
| 106 |
+
await user.click(
|
| 107 |
+
screen.getByRole("button", { name: "Dictate the search query" }),
|
| 108 |
+
);
|
| 109 |
+
await waitFor(() =>
|
| 110 |
+
expect(startDictation).toHaveBeenCalledWith(expect.anything(), false),
|
| 111 |
+
);
|
| 112 |
+
expect(getDictationEngine).toHaveBeenCalledWith(false);
|
| 113 |
+
} finally {
|
| 114 |
+
settingsPubSub[0]({
|
| 115 |
+
...(settingsPubSub[2]?.() ?? {}),
|
| 116 |
+
enableLocalDictationModel: true,
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
it("switches to the recording state and stops on the second press", async () => {
|
| 122 |
const user = userEvent.setup();
|
| 123 |
renderButton();
|
|
|
|
| 126 |
screen.getByRole("button", { name: "Dictate the search query" }),
|
| 127 |
);
|
| 128 |
|
| 129 |
+
// The mirror of the `false` case above: the pubsub holds a literal
|
| 130 |
+
// `true` restored by that test's finally block, so a hardcoded `false`
|
| 131 |
+
// at the forwarding sites fails here. The en-US derivation itself is
|
| 132 |
+
// pinned in settings.test.ts.
|
| 133 |
+
await waitFor(() =>
|
| 134 |
+
expect(startDictation).toHaveBeenCalledWith(expect.anything(), true),
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
await waitFor(() =>
|
| 138 |
expect(
|
| 139 |
screen.getByRole("button", { name: "Stop dictating the search query" }),
|
client/components/Search/Form/DictationButton.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
import { Button } from "@mantine/core";
|
| 2 |
import { notifications } from "@mantine/notifications";
|
| 3 |
-
import { IconMicrophone } from "@tabler/icons-react";
|
| 4 |
import { usePubSub } from "create-pubsub/react";
|
| 5 |
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
| 6 |
import { settingsPubSub } from "@/modules/pubSub";
|
|
|
|
| 7 |
import {
|
| 8 |
DictationError,
|
| 9 |
type DictationSession,
|
|
@@ -124,6 +124,15 @@ export default memo(function DictationButton({
|
|
| 124 |
void stop();
|
| 125 |
}, [settings.enableDictation, stop]);
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
const handleClick = useCallback(async () => {
|
| 128 |
if (sessionRef.current) {
|
| 129 |
await stop();
|
|
@@ -134,31 +143,34 @@ export default memo(function DictationButton({
|
|
| 134 |
const generation = ++pressGenerationRef.current;
|
| 135 |
setPhase("loading");
|
| 136 |
try {
|
| 137 |
-
const session = await startDictation(
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
},
|
| 161 |
-
|
|
|
|
| 162 |
// The load takes seconds and the permission prompt can take minutes, so
|
| 163 |
// the button may well be gone by now; whichever path removed it had no
|
| 164 |
// session to stop when it ran.
|
|
@@ -185,9 +197,13 @@ export default memo(function DictationButton({
|
|
| 185 |
color: "red",
|
| 186 |
});
|
| 187 |
}
|
| 188 |
-
}, [handleTranscript, stop, phase]);
|
| 189 |
|
| 190 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
const recording = phase === "recording";
|
| 193 |
const percent = formatDownloadProgress(progress);
|
|
@@ -205,14 +221,6 @@ export default memo(function DictationButton({
|
|
| 205 |
}
|
| 206 |
aria-pressed={recording}
|
| 207 |
data-dictation-phase={phase}
|
| 208 |
-
leftSection={
|
| 209 |
-
<IconMicrophone
|
| 210 |
-
size={14}
|
| 211 |
-
style={
|
| 212 |
-
recording ? { color: "var(--mantine-color-red-filled)" } : undefined
|
| 213 |
-
}
|
| 214 |
-
/>
|
| 215 |
-
}
|
| 216 |
>
|
| 217 |
{phase === "loading"
|
| 218 |
? `Loading${percent}`
|
|
|
|
| 1 |
import { Button } from "@mantine/core";
|
| 2 |
import { notifications } from "@mantine/notifications";
|
|
|
|
| 3 |
import { usePubSub } from "create-pubsub/react";
|
| 4 |
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
| 5 |
import { settingsPubSub } from "@/modules/pubSub";
|
| 6 |
+
import { prefersLocalDictationModel } from "@/modules/settings";
|
| 7 |
import {
|
| 8 |
DictationError,
|
| 9 |
type DictationSession,
|
|
|
|
| 124 |
void stop();
|
| 125 |
}, [settings.enableDictation, stop]);
|
| 126 |
|
| 127 |
+
/**
|
| 128 |
+
* An upgrading profile's stored settings lack this key until `App` merges
|
| 129 |
+
* the defaults after `/api/config` resolves. Reading it raw would be
|
| 130 |
+
* falsy in that window and hand the microphone to the vendor recognizer
|
| 131 |
+
* unnoticed, so the derived default is applied here as well.
|
| 132 |
+
*/
|
| 133 |
+
const preferLocalModel =
|
| 134 |
+
settings.enableLocalDictationModel ?? prefersLocalDictationModel();
|
| 135 |
+
|
| 136 |
const handleClick = useCallback(async () => {
|
| 137 |
if (sessionRef.current) {
|
| 138 |
await stop();
|
|
|
|
| 143 |
const generation = ++pressGenerationRef.current;
|
| 144 |
setPhase("loading");
|
| 145 |
try {
|
| 146 |
+
const session = await startDictation(
|
| 147 |
+
{
|
| 148 |
+
onTranscript: handleTranscript,
|
| 149 |
+
onProgress: (loaded, total) => setProgress({ loaded, total }),
|
| 150 |
+
onFallback: () =>
|
| 151 |
+
notifications.show({
|
| 152 |
+
title: "Using the browser's recognizer",
|
| 153 |
+
message:
|
| 154 |
+
"The on-device model could not run, so this browser's own speech recognition is transcribing instead. It may send the audio to the browser vendor.",
|
| 155 |
+
color: "yellow",
|
| 156 |
+
}),
|
| 157 |
+
onEnd: () => void stop(),
|
| 158 |
+
onError: (error) => {
|
| 159 |
+
const denied = error.kind === "permission";
|
| 160 |
+
notifications.show({
|
| 161 |
+
title: denied
|
| 162 |
+
? "Microphone permission denied"
|
| 163 |
+
: "Dictation stopped",
|
| 164 |
+
message: denied
|
| 165 |
+
? "Allow microphone access in the browser settings to dictate a search."
|
| 166 |
+
: error.message,
|
| 167 |
+
color: "red",
|
| 168 |
+
});
|
| 169 |
+
void stop();
|
| 170 |
+
},
|
| 171 |
},
|
| 172 |
+
preferLocalModel,
|
| 173 |
+
);
|
| 174 |
// The load takes seconds and the permission prompt can take minutes, so
|
| 175 |
// the button may well be gone by now; whichever path removed it had no
|
| 176 |
// session to stop when it ran.
|
|
|
|
| 197 |
color: "red",
|
| 198 |
});
|
| 199 |
}
|
| 200 |
+
}, [handleTranscript, stop, phase, preferLocalModel]);
|
| 201 |
|
| 202 |
+
if (
|
| 203 |
+
!settings.enableDictation ||
|
| 204 |
+
getDictationEngine(preferLocalModel) === null
|
| 205 |
+
)
|
| 206 |
+
return null;
|
| 207 |
|
| 208 |
const recording = phase === "recording";
|
| 209 |
const percent = formatDownloadProgress(progress);
|
|
|
|
| 221 |
}
|
| 222 |
aria-pressed={recording}
|
| 223 |
data-dictation-phase={phase}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
>
|
| 225 |
{phase === "loading"
|
| 226 |
? `Loading${percent}`
|
client/modules/settings.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
| 5 |
defaultSettings,
|
| 6 |
getDefaultCpuThreads,
|
| 7 |
getInferenceTypes,
|
|
|
|
| 8 |
} from "./settings";
|
| 9 |
|
| 10 |
const mockConfig: ServerConfig = {
|
|
@@ -55,6 +56,27 @@ describe("Settings Module", () => {
|
|
| 55 |
});
|
| 56 |
});
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
it("should include core inference types", () => {
|
| 59 |
const values = getInferenceTypes(mockConfig).map((i) => i.value);
|
| 60 |
expect(values).toContain("browser");
|
|
@@ -158,3 +180,34 @@ describe("legacy voice migration", () => {
|
|
| 158 |
expect(settings.textToSpeechEngine).toBe("local");
|
| 159 |
});
|
| 160 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
defaultSettings,
|
| 6 |
getDefaultCpuThreads,
|
| 7 |
getInferenceTypes,
|
| 8 |
+
prefersLocalDictationModel,
|
| 9 |
} from "./settings";
|
| 10 |
|
| 11 |
const mockConfig: ServerConfig = {
|
|
|
|
| 56 |
});
|
| 57 |
});
|
| 58 |
|
| 59 |
+
describe("prefersLocalDictationModel", () => {
|
| 60 |
+
it("is true when the primary language is English", () => {
|
| 61 |
+
expect(prefersLocalDictationModel("en")).toBe(true);
|
| 62 |
+
expect(prefersLocalDictationModel("en-US")).toBe(true);
|
| 63 |
+
expect(prefersLocalDictationModel("en-GB")).toBe(true);
|
| 64 |
+
expect(prefersLocalDictationModel("EN")).toBe(true);
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("is false when the primary language is not English", () => {
|
| 68 |
+
expect(prefersLocalDictationModel("pt")).toBe(false);
|
| 69 |
+
expect(prefersLocalDictationModel("pt-BR")).toBe(false);
|
| 70 |
+
expect(prefersLocalDictationModel("de-DE")).toBe(false);
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
it("compares the subtag exactly instead of by prefix", () => {
|
| 74 |
+
// `startsWith("en")` would match historical subtags like Middle
|
| 75 |
+
// English, which no browser UI actually uses.
|
| 76 |
+
expect(prefersLocalDictationModel("enm")).toBe(false);
|
| 77 |
+
});
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
it("should include core inference types", () => {
|
| 81 |
const values = getInferenceTypes(mockConfig).map((i) => i.value);
|
| 82 |
expect(values).toContain("browser");
|
|
|
|
| 180 |
expect(settings.textToSpeechEngine).toBe("local");
|
| 181 |
});
|
| 182 |
});
|
| 183 |
+
|
| 184 |
+
describe("dictation model default", () => {
|
| 185 |
+
const loadWithLanguage = async (language: string) => {
|
| 186 |
+
// Scoped restore rather than vi.restoreAllMocks(): the setup file installs
|
| 187 |
+
// matchMedia as a mock, and a broad restore would take it down with the
|
| 188 |
+
// language spy for anything that runs after this describe.
|
| 189 |
+
const languageSpy = vi
|
| 190 |
+
.spyOn(navigator, "language", "get")
|
| 191 |
+
.mockReturnValue(language);
|
| 192 |
+
vi.resetModules();
|
| 193 |
+
const module = await import("./settings");
|
| 194 |
+
languageSpy.mockRestore();
|
| 195 |
+
return module;
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
it("starts a non-English profile on the browser recognizer", async () => {
|
| 199 |
+
// The wiring from prefersLocalDictationModel into defaultSettings: a
|
| 200 |
+
// literal `true` here would leave every non-English first visit on a
|
| 201 |
+
// model that cannot transcribe their speech, while the pure function
|
| 202 |
+
// kept passing its own tests.
|
| 203 |
+
const { defaultSettings: settings } = await loadWithLanguage("pt-BR");
|
| 204 |
+
|
| 205 |
+
expect(settings.enableLocalDictationModel).toBe(false);
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
it("starts an English profile on the on-device model", async () => {
|
| 209 |
+
const { defaultSettings: settings } = await loadWithLanguage("en-US");
|
| 210 |
+
|
| 211 |
+
expect(settings.enableLocalDictationModel).toBe(true);
|
| 212 |
+
});
|
| 213 |
+
});
|
client/modules/settings.ts
CHANGED
|
@@ -57,6 +57,23 @@ export function getDefaultCpuThreads(
|
|
| 57 |
return Math.max(1, Math.floor(hardwareConcurrency / 2));
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
/**
|
| 61 |
* Default application settings configuration.
|
| 62 |
* Runtime server config is merged in via `applyServerConfig()` after
|
|
@@ -88,6 +105,7 @@ export const defaultSettings = {
|
|
| 88 |
historyGroupByDate: true,
|
| 89 |
selectedVoiceId: "",
|
| 90 |
enableDictation: true,
|
|
|
|
| 91 |
textToSpeechEngine: (hasLegacySystemVoice ? "system" : "local") as
|
| 92 |
| "local"
|
| 93 |
| "system",
|
|
|
|
| 57 |
return Math.max(1, Math.floor(hardwareConcurrency / 2));
|
| 58 |
}
|
| 59 |
|
| 60 |
+
/**
|
| 61 |
+
* Whether the browser's primary language is English. The on-device dictation
|
| 62 |
+
* model understands English only, so a non-English profile should start on
|
| 63 |
+
* the browser's own multi-lingual recognizer instead.
|
| 64 |
+
*
|
| 65 |
+
* Only the primary language is consulted, never the full `navigator.languages`
|
| 66 |
+
* list: OEM images commonly append `en-US` to non-English installs, which
|
| 67 |
+
* would resolve "English" for a large share of the users this default exists
|
| 68 |
+
* to protect. The primary subtag is compared exactly, so a prefix match on
|
| 69 |
+
* `en` cannot pick up unrelated subtags like `enm`.
|
| 70 |
+
*/
|
| 71 |
+
export function prefersLocalDictationModel(
|
| 72 |
+
language: string = navigator.language,
|
| 73 |
+
): boolean {
|
| 74 |
+
return language.toLowerCase().split("-")[0] === "en";
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
/**
|
| 78 |
* Default application settings configuration.
|
| 79 |
* Runtime server config is merged in via `applyServerConfig()` after
|
|
|
|
| 105 |
historyGroupByDate: true,
|
| 106 |
selectedVoiceId: "",
|
| 107 |
enableDictation: true,
|
| 108 |
+
enableLocalDictationModel: prefersLocalDictationModel(),
|
| 109 |
textToSpeechEngine: (hasLegacySystemVoice ? "system" : "local") as
|
| 110 |
| "local"
|
| 111 |
| "system",
|
client/modules/speechToText.test.ts
CHANGED
|
@@ -93,22 +93,41 @@ afterEach(() => {
|
|
| 93 |
});
|
| 94 |
|
| 95 |
describe("getDictationEngine", () => {
|
| 96 |
-
|
| 97 |
-
stubWasmSupport(vi.fn());
|
| 98 |
-
expect(getDictationEngine()).toBe("wasm");
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
it("falls back to web-speech when the wasm pieces are missing", () => {
|
| 102 |
class FakeRecognition {
|
| 103 |
start = vi.fn();
|
| 104 |
}
|
| 105 |
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
|
| 106 |
FakeRecognition;
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
});
|
| 109 |
|
| 110 |
it("reports no engine when neither path is available", () => {
|
| 111 |
-
expect(getDictationEngine()).toBeNull();
|
|
|
|
| 112 |
});
|
| 113 |
});
|
| 114 |
|
|
@@ -137,10 +156,13 @@ describe("startDictation with the wasm engine", () => {
|
|
| 137 |
const transcripts: string[] = [];
|
| 138 |
const progress: [number, number | undefined][] = [];
|
| 139 |
|
| 140 |
-
const sessionPromise = startDictation(
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 146 |
const worker = FakeWorker.instances[0];
|
|
@@ -173,7 +195,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 173 |
stubWasmSupport(getUserMedia);
|
| 174 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 175 |
|
| 176 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 177 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 178 |
FakeWorker.instances[0].respond({ type: "loaded" });
|
| 179 |
|
|
@@ -207,7 +229,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 207 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 208 |
const started = installLocalRecognitionFake();
|
| 209 |
|
| 210 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 211 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 212 |
// A 502 from `/dictation-models/` is "cannot run here", not "no engine".
|
| 213 |
FakeWorker.instances[0].respond({
|
|
@@ -230,7 +252,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 230 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 231 |
const started = installLocalRecognitionFake();
|
| 232 |
|
| 233 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 234 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 235 |
FakeWorker.instances[0].respond({ type: "loaded" });
|
| 236 |
|
|
@@ -252,7 +274,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 252 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 253 |
const started = installLocalRecognitionFake();
|
| 254 |
|
| 255 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 256 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 257 |
const worker = FakeWorker.instances[0];
|
| 258 |
worker.respond({ type: "loaded" });
|
|
@@ -282,7 +304,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 282 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 283 |
const started = installLocalRecognitionFake();
|
| 284 |
|
| 285 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 286 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 287 |
const worker = FakeWorker.instances[0];
|
| 288 |
worker.respond({ type: "loaded" });
|
|
@@ -304,7 +326,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 304 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 305 |
const onTranscript = vi.fn();
|
| 306 |
|
| 307 |
-
const sessionPromise = startDictation({ onTranscript });
|
| 308 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 309 |
const worker = FakeWorker.instances[0];
|
| 310 |
worker.respond({ type: "loaded" });
|
|
@@ -328,7 +350,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 328 |
stubWasmSupport(vi.fn().mockResolvedValue(stream));
|
| 329 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 330 |
|
| 331 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 332 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 333 |
const worker = FakeWorker.instances[0];
|
| 334 |
worker.respond({ type: "loaded" });
|
|
@@ -351,10 +373,13 @@ describe("startDictation with the wasm engine", () => {
|
|
| 351 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 352 |
const onError = vi.fn();
|
| 353 |
|
| 354 |
-
const sessionPromise = startDictation(
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
| 358 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 359 |
const worker = FakeWorker.instances[0];
|
| 360 |
worker.respond({ type: "loaded" });
|
|
@@ -374,7 +399,7 @@ describe("startDictation with the wasm engine", () => {
|
|
| 374 |
stubWasmSupport(getUserMedia);
|
| 375 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 376 |
|
| 377 |
-
const sessionPromise = startDictation({ onTranscript: vi.fn() });
|
| 378 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 379 |
const worker = FakeWorker.instances[0];
|
| 380 |
worker.respond({ type: "error", message: "model download failed" });
|
|
@@ -391,6 +416,63 @@ describe("startDictation with the wasm engine", () => {
|
|
| 391 |
});
|
| 392 |
});
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
describe("startDictation with the web speech fallback", () => {
|
| 395 |
interface RecognitionFake {
|
| 396 |
continuous: boolean;
|
|
@@ -437,9 +519,12 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 437 |
installRecognitionFake();
|
| 438 |
|
| 439 |
const transcripts: string[] = [];
|
| 440 |
-
const session = await startDictation(
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
expect(instance?.continuous).toBe(true);
|
| 445 |
expect(instance?.interimResults).toBe(true);
|
|
@@ -468,7 +553,7 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 468 |
});
|
| 469 |
|
| 470 |
await expect(
|
| 471 |
-
startDictation({ onTranscript: vi.fn() }),
|
| 472 |
).rejects.toMatchObject({ kind: "permission" });
|
| 473 |
});
|
| 474 |
|
|
@@ -479,10 +564,13 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 479 |
installRecognitionFake();
|
| 480 |
const onError = vi.fn();
|
| 481 |
|
| 482 |
-
const session = await startDictation(
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
instance?.onerror?.({ error: "not-allowed" });
|
| 488 |
|
|
@@ -496,11 +584,14 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 496 |
const onError = vi.fn();
|
| 497 |
const onEnd = vi.fn();
|
| 498 |
|
| 499 |
-
const session = await startDictation(
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
// Chrome ends after silence even with `continuous`. The session is over,
|
| 506 |
// but there is nothing the user needs told about.
|
|
@@ -516,11 +607,14 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 516 |
const onError = vi.fn();
|
| 517 |
const onEnd = vi.fn();
|
| 518 |
|
| 519 |
-
const session = await startDictation(
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
await session.stop();
|
| 526 |
instance?.onend?.();
|
|
@@ -533,7 +627,7 @@ describe("startDictation with the web speech fallback", () => {
|
|
| 533 |
describe("startDictation without any engine", () => {
|
| 534 |
it("rejects with an unavailable error", async () => {
|
| 535 |
await expect(
|
| 536 |
-
startDictation({ onTranscript: vi.fn() }),
|
| 537 |
).rejects.toMatchObject({ kind: "unavailable" });
|
| 538 |
});
|
| 539 |
});
|
|
|
|
| 93 |
});
|
| 94 |
|
| 95 |
describe("getDictationEngine", () => {
|
| 96 |
+
function installRecognitionFake() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
class FakeRecognition {
|
| 98 |
start = vi.fn();
|
| 99 |
}
|
| 100 |
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
|
| 101 |
FakeRecognition;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
it("reports the wasm engine when it is preferred and every piece is present", () => {
|
| 105 |
+
stubWasmSupport(vi.fn());
|
| 106 |
+
expect(getDictationEngine(true)).toBe("wasm");
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
it("falls back to web-speech when the wasm pieces are missing", () => {
|
| 110 |
+
installRecognitionFake();
|
| 111 |
+
expect(getDictationEngine(true)).toBe("web-speech");
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
it("prefers web-speech over a capable wasm path when the local model is off", () => {
|
| 115 |
+
stubWasmSupport(vi.fn());
|
| 116 |
+
installRecognitionFake();
|
| 117 |
+
expect(getDictationEngine(false)).toBe("web-speech");
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("keeps the wasm engine when the local model is off but no recognizer exists", () => {
|
| 121 |
+
// The preference reorders engines, it never empties the list: a browser
|
| 122 |
+
// without `SpeechRecognition` (Firefox, for one) must still get a
|
| 123 |
+
// working Dictate button with the setting off.
|
| 124 |
+
stubWasmSupport(vi.fn());
|
| 125 |
+
expect(getDictationEngine(false)).toBe("wasm");
|
| 126 |
});
|
| 127 |
|
| 128 |
it("reports no engine when neither path is available", () => {
|
| 129 |
+
expect(getDictationEngine(true)).toBeNull();
|
| 130 |
+
expect(getDictationEngine(false)).toBeNull();
|
| 131 |
});
|
| 132 |
});
|
| 133 |
|
|
|
|
| 156 |
const transcripts: string[] = [];
|
| 157 |
const progress: [number, number | undefined][] = [];
|
| 158 |
|
| 159 |
+
const sessionPromise = startDictation(
|
| 160 |
+
{
|
| 161 |
+
onTranscript: (text) => transcripts.push(text),
|
| 162 |
+
onProgress: (loaded, total) => progress.push([loaded, total]),
|
| 163 |
+
},
|
| 164 |
+
true,
|
| 165 |
+
);
|
| 166 |
|
| 167 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 168 |
const worker = FakeWorker.instances[0];
|
|
|
|
| 195 |
stubWasmSupport(getUserMedia);
|
| 196 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 197 |
|
| 198 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 199 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 200 |
FakeWorker.instances[0].respond({ type: "loaded" });
|
| 201 |
|
|
|
|
| 229 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 230 |
const started = installLocalRecognitionFake();
|
| 231 |
|
| 232 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 233 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 234 |
// A 502 from `/dictation-models/` is "cannot run here", not "no engine".
|
| 235 |
FakeWorker.instances[0].respond({
|
|
|
|
| 252 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 253 |
const started = installLocalRecognitionFake();
|
| 254 |
|
| 255 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 256 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 257 |
FakeWorker.instances[0].respond({ type: "loaded" });
|
| 258 |
|
|
|
|
| 274 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 275 |
const started = installLocalRecognitionFake();
|
| 276 |
|
| 277 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 278 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 279 |
const worker = FakeWorker.instances[0];
|
| 280 |
worker.respond({ type: "loaded" });
|
|
|
|
| 304 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 305 |
const started = installLocalRecognitionFake();
|
| 306 |
|
| 307 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 308 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 309 |
const worker = FakeWorker.instances[0];
|
| 310 |
worker.respond({ type: "loaded" });
|
|
|
|
| 326 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 327 |
const onTranscript = vi.fn();
|
| 328 |
|
| 329 |
+
const sessionPromise = startDictation({ onTranscript }, true);
|
| 330 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 331 |
const worker = FakeWorker.instances[0];
|
| 332 |
worker.respond({ type: "loaded" });
|
|
|
|
| 350 |
stubWasmSupport(vi.fn().mockResolvedValue(stream));
|
| 351 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 352 |
|
| 353 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 354 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 355 |
const worker = FakeWorker.instances[0];
|
| 356 |
worker.respond({ type: "loaded" });
|
|
|
|
| 373 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 374 |
const onError = vi.fn();
|
| 375 |
|
| 376 |
+
const sessionPromise = startDictation(
|
| 377 |
+
{
|
| 378 |
+
onTranscript: vi.fn(),
|
| 379 |
+
onError,
|
| 380 |
+
},
|
| 381 |
+
true,
|
| 382 |
+
);
|
| 383 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 384 |
const worker = FakeWorker.instances[0];
|
| 385 |
worker.respond({ type: "loaded" });
|
|
|
|
| 399 |
stubWasmSupport(getUserMedia);
|
| 400 |
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 401 |
|
| 402 |
+
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
|
| 403 |
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
|
| 404 |
const worker = FakeWorker.instances[0];
|
| 405 |
worker.respond({ type: "error", message: "model download failed" });
|
|
|
|
| 416 |
});
|
| 417 |
});
|
| 418 |
|
| 419 |
+
describe("startDictation with the local model preference off", () => {
|
| 420 |
+
let recognitionStarted = false;
|
| 421 |
+
|
| 422 |
+
function installRecognitionFake(
|
| 423 |
+
startImpl: () => void = () => {
|
| 424 |
+
recognitionStarted = true;
|
| 425 |
+
},
|
| 426 |
+
) {
|
| 427 |
+
class FakeRecognition {
|
| 428 |
+
continuous = false;
|
| 429 |
+
interimResults = false;
|
| 430 |
+
lang = "";
|
| 431 |
+
onresult: unknown = null;
|
| 432 |
+
onerror: unknown = null;
|
| 433 |
+
onend: unknown = null;
|
| 434 |
+
start = startImpl;
|
| 435 |
+
stop = () => {};
|
| 436 |
+
}
|
| 437 |
+
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
|
| 438 |
+
FakeRecognition;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
beforeEach(() => {
|
| 442 |
+
recognitionStarted = false;
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
it("never constructs the model worker on a fully wasm-capable browser", async () => {
|
| 446 |
+
// The whole point of the setting: with it off, the ~51 MB model must
|
| 447 |
+
// not be downloaded even where it could run.
|
| 448 |
+
const { stream } = makeMediaStream();
|
| 449 |
+
stubWasmSupport(vi.fn().mockResolvedValue(stream));
|
| 450 |
+
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 451 |
+
installRecognitionFake();
|
| 452 |
+
|
| 453 |
+
const session = await startDictation({ onTranscript: vi.fn() }, false);
|
| 454 |
+
|
| 455 |
+
expect(FakeWorker.instances.length).toBe(0);
|
| 456 |
+
expect(recognitionStarted).toBe(true);
|
| 457 |
+
await session.stop();
|
| 458 |
+
});
|
| 459 |
+
|
| 460 |
+
it("does not fall back to the wasm engine when the recognizer fails", async () => {
|
| 461 |
+
// A reverse fallback would start the ~51 MB download right after the
|
| 462 |
+
// user turned that model off; the failure must surface instead.
|
| 463 |
+
stubWasmSupport(vi.fn());
|
| 464 |
+
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
|
| 465 |
+
installRecognitionFake(() => {
|
| 466 |
+
throw new Error("recognizer refused to start");
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
await expect(
|
| 470 |
+
startDictation({ onTranscript: vi.fn() }, false),
|
| 471 |
+
).rejects.toMatchObject({ kind: "unavailable" });
|
| 472 |
+
expect(FakeWorker.instances.length).toBe(0);
|
| 473 |
+
});
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
describe("startDictation with the web speech fallback", () => {
|
| 477 |
interface RecognitionFake {
|
| 478 |
continuous: boolean;
|
|
|
|
| 519 |
installRecognitionFake();
|
| 520 |
|
| 521 |
const transcripts: string[] = [];
|
| 522 |
+
const session = await startDictation(
|
| 523 |
+
{
|
| 524 |
+
onTranscript: (text) => transcripts.push(text),
|
| 525 |
+
},
|
| 526 |
+
true,
|
| 527 |
+
);
|
| 528 |
|
| 529 |
expect(instance?.continuous).toBe(true);
|
| 530 |
expect(instance?.interimResults).toBe(true);
|
|
|
|
| 553 |
});
|
| 554 |
|
| 555 |
await expect(
|
| 556 |
+
startDictation({ onTranscript: vi.fn() }, true),
|
| 557 |
).rejects.toMatchObject({ kind: "permission" });
|
| 558 |
});
|
| 559 |
|
|
|
|
| 564 |
installRecognitionFake();
|
| 565 |
const onError = vi.fn();
|
| 566 |
|
| 567 |
+
const session = await startDictation(
|
| 568 |
+
{
|
| 569 |
+
onTranscript: vi.fn(),
|
| 570 |
+
onError,
|
| 571 |
+
},
|
| 572 |
+
true,
|
| 573 |
+
);
|
| 574 |
|
| 575 |
instance?.onerror?.({ error: "not-allowed" });
|
| 576 |
|
|
|
|
| 584 |
const onError = vi.fn();
|
| 585 |
const onEnd = vi.fn();
|
| 586 |
|
| 587 |
+
const session = await startDictation(
|
| 588 |
+
{
|
| 589 |
+
onTranscript: vi.fn(),
|
| 590 |
+
onEnd,
|
| 591 |
+
onError,
|
| 592 |
+
},
|
| 593 |
+
true,
|
| 594 |
+
);
|
| 595 |
|
| 596 |
// Chrome ends after silence even with `continuous`. The session is over,
|
| 597 |
// but there is nothing the user needs told about.
|
|
|
|
| 607 |
const onError = vi.fn();
|
| 608 |
const onEnd = vi.fn();
|
| 609 |
|
| 610 |
+
const session = await startDictation(
|
| 611 |
+
{
|
| 612 |
+
onTranscript: vi.fn(),
|
| 613 |
+
onEnd,
|
| 614 |
+
onError,
|
| 615 |
+
},
|
| 616 |
+
true,
|
| 617 |
+
);
|
| 618 |
|
| 619 |
await session.stop();
|
| 620 |
instance?.onend?.();
|
|
|
|
| 627 |
describe("startDictation without any engine", () => {
|
| 628 |
it("rejects with an unavailable error", async () => {
|
| 629 |
await expect(
|
| 630 |
+
startDictation({ onTranscript: vi.fn() }, true),
|
| 631 |
).rejects.toMatchObject({ kind: "unavailable" });
|
| 632 |
});
|
| 633 |
});
|
client/modules/speechToText.ts
CHANGED
|
@@ -71,23 +71,32 @@ function getSpeechRecognitionConstructor(): SpeechRecognitionConstructor | null
|
|
| 71 |
/**
|
| 72 |
* The engine that would run a dictation right now, or null when neither the
|
| 73 |
* WASM path nor `SpeechRecognition` is available and the button should hide.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
*/
|
| 75 |
-
export function getDictationEngine(
|
|
|
|
|
|
|
| 76 |
// Neither engine can open a microphone outside a secure context. Without
|
| 77 |
// this, a plain-HTTP LAN deployment falls through to `SpeechRecognition`,
|
| 78 |
// which then reports `not-allowed` and tells the user permission was denied
|
| 79 |
// when the real cause is the origin.
|
| 80 |
if (typeof isSecureContext !== "undefined" && !isSecureContext) return null;
|
| 81 |
-
|
| 82 |
typeof Worker !== "undefined" &&
|
| 83 |
typeof WebAssembly !== "undefined" &&
|
| 84 |
typeof AudioContext !== "undefined" &&
|
| 85 |
-
typeof navigator.mediaDevices?.getUserMedia === "function"
|
| 86 |
-
)
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
-
if (
|
| 90 |
-
return null;
|
| 91 |
}
|
| 92 |
|
| 93 |
export interface DictationCallbacks {
|
|
@@ -433,8 +442,9 @@ function startWebSpeechDictation(
|
|
| 433 |
*/
|
| 434 |
export async function startDictation(
|
| 435 |
callbacks: DictationCallbacks,
|
|
|
|
| 436 |
): Promise<DictationSession> {
|
| 437 |
-
const engine = getDictationEngine();
|
| 438 |
if (engine === "wasm") {
|
| 439 |
try {
|
| 440 |
return await startWasmDictation(callbacks);
|
|
@@ -454,7 +464,13 @@ export async function startDictation(
|
|
| 454 |
return startWebSpeechDictation(callbacks);
|
| 455 |
}
|
| 456 |
}
|
| 457 |
-
if (engine === "web-speech")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
addLogEntry("Dictation is not available in this browser");
|
| 459 |
throw new DictationError(
|
| 460 |
"unavailable",
|
|
|
|
| 71 |
/**
|
| 72 |
* The engine that would run a dictation right now, or null when neither the
|
| 73 |
* WASM path nor `SpeechRecognition` is available and the button should hide.
|
| 74 |
+
*
|
| 75 |
+
* `preferLocalModel` reorders the two engines; it never empties the list.
|
| 76 |
+
* A user who turns the on-device model off on a browser without
|
| 77 |
+
* `SpeechRecognition` (Firefox, for one) must still get a working Dictate
|
| 78 |
+
* button, so the preference cannot be an exclusion.
|
| 79 |
*/
|
| 80 |
+
export function getDictationEngine(
|
| 81 |
+
preferLocalModel: boolean,
|
| 82 |
+
): DictationEngine | null {
|
| 83 |
// Neither engine can open a microphone outside a secure context. Without
|
| 84 |
// this, a plain-HTTP LAN deployment falls through to `SpeechRecognition`,
|
| 85 |
// which then reports `not-allowed` and tells the user permission was denied
|
| 86 |
// when the real cause is the origin.
|
| 87 |
if (typeof isSecureContext !== "undefined" && !isSecureContext) return null;
|
| 88 |
+
const wasmCapable =
|
| 89 |
typeof Worker !== "undefined" &&
|
| 90 |
typeof WebAssembly !== "undefined" &&
|
| 91 |
typeof AudioContext !== "undefined" &&
|
| 92 |
+
typeof navigator.mediaDevices?.getUserMedia === "function";
|
| 93 |
+
const webSpeechAvailable = getSpeechRecognitionConstructor() !== null;
|
| 94 |
+
if (preferLocalModel) {
|
| 95 |
+
if (wasmCapable) return "wasm";
|
| 96 |
+
return webSpeechAvailable ? "web-speech" : null;
|
| 97 |
}
|
| 98 |
+
if (webSpeechAvailable) return "web-speech";
|
| 99 |
+
return wasmCapable ? "wasm" : null;
|
| 100 |
}
|
| 101 |
|
| 102 |
export interface DictationCallbacks {
|
|
|
|
| 442 |
*/
|
| 443 |
export async function startDictation(
|
| 444 |
callbacks: DictationCallbacks,
|
| 445 |
+
preferLocalModel: boolean,
|
| 446 |
): Promise<DictationSession> {
|
| 447 |
+
const engine = getDictationEngine(preferLocalModel);
|
| 448 |
if (engine === "wasm") {
|
| 449 |
try {
|
| 450 |
return await startWasmDictation(callbacks);
|
|
|
|
| 464 |
return startWebSpeechDictation(callbacks);
|
| 465 |
}
|
| 466 |
}
|
| 467 |
+
if (engine === "web-speech") {
|
| 468 |
+
// No reverse fallback to wasm when this recognizer fails. Falling through
|
| 469 |
+
// would start a ~51 MB model download right after the user turned that
|
| 470 |
+
// model off; a clear failure is the better answer, and it matches what
|
| 471 |
+
// web-speech-only browsers already do.
|
| 472 |
+
return startWebSpeechDictation(callbacks);
|
| 473 |
+
}
|
| 474 |
addLogEntry("Dictation is not available in this browser");
|
| 475 |
throw new DictationError(
|
| 476 |
"unavailable",
|
docs/configuration.md
CHANGED
|
@@ -188,7 +188,8 @@ Search results:
|
|
| 188 |
|---------|------|---------|-------------|
|
| 189 |
| `selectedVoiceId` | string | `""` | Voice for reading answers aloud. `piper:<voice>` picks a local neural voice, `system:<voiceURI>` an OS voice, and `""` picks one automatically for the browser language. The Voice settings panel lists only the voices of the selected engine, and switching that engine resets this to `""`. A bare value stored by an earlier version is read as an OS voice whenever the system engine runs; with the local engine it is ignored in favor of a language match |
|
| 190 |
| `textToSpeechEngine` | `"local" \| "system"` | `"local"` | Which engine reads answers aloud, and which set of voices the Voice settings panel lists. `"system"` also overrides a local voice picked above, and stops the voice list from being fetched, so it never contacts a third-party host. A profile that had picked an OS voice before this setting existed starts on `"system"`, so the upgrade does not replace that choice with a model download |
|
| 191 |
-
| `enableDictation` | boolean | `true` | Shows the
|
|
|
|
| 192 |
| `reasoningStartMarker` | string | `"<think>"` | Marker for start of reasoning content |
|
| 193 |
| `reasoningEndMarker` | string | `"</think>"` | Marker for end of reasoning content |
|
| 194 |
|
|
|
|
| 188 |
|---------|------|---------|-------------|
|
| 189 |
| `selectedVoiceId` | string | `""` | Voice for reading answers aloud. `piper:<voice>` picks a local neural voice, `system:<voiceURI>` an OS voice, and `""` picks one automatically for the browser language. The Voice settings panel lists only the voices of the selected engine, and switching that engine resets this to `""`. A bare value stored by an earlier version is read as an OS voice whenever the system engine runs; with the local engine it is ignored in favor of a language match |
|
| 190 |
| `textToSpeechEngine` | `"local" \| "system"` | `"local"` | Which engine reads answers aloud, and which set of voices the Voice settings panel lists. `"system"` also overrides a local voice picked above, and stops the voice list from being fetched, so it never contacts a third-party host. A profile that had picked an OS voice before this setting existed starts on `"system"`, so the upgrade does not replace that choice with a model download |
|
| 191 |
+
| `enableDictation` | boolean | `true` | Shows the Dictate button that fills the search field while the user speaks. Turning this off hides the button and loads nothing. The button is also hidden outside a secure context, so an instance served over plain HTTP on a LAN will not show it: no browser grants microphone access there |
|
| 192 |
+
| `enableLocalDictationModel` | boolean | `true` only when the browser's primary language is English | Whether the Dictate button prefers the on-device Moonshine model, which understands English only. With it off, the button uses the browser's built-in `SpeechRecognition`, which handles other languages but may send the audio to the browser vendor. The preference reorders the two engines and never removes one: on browsers without `SpeechRecognition` the on-device model is used regardless. The default is derived from `navigator.language` at first load, so a non-English browser starts on the built-in recognizer with no model download; the Voice settings panel can flip it either way |
|
| 193 |
| `reasoningStartMarker` | string | `"<think>"` | Marker for start of reasoning content |
|
| 194 |
| `reasoningEndMarker` | string | `"</think>"` | Marker for end of reasoning content |
|
| 195 |
|
docs/failure-injection.md
CHANGED
|
@@ -73,7 +73,8 @@ needed.
|
|
| 73 |
| A follow-up question generation rejects after a newer one started | The failed call blanks nothing, so the newer question still lands and the flag stays on until it does | `client/components/AiResponse/ChatInterface.test.tsx` › blanks nothing when an orphaned call rejects after a newer one started |
|
| 74 |
| `ChatInterface` unmounts while a regenerate is still awaiting its response | The orphaned regenerate leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › leaves the next mount's response flag alone when an orphaned regenerate settles |
|
| 75 |
| `ChatInterface` unmounts while a send is still awaiting its response | The orphaned send asks for no follow-up question and leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › starts no follow-up question for a send that settles after unmount, leaves the next mount's response flag alone when an orphaned send settles |
|
| 76 |
-
| The dictation model cannot load in the browser |
|
|
|
|
| 77 |
| Microphone permission is denied | A notification says so instead of the press failing quietly, and no fallback asks again | `client/components/Search/Form/DictationButton.test.tsx` › shows a notification when microphone permission is denied, `client/modules/speechToText.test.ts` › reports a denied microphone instead of falling back |
|
| 78 |
| The dictation engine fails while the microphone permission prompt is open | The load fails rather than resolving into a dead transcriber, so the browser recognizer takes over | `client/modules/speechToText.test.ts` › fails the load when the engine dies while permission is pending, fails the load when the worker dies uncaught while permission is pending |
|
| 79 |
| The browser recognizer is denied after the session started | Reported through the error callback, since the promise it would have rejected has already resolved | `client/modules/speechToText.test.ts` › reports a denial that arrives after the session resolved |
|
|
|
|
| 73 |
| A follow-up question generation rejects after a newer one started | The failed call blanks nothing, so the newer question still lands and the flag stays on until it does | `client/components/AiResponse/ChatInterface.test.tsx` › blanks nothing when an orphaned call rejects after a newer one started |
|
| 74 |
| `ChatInterface` unmounts while a regenerate is still awaiting its response | The orphaned regenerate leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › leaves the next mount's response flag alone when an orphaned regenerate settles |
|
| 75 |
| `ChatInterface` unmounts while a send is still awaiting its response | The orphaned send asks for no follow-up question and leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › starts no follow-up question for a send that settles after unmount, leaves the next mount's response flag alone when an orphaned send settles |
|
| 76 |
+
| The dictation model cannot load in the browser | While the on-device model is preferred, the local engine is abandoned for the browser's `SpeechRecognition`, and the button hides when that is missing too | `client/modules/speechToText.test.ts` › falls back to the browser recognizer when the local engine fails |
|
| 77 |
+
| The browser recognizer fails while the on-device model is off | The session fails outright; there is no reverse fallback, so the disabled model is never downloaded behind the user's back | `client/modules/speechToText.test.ts` › does not fall back to the wasm engine when the recognizer fails |
|
| 78 |
| Microphone permission is denied | A notification says so instead of the press failing quietly, and no fallback asks again | `client/components/Search/Form/DictationButton.test.tsx` › shows a notification when microphone permission is denied, `client/modules/speechToText.test.ts` › reports a denied microphone instead of falling back |
|
| 79 |
| The dictation engine fails while the microphone permission prompt is open | The load fails rather than resolving into a dead transcriber, so the browser recognizer takes over | `client/modules/speechToText.test.ts` › fails the load when the engine dies while permission is pending, fails the load when the worker dies uncaught while permission is pending |
|
| 80 |
| The browser recognizer is denied after the session started | Reported through the error callback, since the promise it would have rejected has already resolved | `client/modules/speechToText.test.ts` › reports a denial that arrives after the session resolved |
|
docs/project-structure.md
CHANGED
|
@@ -59,7 +59,7 @@ The `client/modules/` directory contains core business logic organized by domain
|
|
| 59 |
| `textToSpeechWorker.ts` | Synthesizes sentences off the main thread |
|
| 60 |
| `textToSpeechWorkerProtocol.ts` | Message types shared by both sides of the worker |
|
| 61 |
| `piper.ts` | The only module that imports `@diffusionstudio/vits-web` |
|
| 62 |
-
| `speechToText.ts` | Dictation: microphone capture, the worker's lifecycle, and the `SpeechRecognition`
|
| 63 |
| `speechToTextWorker.ts` | Runs the Moonshine transcriber off the main thread |
|
| 64 |
| `speechToTextWorkerProtocol.ts` | Message types shared by both sides of the dictation worker |
|
| 65 |
| `moonshine.ts` | The only module that imports `@moonshine-ai/moonshine-wasm` |
|
|
|
|
| 59 |
| `textToSpeechWorker.ts` | Synthesizes sentences off the main thread |
|
| 60 |
| `textToSpeechWorkerProtocol.ts` | Message types shared by both sides of the worker |
|
| 61 |
| `piper.ts` | The only module that imports `@diffusionstudio/vits-web` |
|
| 62 |
+
| `speechToText.ts` | Dictation: microphone capture, the worker's lifecycle, and engine selection between the on-device model and `SpeechRecognition` |
|
| 63 |
| `speechToTextWorker.ts` | Runs the Moonshine transcriber off the main thread |
|
| 64 |
| `speechToTextWorkerProtocol.ts` | Message types shared by both sides of the dictation worker |
|
| 65 |
| `moonshine.ts` | The only module that imports `@moonshine-ai/moonshine-wasm` |
|
docs/security.md
CHANGED
|
@@ -49,7 +49,7 @@ Every HTTP request from client to backend carries a `token` query parameter for
|
|
| 49 |
- **Page Reading Is On By Default, And Reversible**: `enablePageContentFetch` ships on and the user can turn it off under AI Settings; nothing is read until AI responses are enabled, and the server (never the browser) requests the top result pages, so those sites see the instance and no user cookies or IP
|
| 50 |
- **Search And Page Reading Leave No Query In The Log**: The search query is never written to the server log, and neither is the URL of a page read for grounding; how often searches come back empty, fail on unresponsive engines or are fully discarded, and how page reads ended, are counted instead and reported on `/status` (`searchesWithoutResults`, `searchesWithUnresponsiveEngines`, `searchesWithAllResultsDiscarded`, `pageReads`). `searches.unresponsiveEngines` names the engines behind those failures, which is configured infrastructure rather than anything a user typed, the same basis on which `inference.byModel` names model ids. What each failed with is published as one of `blocked`, `timeout` or `other`, never SearXNG's own wording: `/status` needs no token, and an upstream engine is free to build its error text out of the request URL, and so out of the query. The raw string stays in the server log and in the thrown error the search endpoint catches; the 502 it answers with carries a fixed message, so the string never leaves the server. Anything in front of the instance keeps its own access log, where `?q=` appears in full
|
| 51 |
|
| 52 |
-
- **Dictation Runs
|
| 53 |
|
| 54 |
- **Reading Answers Aloud Contacts Third-Party Hosts**: The local text-to-speech engine fetches from outside the instance. It downloads the voice index and the model from `huggingface.co` (served from `us.aws.cdn.hf.co`), the ONNX runtime from `cdnjs.cloudflare.com` and the phonemizer from `cdn.jsdelivr.net`, because `@diffusionstudio/vits-web` hardcodes those URLs. Browser inference already downloads its models from HuggingFace, so `cdnjs.cloudflare.com` and `cdn.jsdelivr.net` are the two hosts new to this feature; unlike wllama, whose runtime is bundled with the app, this engine fetches its runtime at use time. Those hosts see the user's IP and that they used the feature. Nothing is fetched until the user presses Listen or opens the Voice settings panel with the local engine selected, and the voice model is cached in OPFS afterwards, while the index, runtime and phonemizer are fetched again on a later visit. Switching `textToSpeechEngine` to `system` avoids all of these requests, including the voice list
|
| 55 |
|
|
|
|
| 49 |
- **Page Reading Is On By Default, And Reversible**: `enablePageContentFetch` ships on and the user can turn it off under AI Settings; nothing is read until AI responses are enabled, and the server (never the browser) requests the top result pages, so those sites see the instance and no user cookies or IP
|
| 50 |
- **Search And Page Reading Leave No Query In The Log**: The search query is never written to the server log, and neither is the URL of a page read for grounding; how often searches come back empty, fail on unresponsive engines or are fully discarded, and how page reads ended, are counted instead and reported on `/status` (`searchesWithoutResults`, `searchesWithUnresponsiveEngines`, `searchesWithAllResultsDiscarded`, `pageReads`). `searches.unresponsiveEngines` names the engines behind those failures, which is configured infrastructure rather than anything a user typed, the same basis on which `inference.byModel` names model ids. What each failed with is published as one of `blocked`, `timeout` or `other`, never SearXNG's own wording: `/status` needs no token, and an upstream engine is free to build its error text out of the request URL, and so out of the query. The raw string stays in the server log and in the thrown error the search endpoint catches; the 502 it answers with carries a fixed message, so the string never leaves the server. Anything in front of the instance keeps its own access log, where `?q=` appears in full
|
| 51 |
|
| 52 |
+
- **Dictation: Which Engine Runs, And Where The Audio Goes**: The Dictate button has two engines. The on-device engine transcribes with the Moonshine streaming model running as WebAssembly in a worker; the audio is read locally and never leaves the device, but the model understands English only. The browser engine is the browser's own `SpeechRecognition`, which handles other languages but sends audio to the browser vendor. Which one runs is decided by `enableLocalDictationModel`, and that defaults to on only when the browser's primary language is English, so a non-English browser starts on the vendor recognizer rather than on a model that cannot transcribe its user's speech. The preference reorders the engines and never removes one: on browsers without `SpeechRecognition` the on-device model runs regardless. When the preferred engine cannot run, the switch from on-device to browser happens only while the local model is on, and a notification announces it because the audio's destination changed; the reverse never happens, so a user who turned the model off never gets a surprise ~51 MB download after a recognizer failure. A denied microphone is never retried on the browser engine, since that is the user's answer rather than an engine that cannot run. Nothing loads until the button is pressed, so a user who never dictates pays nothing for the feature. The model files are served by the instance itself under `/dictation-models/` (`server/dictationModelServerHook.ts`), which only resolves a fixed whitelist of filenames for the pinned MIT-licensed English streaming model, fetches them once from `download.moonshine.ai`, and caches them on disk under `DICTATION_MODELS_DIR`; the page makes no third-party requests. The browser asks for microphone permission on the first press; a denied permission shows a notification instead of failing quietly, on either engine. On the browser engine the denial arrives after the session has started, so it is reported through the same notification rather than the press simply failing. When neither engine is available, or the page is not in a secure context, the button hides. Users can turn the feature off with `enableDictation` in the Voice settings
|
| 53 |
|
| 54 |
- **Reading Answers Aloud Contacts Third-Party Hosts**: The local text-to-speech engine fetches from outside the instance. It downloads the voice index and the model from `huggingface.co` (served from `us.aws.cdn.hf.co`), the ONNX runtime from `cdnjs.cloudflare.com` and the phonemizer from `cdn.jsdelivr.net`, because `@diffusionstudio/vits-web` hardcodes those URLs. Browser inference already downloads its models from HuggingFace, so `cdnjs.cloudflare.com` and `cdn.jsdelivr.net` are the two hosts new to this feature; unlike wllama, whose runtime is bundled with the app, this engine fetches its runtime at use time. Those hosts see the user's IP and that they used the feature. Nothing is fetched until the user presses Listen or opens the Voice settings panel with the local engine selected, and the voice model is cached in OPFS afterwards, while the index, runtime and phonemizer are fetched again on a later visit. Switching `textToSpeechEngine` to `system` avoids all of these requests, including the voice list
|
| 55 |
|
docs/ui-components.md
CHANGED
|
@@ -261,7 +261,7 @@ function AiResponseSection() {
|
|
| 261 |
- **SearchSettingsForm:** Result limits, image search toggle
|
| 262 |
- **InterfaceSettingsForm:** UI preferences
|
| 263 |
- **HistorySettings:** Retention days, max entries
|
| 264 |
-
- **VoiceSettingsForm:** Dictation toggle, text-to-speech engine and voice selection
|
| 265 |
- **ActionsForm:** Data management actions
|
| 266 |
|
| 267 |
**PubSub:**
|
|
|
|
| 261 |
- **SearchSettingsForm:** Result limits, image search toggle
|
| 262 |
- **InterfaceSettingsForm:** UI preferences
|
| 263 |
- **HistorySettings:** Retention days, max entries
|
| 264 |
+
- **VoiceSettingsForm:** Dictation toggle, on-device dictation model toggle, text-to-speech engine and voice selection
|
| 265 |
- **ActionsForm:** Data management actions
|
| 266 |
|
| 267 |
**PubSub:**
|