Spaces:
Running
Running
Sync from felladrin/MiniSearch@74f42b1
Browse files- changelog.md +2 -0
- client/components/Pages/Main/Menu/VoiceSettingsForm.test.tsx +34 -1
- client/components/Pages/Main/Menu/VoiceSettingsForm.tsx +32 -8
- client/modules/textToSpeech.test.ts +117 -8
- client/modules/textToSpeech.ts +135 -11
- docs/ai-integration.md +14 -1
- docs/configuration.md +1 -1
- docs/failure-injection.md +1 -0
- package-lock.json +48 -0
- package.json +1 -0
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
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 92 |
-
{ value: "system", label: "System voices
|
| 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
|
|
|
|
| 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={
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 386 |
-
|
| 387 |
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 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:
|
| 92 |
-
* the given language
|
| 93 |
-
*
|
| 94 |
-
*
|
|
|
|
| 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 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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",
|