system HF Staff commited on
Commit
d84bd10
·
verified ·
1 Parent(s): a39b7f4

Sync from felladrin/MiniSearch@998b6c8

Browse files
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` fallback
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 microphone button next to the search field. The speech is transcribed on this device."
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
- onTranscript: handleTranscript,
139
- onProgress: (loaded, total) => setProgress({ loaded, total }),
140
- onFallback: () =>
141
- notifications.show({
142
- title: "Using the browser's recognizer",
143
- message:
144
- "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.",
145
- color: "yellow",
146
- }),
147
- onEnd: () => void stop(),
148
- onError: (error) => {
149
- const denied = error.kind === "permission";
150
- notifications.show({
151
- title: denied
152
- ? "Microphone permission denied"
153
- : "Dictation stopped",
154
- message: denied
155
- ? "Allow microphone access in the browser settings to dictate a search."
156
- : error.message,
157
- color: "red",
158
- });
159
- void stop();
 
 
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 (!settings.enableDictation || getDictationEngine() === null) return null;
 
 
 
 
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
- it("reports the wasm engine when every piece is present", () => {
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
- expect(getDictationEngine()).toBe("web-speech");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- onTranscript: (text) => transcripts.push(text),
142
- onProgress: (loaded, total) => progress.push([loaded, total]),
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
- onTranscript: vi.fn(),
356
- onError,
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
- onTranscript: (text) => transcripts.push(text),
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
- onTranscript: vi.fn(),
484
- onError,
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
- onTranscript: vi.fn(),
501
- onEnd,
502
- onError,
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
- onTranscript: vi.fn(),
521
- onEnd,
522
- onError,
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(): DictationEngine | null {
 
 
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
- if (
82
  typeof Worker !== "undefined" &&
83
  typeof WebAssembly !== "undefined" &&
84
  typeof AudioContext !== "undefined" &&
85
- typeof navigator.mediaDevices?.getUserMedia === "function"
86
- ) {
87
- return "wasm";
 
 
88
  }
89
- if (getSpeechRecognitionConstructor()) return "web-speech";
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") return startWebSpeechDictation(callbacks);
 
 
 
 
 
 
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 microphone button that fills the search field while the user speaks. Transcription runs on the device; 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
  | `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 | 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
  | 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` fallback |
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 On The Device**: The microphone button transcribes speech with the Moonshine streaming model running as WebAssembly in a worker; the audio is read locally and never leaves the device. 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 fallback the denial arrives after the session has started, so it is reported through the same notification rather than the press simply failing. When the WASM path cannot run, whether because the browser lacks it or because the model fails to load, the button falls back to the browser's own `SpeechRecognition`, which does send audio to the browser vendor; a notification says so at the moment it happens. A denied microphone is never retried that way, since that is the user's answer rather than an engine that cannot run. When neither path 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
 
 
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:**