system HF Staff commited on
Commit
856802f
ยท
verified ยท
1 Parent(s): 74dbd63

Sync from felladrin/MiniSearch@74f42b1

Browse files
changelog.md CHANGED
@@ -7,6 +7,8 @@ Notable changes, grouped by date, most recent first.
7
  - The in-browser engine, wllama, moves from 3.8.1 to 3.13.0. It now handles a model download that stopped midway by itself, so MiniSearch no longer deletes those files before each load.
8
  - Dictation's on-device model is now Qwen3-ASR 0.6B, run through wllama, instead of Moonshine. It understands every language MiniSearch answers in, not only English, so the setting that prefers it is now on by default for every browser; a profile saved before keeps its value, so a non-English profile turns it on in the Voice settings. The text now appears when the user stops recording rather than while they speak (about 2 seconds for a short query on WebGPU), and a recording ends on its own after two minutes. The first press downloads about 700 MB from Hugging Face, against 51 MB before, and later presses load it from the browser's storage in about 2 seconds; leaving the page or turning dictation off mid-download aborts it. The Moonshine runtime leaves the build, which shrinks from 35.7 MB to 22.2 MB, and the `/dictation-models/` route and `DICTATION_MODELS_DIR` are gone, since the server no longer serves model files. `docs/speech-model-evaluation.md` records the speech models tried for dictation and reading aloud, and why the others were turned down.
9
 
 
 
10
  ## 2026-11-08
11
 
12
  - MiniSearch now checks that the GPU can run wllama's shaders before using it, instead of only checking that the browser has WebGPU. The shaders use subgroups and the `subgroup_id` builtin, so the GPU is used only when the browser's shader compiler supports `subgroup_id`, an adapter answers within 5 seconds, it is not a software fallback, and it has the `subgroups` feature. Otherwise the model runs on the CPU and a new profile starts on the CPU default model. On Chrome 137, which has WebGPU but not `subgroup_id`, every model used to abort; it now answers on the CPU with LittleLamb 290M. The logs say which check failed.
 
7
  - The in-browser engine, wllama, moves from 3.8.1 to 3.13.0. It now handles a model download that stopped midway by itself, so MiniSearch no longer deletes those files before each load.
8
  - Dictation's on-device model is now Qwen3-ASR 0.6B, run through wllama, instead of Moonshine. It understands every language MiniSearch answers in, not only English, so the setting that prefers it is now on by default for every browser; a profile saved before keeps its value, so a non-English profile turns it on in the Voice settings. The text now appears when the user stops recording rather than while they speak (about 2 seconds for a short query on WebGPU), and a recording ends on its own after two minutes. The first press downloads about 700 MB from Hugging Face, against 51 MB before, and later presses load it from the browser's storage in about 2 seconds; leaving the page or turning dictation off mid-download aborts it. The Moonshine runtime leaves the build, which shrinks from 35.7 MB to 22.2 MB, and the `/dictation-models/` route and `DICTATION_MODELS_DIR` are gone, since the server no longer serves model files. `docs/speech-model-evaluation.md` records the speech models tried for dictation and reading aloud, and why the others were turned down.
9
 
10
+ - Reading answers aloud now speaks the answer's language. With no voice chosen, the voice is picked for the language the answer is written in, detected with `franc-min`, instead of the browser's: on an English browser, an answer in Portuguese is read by a Portuguese voice. The Voice settings panel lists every local voice (124 in 34 languages today) grouped by language, with the browser's language first, where it used to list only the browser's language, and the engine is no longer labeled "English-only". The detector is a new dependency, a 109 KB script (51 KB compressed) loaded on the first Listen press rather than with the page. A browser language the detector does not know (Danish, Finnish and Catalan among them) keeps its voice, as before, and a chosen voice reads every answer. Japanese, Korean and Hindi have no local voice, so their answers are read by an OS voice of that language.
11
+
12
  ## 2026-11-08
13
 
14
  - MiniSearch now checks that the GPU can run wllama's shaders before using it, instead of only checking that the browser has WebGPU. The shaders use subgroups and the `subgroup_id` builtin, so the GPU is used only when the browser's shader compiler supports `subgroup_id`, an adapter answers within 5 seconds, it is not a software fallback, and it has the `subgroups` feature. Otherwise the model runs on the CPU and a new profile starts on the CPU default model. On Chrome 137, which has WebGPU but not `subgroup_id`, every model used to abort; it now answers on the CPU with LittleLamb 290M. The logs say which check failed.
client/components/Pages/Main/Menu/VoiceSettingsForm.test.tsx CHANGED
@@ -9,8 +9,17 @@ const localVoice: VoiceOption = {
9
  label: "lessac โ€ข English (high)",
10
  engine: "local",
11
  languageCode: "en_US",
 
12
  };
13
 
 
 
 
 
 
 
 
 
14
  const systemVoice: VoiceOption = {
15
  value: "system:os-voice",
16
  label: "OS Voice โ€ข en-US",
@@ -99,6 +108,30 @@ describe("VoiceSettingsForm", () => {
99
  ]);
100
  });
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  it("lists only the system voices while the system engine is selected", async () => {
103
  settings.current.textToSpeechEngine = "system";
104
  renderForm();
@@ -146,7 +179,7 @@ describe("VoiceSettingsForm", () => {
146
  const engine = screen.getAllByRole("combobox")[0] as HTMLElement;
147
  fireEvent.click(
148
  openSelect(engine).getByRole("option", {
149
- name: "System voices (Multi-lingual)",
150
  hidden: true,
151
  }),
152
  );
 
9
  label: "lessac โ€ข English (high)",
10
  engine: "local",
11
  languageCode: "en_US",
12
+ languageName: "English",
13
  };
14
 
15
+ const portugueseVoices: VoiceOption[] = ["pt_BR", "pt_PT"].map((code) => ({
16
+ value: `piper:${code}-voice-medium`,
17
+ label: `voice โ€ข Portuguese (medium)`,
18
+ engine: "local",
19
+ languageCode: code,
20
+ languageName: "Portuguese",
21
+ }));
22
+
23
  const systemVoice: VoiceOption = {
24
  value: "system:os-voice",
25
  label: "OS Voice โ€ข en-US",
 
108
  ]);
109
  });
110
 
111
+ it("groups the local voices by language, in the order they are listed", async () => {
112
+ listVoices.mockResolvedValue([...portugueseVoices, localVoice]);
113
+ renderForm();
114
+ await screen.findByText("Voice Selection");
115
+
116
+ const listbox = openSelect(voiceInput());
117
+ const groups = listbox
118
+ .getAllByRole("group", { hidden: true })
119
+ .map((group) => [
120
+ group.firstElementChild?.textContent,
121
+ within(group)
122
+ .getAllByRole("option", { hidden: true })
123
+ .map((option) => option.textContent),
124
+ ]);
125
+
126
+ expect(groups).toEqual([
127
+ [
128
+ "Portuguese",
129
+ ["๐Ÿ‡ง๐Ÿ‡ท voice โ€ข Portuguese (medium)", "๐Ÿ‡ต๐Ÿ‡น voice โ€ข Portuguese (medium)"],
130
+ ],
131
+ ["English", ["๐Ÿ‡บ๐Ÿ‡ธ lessac โ€ข English (high)"]],
132
+ ]);
133
+ });
134
+
135
  it("lists only the system voices while the system engine is selected", async () => {
136
  settings.current.textToSpeechEngine = "system";
137
  renderForm();
 
179
  const engine = screen.getAllByRole("combobox")[0] as HTMLElement;
180
  fireEvent.click(
181
  openSelect(engine).getByRole("option", {
182
+ name: "System voices",
183
  hidden: true,
184
  }),
185
  );
client/components/Pages/Main/Menu/VoiceSettingsForm.tsx CHANGED
@@ -1,4 +1,11 @@
1
- import { Select, Stack, Switch, Text } from "@mantine/core";
 
 
 
 
 
 
 
2
  import { useForm } from "@mantine/form";
3
  import getUnicodeFlagIcon from "country-flag-icons/unicode";
4
  import { usePubSub } from "create-pubsub/react";
@@ -52,6 +59,25 @@ export default function VoiceSettingsForm() {
52
  };
53
  }, [settings.textToSpeechEngine]);
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  return (
56
  <Stack gap="xs">
57
  <Switch
@@ -88,8 +114,8 @@ export default function VoiceSettingsForm() {
88
  }));
89
  }}
90
  data={[
91
- { value: "local", label: "Local neural voice (English-only)" },
92
- { value: "system", label: "System voices (Multi-lingual)" },
93
  ]}
94
  allowDeselect={false}
95
  />
@@ -97,17 +123,15 @@ export default function VoiceSettingsForm() {
97
  <Text size="xs" c="dimmed">
98
  Choose the voice to use when reading AI responses aloud. Only the voices
99
  of the selected engine are listed. Leave it empty to pick one
100
- automatically for your language.
 
101
  </Text>
102
  <Select
103
  // Remounting drops the label Mantine keeps for the previous value, which
104
  // would otherwise filter the new engine's list as if it were typed.
105
  key={settings.textToSpeechEngine}
106
  {...form.getInputProps("selectedVoiceId")}
107
- data={voiceOptions.map((option) => ({
108
- value: option.value,
109
- label: `${getCountryFlag(option.languageCode)} ${option.label}`,
110
- }))}
111
  searchable
112
  nothingFoundMessage="No voices found"
113
  placeholder="Auto-detected"
 
1
+ import {
2
+ type ComboboxItem,
3
+ type ComboboxItemGroup,
4
+ Select,
5
+ Stack,
6
+ Switch,
7
+ Text,
8
+ } from "@mantine/core";
9
  import { useForm } from "@mantine/form";
10
  import getUnicodeFlagIcon from "country-flag-icons/unicode";
11
  import { usePubSub } from "create-pubsub/react";
 
59
  };
60
  }, [settings.textToSpeechEngine]);
61
 
62
+ // `listVoices` keeps each language's voices together, so a group only ever
63
+ // continues the one before it.
64
+ const voiceData: (ComboboxItem | ComboboxItemGroup<ComboboxItem>)[] = [];
65
+ for (const option of voiceOptions) {
66
+ const item = {
67
+ value: option.value,
68
+ label: `${getCountryFlag(option.languageCode)} ${option.label}`,
69
+ };
70
+ const previous = voiceData.at(-1);
71
+ if (!option.languageName) voiceData.push(item);
72
+ else if (
73
+ previous &&
74
+ "group" in previous &&
75
+ previous.group === option.languageName
76
+ )
77
+ previous.items.push(item);
78
+ else voiceData.push({ group: option.languageName, items: [item] });
79
+ }
80
+
81
  return (
82
  <Stack gap="xs">
83
  <Switch
 
114
  }));
115
  }}
116
  data={[
117
+ { value: "local", label: "Local neural voice (many languages)" },
118
+ { value: "system", label: "System voices" },
119
  ]}
120
  allowDeselect={false}
121
  />
 
123
  <Text size="xs" c="dimmed">
124
  Choose the voice to use when reading AI responses aloud. Only the voices
125
  of the selected engine are listed. Leave it empty to pick one
126
+ automatically for the language of each response; a voice you choose
127
+ reads every response, whatever its language.
128
  </Text>
129
  <Select
130
  // Remounting drops the label Mantine keeps for the previous value, which
131
  // would otherwise filter the new engine's list as if it were typed.
132
  key={settings.textToSpeechEngine}
133
  {...form.getInputProps("selectedVoiceId")}
134
+ data={voiceData}
 
 
 
135
  searchable
136
  nothingFoundMessage="No voices found"
137
  placeholder="Auto-detected"
client/modules/textToSpeech.test.ts CHANGED
@@ -41,6 +41,37 @@ const englishVoice = {
41
  language: { code: "en_US", name_english: "English" },
42
  };
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  /** A worker stub the test drives by hand, standing in for the real one. */
45
  function createFakeWorker() {
46
  const sent: WorkerRequest[] = [];
@@ -55,6 +86,7 @@ function createFakeWorker() {
55
  }
56
 
57
  let spoken: string[] = [];
 
58
  let createdObjectUrls = 0;
59
  let handlersAtSourceClear: {
60
  onended: (() => void) | null;
@@ -65,6 +97,7 @@ beforeEach(async () => {
65
  vi.clearAllMocks();
66
  vi.resetModules();
67
  spoken = [];
 
68
  settings.selectedVoiceId = "";
69
  settings.textToSpeechEngine = "local";
70
  mockVoices.mockResolvedValue([englishVoice]);
@@ -111,8 +144,13 @@ beforeEach(async () => {
111
  { voiceURI: "os-voice", name: "OS Voice", lang: "en-US" },
112
  ],
113
  cancel: vi.fn(),
114
- speak: (utterance: { text: string; onend: (() => void) | null }) => {
 
 
 
 
115
  spoken.push(utterance.text);
 
116
  queueMicrotask(() => utterance.onend?.());
117
  },
118
  });
@@ -339,6 +377,74 @@ describe("speak", () => {
339
  expect(mockUpdateState).toHaveBeenLastCalledWith("idle");
340
  });
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  it("stops playback without falling back to the system voice", async () => {
343
  const worker = createFakeWorker();
344
  tts.setWorkerFactory(() => worker as unknown as Worker);
@@ -382,14 +488,17 @@ describe("no speech engine at all", () => {
382
  });
383
 
384
  describe("listVoices", () => {
385
- it("offers only the local voices for the language", async () => {
386
- const options = await tts.listVoices("en-US");
387
 
388
- expect(options).toEqual([
389
- expect.objectContaining({
390
- value: "piper:en_US-lessac-high",
391
- engine: "local",
392
- }),
 
 
 
393
  ]);
394
  });
395
 
 
41
  language: { code: "en_US", name_english: "English" },
42
  };
43
 
44
+ const portugueseVoice = {
45
+ key: "pt_BR-faber-medium",
46
+ name: "faber",
47
+ quality: "medium",
48
+ language: { code: "pt_BR", name_english: "Portuguese" },
49
+ };
50
+
51
+ const germanVoice = {
52
+ key: "de_DE-thorsten-medium",
53
+ name: "thorsten",
54
+ quality: "medium",
55
+ language: { code: "de_DE", name_english: "German" },
56
+ };
57
+
58
+ const swedishVoice = {
59
+ key: "sv_SE-nst-medium",
60
+ name: "nst",
61
+ quality: "medium",
62
+ language: { code: "sv_SE", name_english: "Swedish" },
63
+ };
64
+
65
+ const danishVoice = {
66
+ key: "da_DK-talesyntese-medium",
67
+ name: "talesyntese",
68
+ quality: "medium",
69
+ language: { code: "da_DK", name_english: "Danish" },
70
+ };
71
+
72
+ const portugueseAnswer =
73
+ "A Torre Eiffel foi construรญda em 1889 para a Exposiรงรฃo Universal de Paris e tem cerca de 330 metros de altura.";
74
+
75
  /** A worker stub the test drives by hand, standing in for the real one. */
76
  function createFakeWorker() {
77
  const sent: WorkerRequest[] = [];
 
86
  }
87
 
88
  let spoken: string[] = [];
89
+ let spokenLanguages: string[] = [];
90
  let createdObjectUrls = 0;
91
  let handlersAtSourceClear: {
92
  onended: (() => void) | null;
 
97
  vi.clearAllMocks();
98
  vi.resetModules();
99
  spoken = [];
100
+ spokenLanguages = [];
101
  settings.selectedVoiceId = "";
102
  settings.textToSpeechEngine = "local";
103
  mockVoices.mockResolvedValue([englishVoice]);
 
144
  { voiceURI: "os-voice", name: "OS Voice", lang: "en-US" },
145
  ],
146
  cancel: vi.fn(),
147
+ speak: (utterance: {
148
+ text: string;
149
+ lang: string;
150
+ onend: (() => void) | null;
151
+ }) => {
152
  spoken.push(utterance.text);
153
+ spokenLanguages.push(utterance.lang);
154
  queueMicrotask(() => utterance.onend?.());
155
  },
156
  });
 
377
  expect(mockUpdateState).toHaveBeenLastCalledWith("idle");
378
  });
379
 
380
+ it("picks the local voice for the answer's language, not the browser's", async () => {
381
+ mockVoices.mockResolvedValue([englishVoice, portugueseVoice]);
382
+ const worker = createFakeWorker();
383
+ tts.setWorkerFactory(() => worker as unknown as Worker);
384
+
385
+ void tts.speak(portugueseAnswer);
386
+ await vi.waitFor(() => expect(worker.sent).toHaveLength(1));
387
+
388
+ expect(worker.sent[0].voiceId).toBe("pt_BR-faber-medium");
389
+ });
390
+
391
+ it("keeps a chosen voice whatever the answer's language", async () => {
392
+ mockVoices.mockResolvedValue([englishVoice, portugueseVoice]);
393
+ settings.selectedVoiceId = "piper:en_US-lessac-high";
394
+ const worker = createFakeWorker();
395
+ tts.setWorkerFactory(() => worker as unknown as Worker);
396
+
397
+ void tts.speak(portugueseAnswer);
398
+ await vi.waitFor(() => expect(worker.sent).toHaveLength(1));
399
+
400
+ expect(worker.sent[0].voiceId).toBe("en_US-lessac-high");
401
+ });
402
+
403
+ it("keeps the browser language when the detector cannot recognize it", async () => {
404
+ // franc-min has no Danish and reads this sentence as Swedish.
405
+ vi.stubGlobal("navigator", { language: "da-DK" });
406
+ mockVoices.mockResolvedValue([swedishVoice, danishVoice]);
407
+ const worker = createFakeWorker();
408
+ tts.setWorkerFactory(() => worker as unknown as Worker);
409
+
410
+ void tts.speak(
411
+ "Eiffeltรฅrnet blev bygget i 1889 til verdensudstillingen i Paris og er omkring 330 meter hรธjt.",
412
+ );
413
+ await vi.waitFor(() => expect(worker.sent).toHaveLength(1));
414
+
415
+ expect(worker.sent[0].voiceId).toBe("da_DK-talesyntese-medium");
416
+ });
417
+
418
+ it("uses the browser language when the detector fails to load", async () => {
419
+ vi.doMock("franc-min", () => {
420
+ throw new Error("chunk failed to load");
421
+ });
422
+ try {
423
+ tts = await import("./textToSpeech");
424
+ mockVoices.mockResolvedValue([englishVoice, portugueseVoice]);
425
+ const worker = createFakeWorker();
426
+ tts.setWorkerFactory(() => worker as unknown as Worker);
427
+
428
+ void tts.speak(portugueseAnswer);
429
+ await vi.waitFor(() => expect(worker.sent).toHaveLength(1));
430
+
431
+ expect(worker.sent[0].voiceId).toBe("en_US-lessac-high");
432
+ expect(mockAddLogEntry).toHaveBeenCalledWith(
433
+ expect.stringContaining("Could not detect the answer's language"),
434
+ );
435
+ } finally {
436
+ vi.doUnmock("franc-min");
437
+ }
438
+ });
439
+
440
+ it("asks the system engine for the answer's language", async () => {
441
+ settings.textToSpeechEngine = "system";
442
+
443
+ await tts.speak(portugueseAnswer);
444
+
445
+ expect(spokenLanguages).toEqual(["pt"]);
446
+ });
447
+
448
  it("stops playback without falling back to the system voice", async () => {
449
  const worker = createFakeWorker();
450
  tts.setWorkerFactory(() => worker as unknown as Worker);
 
488
  });
489
 
490
  describe("listVoices", () => {
491
+ it("offers the local voices of every language, the given language first", async () => {
492
+ mockVoices.mockResolvedValue([germanVoice, englishVoice, portugueseVoice]);
493
 
494
+ const options = await tts.listVoices("pt-BR");
495
+
496
+ expect(
497
+ options.map(({ value, languageName }) => [value, languageName]),
498
+ ).toEqual([
499
+ ["piper:pt_BR-faber-medium", "Portuguese"],
500
+ ["piper:en_US-lessac-high", "English"],
501
+ ["piper:de_DE-thorsten-medium", "German"],
502
  ]);
503
  });
504
 
client/modules/textToSpeech.ts CHANGED
@@ -23,6 +23,8 @@ export interface VoiceOption {
23
  label: string;
24
  engine: TextToSpeechEngine;
25
  languageCode: string;
 
 
26
  }
27
 
28
  /** The markers come from user settings, so a character like `(` must not be read as syntax. */
@@ -69,6 +71,110 @@ function primaryLanguage(code: string): string {
69
  return code.replace("_", "-").split("-")[0].toLowerCase();
70
  }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  let cachedLocalVoices: Promise<Voice[]> | null = null;
73
 
74
  /**
@@ -88,10 +194,11 @@ function getLocalVoices(): Promise<Voice[]> {
88
  }
89
 
90
  /**
91
- * Lists only the voices the given engine can actually play: the local voices for
92
- * the given language, or every `speechSynthesis` voice. Listing the local
93
- * voices means fetching the catalogue over the network, so it must not happen
94
- * when the user has opted out of the local engine.
 
95
  */
96
  export async function listVoices(
97
  languageCode: string = navigator.language,
@@ -102,13 +209,20 @@ export async function listVoices(
102
 
103
  if (engine !== "system") {
104
  try {
105
- for (const voice of await getLocalVoices()) {
106
- if (primaryLanguage(voice.language.code) !== wanted) continue;
 
 
 
 
 
 
107
  options.push({
108
  value: `${LOCAL_VOICE_PREFIX}${voice.key}`,
109
  label: `${voice.name} โ€ข ${voice.language.name_english} (${voice.quality})`,
110
  engine: "local",
111
  languageCode: voice.language.code,
 
112
  });
113
  }
114
  } catch (error) {
@@ -188,6 +302,7 @@ export function stopSpeaking(): void {
188
  function speakWithSystemVoice(
189
  text: string,
190
  storedVoiceId: string,
 
191
  session: SpeechSession,
192
  ): Promise<void> {
193
  return new Promise((resolve) => {
@@ -198,6 +313,8 @@ function speakWithSystemVoice(
198
  }
199
 
200
  const utterance = new SpeechSynthesisUtterance(text);
 
 
201
  const wantedUri = storedVoiceId.startsWith(SYSTEM_VOICE_PREFIX)
202
  ? storedVoiceId.slice(SYSTEM_VOICE_PREFIX.length)
203
  : storedVoiceId;
@@ -414,13 +531,15 @@ export async function speak(text: string): Promise<void> {
414
  const wantsSystemVoice =
415
  settings.textToSpeechEngine === "system" ||
416
  storedVoiceId.startsWith(SYSTEM_VOICE_PREFIX);
 
 
 
 
 
417
 
418
  if (!wantsSystemVoice) {
419
  try {
420
- const voiceId = await resolveLocalVoiceId(
421
- storedVoiceId,
422
- navigator.language,
423
- );
424
  if (stopRequested) return;
425
  if (voiceId) {
426
  // Returning here is what keeps a stop silent: `speakWithLocalVoice`
@@ -446,7 +565,12 @@ export async function speak(text: string): Promise<void> {
446
  }
447
 
448
  if (stopRequested) return;
449
- await speakWithSystemVoice(spokenText, storedVoiceId, session);
 
 
 
 
 
450
  } finally {
451
  // A session that has already been replaced must not clear the live one.
452
  if (activeSession === session) {
 
23
  label: string;
24
  engine: TextToSpeechEngine;
25
  languageCode: string;
26
+ /** Set on local voices only, which the settings form groups by language. */
27
+ languageName?: string;
28
  }
29
 
30
  /** The markers come from user settings, so a character like `(` must not be read as syntax. */
 
71
  return code.replace("_", "-").split("-")[0].toLowerCase();
72
  }
73
 
74
+ /**
75
+ * franc-min names languages in ISO 639-3, while the voices use the two-letter
76
+ * codes. Its languages without a two-letter code are left out.
77
+ */
78
+ const LANGUAGE_BY_FRANC_CODE: Record<string, string> = {
79
+ amh: "am",
80
+ arb: "ar",
81
+ azj: "az",
82
+ bel: "be",
83
+ ben: "bn",
84
+ bos: "bs",
85
+ bul: "bg",
86
+ ces: "cs",
87
+ cmn: "zh",
88
+ deu: "de",
89
+ ell: "el",
90
+ eng: "en",
91
+ fra: "fr",
92
+ guj: "gu",
93
+ hau: "ha",
94
+ hin: "hi",
95
+ hrv: "hr",
96
+ hun: "hu",
97
+ ibo: "ig",
98
+ ind: "id",
99
+ ita: "it",
100
+ jav: "jv",
101
+ jpn: "ja",
102
+ kan: "kn",
103
+ kaz: "kk",
104
+ kin: "rw",
105
+ kor: "ko",
106
+ lin: "ln",
107
+ mal: "ml",
108
+ mar: "mr",
109
+ mya: "my",
110
+ nld: "nl",
111
+ npi: "ne",
112
+ nya: "ny",
113
+ pan: "pa",
114
+ pbu: "ps",
115
+ pes: "fa",
116
+ plt: "mg",
117
+ pol: "pl",
118
+ por: "pt",
119
+ ron: "ro",
120
+ run: "rn",
121
+ rus: "ru",
122
+ sin: "si",
123
+ som: "so",
124
+ spa: "es",
125
+ srp: "sr",
126
+ sun: "su",
127
+ swe: "sv",
128
+ swh: "sw",
129
+ tam: "ta",
130
+ tel: "te",
131
+ tgl: "tl",
132
+ tha: "th",
133
+ tur: "tr",
134
+ ukr: "uk",
135
+ urd: "ur",
136
+ uzn: "uz",
137
+ vie: "vi",
138
+ yor: "yo",
139
+ zlm: "ms",
140
+ zul: "zu",
141
+ };
142
+
143
+ const DETECTABLE_LANGUAGES = new Set(Object.values(LANGUAGE_BY_FRANC_CODE));
144
+
145
+ /**
146
+ * The language an answer is written in, which can differ from the browser's:
147
+ * the model answers in the language of the question. Falls back to the browser
148
+ * language when the text is too short to tell, and keeps it outright when
149
+ * franc-min cannot recognize it (Danish, Finnish, Catalan...), because it would
150
+ * then read an answer in that language as a neighbouring one.
151
+ */
152
+ async function detectAnswerLanguage(
153
+ text: string,
154
+ browserLanguage: string,
155
+ ): Promise<string> {
156
+ const browserPrimary = primaryLanguage(browserLanguage);
157
+ if (!DETECTABLE_LANGUAGES.has(browserPrimary)) return browserLanguage;
158
+
159
+ try {
160
+ const { franc } = await import("franc-min");
161
+ const detected =
162
+ LANGUAGE_BY_FRANC_CODE[
163
+ franc(text, { only: Object.keys(LANGUAGE_BY_FRANC_CODE) })
164
+ ];
165
+ return !detected || detected === browserPrimary
166
+ ? browserLanguage
167
+ : detected;
168
+ } catch (error) {
169
+ addLogEntry(
170
+ `Could not detect the answer's language, using the browser's: ${
171
+ error instanceof Error ? error.message : "unknown error"
172
+ }`,
173
+ );
174
+ return browserLanguage;
175
+ }
176
+ }
177
+
178
  let cachedLocalVoices: Promise<Voice[]> | null = null;
179
 
180
  /**
 
194
  }
195
 
196
  /**
197
+ * Lists only the voices the given engine can actually play: every local voice,
198
+ * those of the given language first and the rest by language name, or every
199
+ * `speechSynthesis` voice. Listing the local voices means fetching the
200
+ * catalogue over the network, so it must not happen when the user has opted
201
+ * out of the local engine.
202
  */
203
  export async function listVoices(
204
  languageCode: string = navigator.language,
 
209
 
210
  if (engine !== "system") {
211
  try {
212
+ const rank = (voice: Voice) =>
213
+ primaryLanguage(voice.language.code) === wanted ? 0 : 1;
214
+ const sorted = [...(await getLocalVoices())].sort(
215
+ (a, b) =>
216
+ rank(a) - rank(b) ||
217
+ a.language.name_english.localeCompare(b.language.name_english),
218
+ );
219
+ for (const voice of sorted) {
220
  options.push({
221
  value: `${LOCAL_VOICE_PREFIX}${voice.key}`,
222
  label: `${voice.name} โ€ข ${voice.language.name_english} (${voice.quality})`,
223
  engine: "local",
224
  languageCode: voice.language.code,
225
+ languageName: voice.language.name_english,
226
  });
227
  }
228
  } catch (error) {
 
302
  function speakWithSystemVoice(
303
  text: string,
304
  storedVoiceId: string,
305
+ languageCode: string,
306
  session: SpeechSession,
307
  ): Promise<void> {
308
  return new Promise((resolve) => {
 
313
  }
314
 
315
  const utterance = new SpeechSynthesisUtterance(text);
316
+ // Without a voice, the OS picks its own voice for this language.
317
+ utterance.lang = languageCode;
318
  const wantedUri = storedVoiceId.startsWith(SYSTEM_VOICE_PREFIX)
319
  ? storedVoiceId.slice(SYSTEM_VOICE_PREFIX.length)
320
  : storedVoiceId;
 
531
  const wantsSystemVoice =
532
  settings.textToSpeechEngine === "system" ||
533
  storedVoiceId.startsWith(SYSTEM_VOICE_PREFIX);
534
+ const languageCode = await detectAnswerLanguage(
535
+ spokenText,
536
+ navigator.language,
537
+ );
538
+ if (stopRequested) return;
539
 
540
  if (!wantsSystemVoice) {
541
  try {
542
+ const voiceId = await resolveLocalVoiceId(storedVoiceId, languageCode);
 
 
 
543
  if (stopRequested) return;
544
  if (voiceId) {
545
  // Returning here is what keeps a stop silent: `speakWithLocalVoice`
 
565
  }
566
 
567
  if (stopRequested) return;
568
+ await speakWithSystemVoice(
569
+ spokenText,
570
+ storedVoiceId,
571
+ languageCode,
572
+ session,
573
+ );
574
  } finally {
575
  // A session that has already been replaced must not clear the live one.
576
  if (activeSession === session) {
docs/ai-integration.md CHANGED
@@ -368,7 +368,20 @@ not a failure and never falls back.
368
  The catalogue is fetched at runtime from the published voice index rather than
369
  hardcoded: it already lists more voices than the package's own `VoiceId` union,
370
  and a stale copy would offer voices that fail to download. The settings form
371
- groups local voices for the current language separately from the OS voices.
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  ### Third-party requests
374
 
 
368
  The catalogue is fetched at runtime from the published voice index rather than
369
  hardcoded: it already lists more voices than the package's own `VoiceId` union,
370
  and a stale copy would offer voices that fail to download. The settings form
371
+ lists every local voice, grouped by language, with the browser's language
372
+ first. It lists the OS voices only when the system engine is selected.
373
+
374
+ With no voice chosen, `speak()` picks one for the language of the answer,
375
+ which follows the question's language rather than the browser's. `franc-min`
376
+ detects it, loaded on the first Listen press rather than with the page, and the
377
+ first local voice of that language reads the answer. With the system engine,
378
+ or when no local voice speaks that language (Japanese, Korean and Hindi have
379
+ none), the language is passed to `speechSynthesis` so the OS picks its own
380
+ voice. The browser's language is used instead when the detector cannot tell,
381
+ fails to load, or does not know the browser's language at all (Danish, Finnish
382
+ and Catalan among them), since it would then read an answer in that language
383
+ as a neighbouring one. A chosen voice reads every answer, whatever its
384
+ language.
385
 
386
  ### Third-party requests
387
 
docs/configuration.md CHANGED
@@ -216,7 +216,7 @@ Search results:
216
 
217
  | Setting | Type | Default | Description |
218
  | --------------------------- | --------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
219
- | `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 |
220
  | `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 |
221
  | `enableDictation` | boolean | `true` | Shows the microphone button inside the search field and the chat follow-up field, which fills whichever one it sits in 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 |
222
  | `enableLocalDictationModel` | boolean | `true` | Whether the dictation button prefers the on-device Qwen3-ASR model, which transcribes many languages without the audio leaving the device. The first press downloads ~700 MB from Hugging Face, and later presses load it from the browser's storage. The model writes the text once the recording stops, and a recording ends on its own after two minutes. With this off, the button uses the browser's built-in `SpeechRecognition`, which shows the text while the user speaks 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. Until the model became multilingual, the default was `true` only for an English browser, and a profile saved back then keeps its stored value, so the Voice settings panel is where a non-English profile turns it on |
 
216
 
217
  | Setting | Type | Default | Description |
218
  | --------------------------- | --------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
219
+ | `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 language of each answer (see [ai-integration.md](ai-integration.md#voices)). The Voice settings panel lists only the voices of the selected engine, the local ones in every language, 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 |
220
  | `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 |
221
  | `enableDictation` | boolean | `true` | Shows the microphone button inside the search field and the chat follow-up field, which fills whichever one it sits in 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 |
222
  | `enableLocalDictationModel` | boolean | `true` | Whether the dictation button prefers the on-device Qwen3-ASR model, which transcribes many languages without the audio leaving the device. The first press downloads ~700 MB from Hugging Face, and later presses load it from the browser's storage. The model writes the text once the recording stops, and a recording ends on its own after two minutes. With this off, the button uses the browser's built-in `SpeechRecognition`, which shows the text while the user speaks 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. Until the model became multilingual, the default was `true` only for an English browser, and a profile saved back then keeps its stored value, so the Voice settings panel is where a non-English profile turns it on |
docs/failure-injection.md CHANGED
@@ -77,6 +77,7 @@ needed.
77
  | Generation interrupted mid-stream | Partial answer preserved, state stays `interrupted` | `client/modules/textGeneration.degradation.test.ts` |
78
  | The local voice catalogue cannot be fetched | The local engine is skipped and the answer is read by an OS voice | `client/modules/textToSpeech.test.ts` โ€บ falls back when the voice catalogue cannot be reached |
79
  | No local voice matches the language | Same, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` โ€บ falls back when no local voice matches the language |
 
80
  | The synthesis worker fails before anything is audible | The answer is read by an OS voice instead | `client/modules/textToSpeech.test.ts` โ€บ falls back to the system voice when the local engine cannot load, falls back when the worker fails and no chunk was ever audible |
81
  | Every synthesized chunk fails to play (blocked autoplay) | Treated as a local-engine failure, so an OS voice reads the answer rather than the user getting silence | `client/modules/textToSpeech.test.ts` โ€บ falls back when every synthesized chunk fails to play |
82
  | The browser provides no `speechSynthesis` at all and the local engine failed | Playback resolves and returns to idle with a log entry, instead of an unhandled rejection | `client/modules/textToSpeech.test.ts` โ€บ resolves and logs when the local engine failed and there are no OS voices |
 
77
  | Generation interrupted mid-stream | Partial answer preserved, state stays `interrupted` | `client/modules/textGeneration.degradation.test.ts` |
78
  | The local voice catalogue cannot be fetched | The local engine is skipped and the answer is read by an OS voice | `client/modules/textToSpeech.test.ts` โ€บ falls back when the voice catalogue cannot be reached |
79
  | No local voice matches the language | Same, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` โ€บ falls back when no local voice matches the language |
80
+ | The language detector fails to load | The answer is read in the browser's language, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` โ€บ uses the browser language when the detector fails to load |
81
  | The synthesis worker fails before anything is audible | The answer is read by an OS voice instead | `client/modules/textToSpeech.test.ts` โ€บ falls back to the system voice when the local engine cannot load, falls back when the worker fails and no chunk was ever audible |
82
  | Every synthesized chunk fails to play (blocked autoplay) | Treated as a local-engine failure, so an OS voice reads the answer rather than the user getting silence | `client/modules/textToSpeech.test.ts` โ€บ falls back when every synthesized chunk fails to play |
83
  | The browser provides no `speechSynthesis` at all and the local engine failed | Playback resolves and returns to idle with a log entry, instead of an unhandled rejection | `client/modules/textToSpeech.test.ts` โ€บ resolves and logs when the local engine failed and there are no OS voices |
package-lock.json CHANGED
@@ -31,6 +31,7 @@
31
  "dexie": "^4.0.11",
32
  "dotenv": "^18.0.0",
33
  "embla-carousel-react": "^8.3.0",
 
34
  "gpt-tokenizer": "^4.0.0",
35
  "hash-wasm": "4.12.0",
36
  "helper-git-hash": "^1.0.0",
@@ -3123,6 +3124,16 @@
3123
  "node": ">=6"
3124
  }
3125
  },
 
 
 
 
 
 
 
 
 
 
3126
  "node_modules/comma-separated-tokens": {
3127
  "version": "2.0.3",
3128
  "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -3699,6 +3710,19 @@
3699
  "node": ">=18.14.0"
3700
  }
3701
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
3702
  "node_modules/fsevents": {
3703
  "version": "2.3.3",
3704
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5778,6 +5802,16 @@
5778
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5779
  "license": "MIT"
5780
  },
 
 
 
 
 
 
 
 
 
 
5781
  "node_modules/nanoid": {
5782
  "version": "3.3.20",
5783
  "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -7362,6 +7396,20 @@
7362
  "node": "^22.14.0 || >=24.0.0"
7363
  }
7364
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7365
  "node_modules/trim-lines": {
7366
  "version": "3.0.1",
7367
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
 
31
  "dexie": "^4.0.11",
32
  "dotenv": "^18.0.0",
33
  "embla-carousel-react": "^8.3.0",
34
+ "franc-min": "^6.2.0",
35
  "gpt-tokenizer": "^4.0.0",
36
  "hash-wasm": "4.12.0",
37
  "helper-git-hash": "^1.0.0",
 
3124
  "node": ">=6"
3125
  }
3126
  },
3127
+ "node_modules/collapse-white-space": {
3128
+ "version": "2.1.0",
3129
+ "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
3130
+ "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
3131
+ "license": "MIT",
3132
+ "funding": {
3133
+ "type": "github",
3134
+ "url": "https://github.com/sponsors/wooorm"
3135
+ }
3136
+ },
3137
  "node_modules/comma-separated-tokens": {
3138
  "version": "2.0.3",
3139
  "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
 
3710
  "node": ">=18.14.0"
3711
  }
3712
  },
3713
+ "node_modules/franc-min": {
3714
+ "version": "6.2.0",
3715
+ "resolved": "https://registry.npmjs.org/franc-min/-/franc-min-6.2.0.tgz",
3716
+ "integrity": "sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==",
3717
+ "license": "MIT",
3718
+ "dependencies": {
3719
+ "trigram-utils": "^2.0.0"
3720
+ },
3721
+ "funding": {
3722
+ "type": "github",
3723
+ "url": "https://github.com/sponsors/wooorm"
3724
+ }
3725
+ },
3726
  "node_modules/fsevents": {
3727
  "version": "2.3.3",
3728
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
5802
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5803
  "license": "MIT"
5804
  },
5805
+ "node_modules/n-gram": {
5806
+ "version": "2.0.2",
5807
+ "resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz",
5808
+ "integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
5809
+ "license": "MIT",
5810
+ "funding": {
5811
+ "type": "github",
5812
+ "url": "https://github.com/sponsors/wooorm"
5813
+ }
5814
+ },
5815
  "node_modules/nanoid": {
5816
  "version": "3.3.20",
5817
  "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
 
7396
  "node": "^22.14.0 || >=24.0.0"
7397
  }
7398
  },
7399
+ "node_modules/trigram-utils": {
7400
+ "version": "2.0.1",
7401
+ "resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz",
7402
+ "integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
7403
+ "license": "MIT",
7404
+ "dependencies": {
7405
+ "collapse-white-space": "^2.0.0",
7406
+ "n-gram": "^2.0.0"
7407
+ },
7408
+ "funding": {
7409
+ "type": "github",
7410
+ "url": "https://github.com/sponsors/wooorm"
7411
+ }
7412
+ },
7413
  "node_modules/trim-lines": {
7414
  "version": "3.0.1",
7415
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
package.json CHANGED
@@ -64,6 +64,7 @@
64
  "dexie": "^4.0.11",
65
  "dotenv": "^18.0.0",
66
  "embla-carousel-react": "^8.3.0",
 
67
  "gpt-tokenizer": "^4.0.0",
68
  "hash-wasm": "4.12.0",
69
  "helper-git-hash": "^1.0.0",
 
64
  "dexie": "^4.0.11",
65
  "dotenv": "^18.0.0",
66
  "embla-carousel-react": "^8.3.0",
67
+ "franc-min": "^6.2.0",
68
  "gpt-tokenizer": "^4.0.0",
69
  "hash-wasm": "4.12.0",
70
  "helper-git-hash": "^1.0.0",