opencode-voice-dictation 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/dist/gui-experimental.cjs +192 -0
- package/package.json +53 -4
- package/src/gui/contract.ts +87 -0
- package/src/gui/definition.ts +204 -0
- package/src/gui/engines/sapi-speech.ts +151 -0
- package/src/gui/engines/script.ts +125 -0
- package/src/gui/engines/speak-script.ts +66 -0
- package/src/gui/engines/system-speech.ts +202 -0
- package/src/gui/engines/win-h.ts +80 -0
- package/src/gui/main.ts +150 -0
- package/src/gui/settings.tsx +297 -0
- package/src/gui/speak.ts +260 -0
- package/src/gui/window.tsx +289 -0
- package/src/gui.tsx +6 -0
- package/src/index.ts +19 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createSignal, onMount, Show } from "solid-js"
|
|
2
|
+
import { usePlugin } from "@opencode/plugin/gui"
|
|
3
|
+
import { Dictation, VoiceTyping } from "./contract"
|
|
4
|
+
import type { definition } from "./definition"
|
|
5
|
+
import type { Speaker } from "./speak"
|
|
6
|
+
|
|
7
|
+
const FALLBACK_LOCALES = [
|
|
8
|
+
"es-ES",
|
|
9
|
+
"es-MX",
|
|
10
|
+
"es-AR",
|
|
11
|
+
"es-CO",
|
|
12
|
+
"pt-BR",
|
|
13
|
+
"pt-PT",
|
|
14
|
+
"en-US",
|
|
15
|
+
"en-GB",
|
|
16
|
+
"fr-FR",
|
|
17
|
+
"de-DE",
|
|
18
|
+
"it-IT",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Settings page. It only reads and writes the declared store; the engine is
|
|
23
|
+
* asked for installed languages when the page mounts.
|
|
24
|
+
*/
|
|
25
|
+
export function VoiceSettings(props: { speaker: Speaker }) {
|
|
26
|
+
const ctx = usePlugin<typeof definition>()
|
|
27
|
+
const ui = ctx.ui
|
|
28
|
+
const prefs = ctx.stores.prefs
|
|
29
|
+
const speechPrefs = ctx.stores.speech
|
|
30
|
+
const enginePrefs = ctx.stores.engine
|
|
31
|
+
const dictation = ctx.main(Dictation)
|
|
32
|
+
const voiceTyping = ctx.main(VoiceTyping)
|
|
33
|
+
|
|
34
|
+
const [locales, setLocales] = createSignal<readonly string[]>([])
|
|
35
|
+
const [busy, setBusy] = createSignal(false)
|
|
36
|
+
|
|
37
|
+
onMount(() => {
|
|
38
|
+
void dictation
|
|
39
|
+
.diagnose()
|
|
40
|
+
.then((result) => {
|
|
41
|
+
if (result.locales.length > 0) setLocales(result.locales)
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {})
|
|
44
|
+
void props.speaker.refreshVoices()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const localeOptions = () => {
|
|
48
|
+
const list = locales()
|
|
49
|
+
const current = prefs.value.locale
|
|
50
|
+
const merged = list.includes(current) ? list : [current, ...list]
|
|
51
|
+
return merged.map((value) => ({ value, label: value }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const voiceOptions = () => [
|
|
55
|
+
{ value: "", label: ctx.t("speech.voice.auto") },
|
|
56
|
+
...props.speaker.voices().map((voice) => ({ value: voice.id, label: voice.label })),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
const patch = (change: { locale?: string; minConfidence?: number; trailingSpace?: boolean; autoFocus?: boolean }) => {
|
|
60
|
+
prefs.update((draft) => {
|
|
61
|
+
if (change.locale !== undefined) draft.locale = change.locale
|
|
62
|
+
if (change.minConfidence !== undefined) draft.minConfidence = change.minConfidence
|
|
63
|
+
if (change.trailingSpace !== undefined) draft.trailingSpace = change.trailingSpace
|
|
64
|
+
if (change.autoFocus !== undefined) draft.autoFocus = change.autoFocus
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const patchSpeech = (change: { voiceURI?: string; rate?: number; skipCode?: boolean }) => {
|
|
69
|
+
speechPrefs.update((draft) => {
|
|
70
|
+
if (change.voiceURI !== undefined) draft.voiceURI = change.voiceURI
|
|
71
|
+
if (change.rate !== undefined) draft.rate = change.rate
|
|
72
|
+
if (change.skipCode !== undefined) draft.skipCode = change.skipCode
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const patchEngine = (mode: string) => {
|
|
77
|
+
enginePrefs.update((draft) => {
|
|
78
|
+
draft.mode = mode
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const testWinH = async () => {
|
|
83
|
+
try {
|
|
84
|
+
await voiceTyping.toggle()
|
|
85
|
+
ctx.toast.show({
|
|
86
|
+
variant: "default",
|
|
87
|
+
title: ctx.t("settings.winh"),
|
|
88
|
+
description: ctx.t("settings.winh.sent"),
|
|
89
|
+
})
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
ctx.toast.show({
|
|
92
|
+
variant: "error",
|
|
93
|
+
title: ctx.t("pill.title"),
|
|
94
|
+
description: cause instanceof Error ? cause.message : ctx.t("error.winh"),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const preview = async () => {
|
|
100
|
+
try {
|
|
101
|
+
await props.speaker.preview()
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
ctx.toast.show({
|
|
104
|
+
variant: "error",
|
|
105
|
+
title: ctx.t("read.title"),
|
|
106
|
+
description: cause instanceof Error ? cause.message : ctx.t("read.error"),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const test = async () => {
|
|
112
|
+
setBusy(true)
|
|
113
|
+
try {
|
|
114
|
+
const result = await dictation.diagnose()
|
|
115
|
+
|
|
116
|
+
if (result.locales.length === 0) {
|
|
117
|
+
ctx.toast.show({
|
|
118
|
+
variant: "error",
|
|
119
|
+
title: ctx.t("error.title"),
|
|
120
|
+
description: ctx.t("settings.test.failed", { reason: ctx.t("common.reason.no-recognizers") }),
|
|
121
|
+
persistent: true,
|
|
122
|
+
})
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!result.microphone) {
|
|
127
|
+
ctx.toast.show({
|
|
128
|
+
variant: "error",
|
|
129
|
+
title: ctx.t("error.title"),
|
|
130
|
+
description: ctx.t("settings.test.failed", { reason: ctx.t("common.reason.no-microphone") }),
|
|
131
|
+
persistent: true,
|
|
132
|
+
})
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.locales.length > 0 && !locales().length) setLocales(result.locales)
|
|
137
|
+
|
|
138
|
+
ctx.toast.show({
|
|
139
|
+
variant: "success",
|
|
140
|
+
title: ctx.t("error.title"),
|
|
141
|
+
description: ctx.t("settings.test.success", { locale: result.selected || result.locales[0] || prefs.value.locale }),
|
|
142
|
+
})
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
ctx.toast.show({
|
|
145
|
+
variant: "error",
|
|
146
|
+
title: ctx.t("error.title"),
|
|
147
|
+
description: cause instanceof Error ? cause.message : String(cause),
|
|
148
|
+
})
|
|
149
|
+
} finally {
|
|
150
|
+
setBusy(false)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<ui.SettingsLayout title={ctx.t("settings.title")} description={ctx.t("settings.description")}>
|
|
156
|
+
<ui.SettingsSection title={ctx.t("settings.section")}>
|
|
157
|
+
<ui.SettingsRow
|
|
158
|
+
id="engine"
|
|
159
|
+
title={ctx.t("settings.engine")}
|
|
160
|
+
description={ctx.t("settings.engine.description")}
|
|
161
|
+
>
|
|
162
|
+
<ui.Select
|
|
163
|
+
label={ctx.t("settings.engine")}
|
|
164
|
+
value={enginePrefs.value.mode}
|
|
165
|
+
options={[
|
|
166
|
+
{ value: "windows", label: ctx.t("engine.windows") },
|
|
167
|
+
{ value: "system", label: ctx.t("engine.system") },
|
|
168
|
+
]}
|
|
169
|
+
onChange={(value) => patchEngine(value)}
|
|
170
|
+
/>
|
|
171
|
+
</ui.SettingsRow>
|
|
172
|
+
|
|
173
|
+
<Show when={enginePrefs.value.mode === "windows"}>
|
|
174
|
+
<ui.SettingsRow
|
|
175
|
+
id="winh"
|
|
176
|
+
title={ctx.t("settings.winh")}
|
|
177
|
+
description={ctx.t("settings.winh.description")}
|
|
178
|
+
>
|
|
179
|
+
<ui.Button onClick={() => void testWinH()}>{ctx.t("settings.winh.run")}</ui.Button>
|
|
180
|
+
</ui.SettingsRow>
|
|
181
|
+
</Show>
|
|
182
|
+
|
|
183
|
+
<Show when={enginePrefs.value.mode === "system"}>
|
|
184
|
+
<ui.SettingsRow
|
|
185
|
+
id="language"
|
|
186
|
+
title={ctx.t("settings.language")}
|
|
187
|
+
description={ctx.t("settings.language.description")}
|
|
188
|
+
>
|
|
189
|
+
<ui.Select
|
|
190
|
+
label={ctx.t("settings.language")}
|
|
191
|
+
value={prefs.value.locale}
|
|
192
|
+
options={localeOptions().length > 0 ? localeOptions() : FALLBACK_LOCALES.map((value) => ({ value, label: value }))}
|
|
193
|
+
onChange={(value) => patch({ locale: value })}
|
|
194
|
+
/>
|
|
195
|
+
</ui.SettingsRow>
|
|
196
|
+
|
|
197
|
+
<ui.SettingsRow
|
|
198
|
+
id="confidence"
|
|
199
|
+
title={ctx.t("settings.confidence")}
|
|
200
|
+
description={ctx.t("settings.confidence.description")}
|
|
201
|
+
>
|
|
202
|
+
<ui.Select
|
|
203
|
+
label={ctx.t("settings.confidence")}
|
|
204
|
+
value={String(prefs.value.minConfidence)}
|
|
205
|
+
options={[
|
|
206
|
+
{ value: "0.2", label: ctx.t("confidence.low") },
|
|
207
|
+
{ value: "0.4", label: ctx.t("confidence.normal") },
|
|
208
|
+
{ value: "0.6", label: ctx.t("confidence.high") },
|
|
209
|
+
]}
|
|
210
|
+
onChange={(value) => patch({ minConfidence: Number(value) || 0.4 })}
|
|
211
|
+
/>
|
|
212
|
+
</ui.SettingsRow>
|
|
213
|
+
|
|
214
|
+
<ui.SettingsRow id="space" title={ctx.t("settings.space")} description={ctx.t("settings.space.description")}>
|
|
215
|
+
<ui.Switch
|
|
216
|
+
label={ctx.t("settings.space")}
|
|
217
|
+
hideLabel
|
|
218
|
+
checked={prefs.value.trailingSpace}
|
|
219
|
+
onChange={(checked) => patch({ trailingSpace: checked })}
|
|
220
|
+
/>
|
|
221
|
+
</ui.SettingsRow>
|
|
222
|
+
|
|
223
|
+
<ui.SettingsRow id="focus" title={ctx.t("settings.focus")} description={ctx.t("settings.focus.description")}>
|
|
224
|
+
<ui.Switch
|
|
225
|
+
label={ctx.t("settings.focus")}
|
|
226
|
+
hideLabel
|
|
227
|
+
checked={prefs.value.autoFocus}
|
|
228
|
+
onChange={(checked) => patch({ autoFocus: checked })}
|
|
229
|
+
/>
|
|
230
|
+
</ui.SettingsRow>
|
|
231
|
+
</Show>
|
|
232
|
+
</ui.SettingsSection>
|
|
233
|
+
|
|
234
|
+
<ui.SettingsSection title={ctx.t("speech.section")}>
|
|
235
|
+
<ui.SettingsRow
|
|
236
|
+
id="voice"
|
|
237
|
+
title={ctx.t("speech.voice")}
|
|
238
|
+
description={ctx.t("speech.voice.description")}
|
|
239
|
+
>
|
|
240
|
+
<ui.Select
|
|
241
|
+
label={ctx.t("speech.voice")}
|
|
242
|
+
value={speechPrefs.value.voiceURI}
|
|
243
|
+
options={voiceOptions()}
|
|
244
|
+
onChange={(value) => patchSpeech({ voiceURI: value })}
|
|
245
|
+
/>
|
|
246
|
+
</ui.SettingsRow>
|
|
247
|
+
|
|
248
|
+
<ui.SettingsRow
|
|
249
|
+
id="rate"
|
|
250
|
+
title={ctx.t("speech.rate")}
|
|
251
|
+
description={ctx.t("speech.rate.description")}
|
|
252
|
+
>
|
|
253
|
+
<ui.Select
|
|
254
|
+
label={ctx.t("speech.rate")}
|
|
255
|
+
value={String(speechPrefs.value.rate)}
|
|
256
|
+
options={[
|
|
257
|
+
{ value: "0.8", label: ctx.t("speech.rate.slow") },
|
|
258
|
+
{ value: "1", label: ctx.t("speech.rate.normal") },
|
|
259
|
+
{ value: "1.2", label: ctx.t("speech.rate.fast") },
|
|
260
|
+
{ value: "1.5", label: ctx.t("speech.rate.faster") },
|
|
261
|
+
]}
|
|
262
|
+
onChange={(value) => patchSpeech({ rate: Number(value) || 1 })}
|
|
263
|
+
/>
|
|
264
|
+
</ui.SettingsRow>
|
|
265
|
+
|
|
266
|
+
<ui.SettingsRow
|
|
267
|
+
id="skip-code"
|
|
268
|
+
title={ctx.t("speech.skipCode")}
|
|
269
|
+
description={ctx.t("speech.skipCode.description")}
|
|
270
|
+
>
|
|
271
|
+
<ui.Switch
|
|
272
|
+
label={ctx.t("speech.skipCode")}
|
|
273
|
+
hideLabel
|
|
274
|
+
checked={speechPrefs.value.skipCode}
|
|
275
|
+
onChange={(checked) => patchSpeech({ skipCode: checked })}
|
|
276
|
+
/>
|
|
277
|
+
</ui.SettingsRow>
|
|
278
|
+
|
|
279
|
+
<ui.SettingsRow
|
|
280
|
+
id="preview"
|
|
281
|
+
title={ctx.t("speech.preview")}
|
|
282
|
+
description={ctx.t("speech.preview.description")}
|
|
283
|
+
>
|
|
284
|
+
<ui.Button onClick={() => void preview()}>{ctx.t("read.idle")}</ui.Button>
|
|
285
|
+
</ui.SettingsRow>
|
|
286
|
+
</ui.SettingsSection>
|
|
287
|
+
|
|
288
|
+
<ui.SettingsSection title={ctx.t("settings.test.section")}>
|
|
289
|
+
<ui.SettingsRow id="test" title={ctx.t("settings.test")} description={ctx.t("settings.test.description")}>
|
|
290
|
+
<ui.Button busy={busy()} onClick={() => void test()}>
|
|
291
|
+
{ctx.t("settings.test.run")}
|
|
292
|
+
</ui.Button>
|
|
293
|
+
</ui.SettingsRow>
|
|
294
|
+
</ui.SettingsSection>
|
|
295
|
+
</ui.SettingsLayout>
|
|
296
|
+
)
|
|
297
|
+
}
|
package/src/gui/speak.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { IpcClient, SetupContext } from "@opencode/plugin/gui"
|
|
3
|
+
import { Speech } from "./contract"
|
|
4
|
+
import type { definition } from "./definition"
|
|
5
|
+
|
|
6
|
+
export interface VoiceOption {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Speaker {
|
|
12
|
+
speaking(): boolean
|
|
13
|
+
backend(): "web" | "sapi" | "none"
|
|
14
|
+
voices(): VoiceOption[]
|
|
15
|
+
refreshVoices(): Promise<void>
|
|
16
|
+
lastAssistantText(): Promise<string>
|
|
17
|
+
speak(text: string): Promise<boolean>
|
|
18
|
+
cancel(): void
|
|
19
|
+
preview(): Promise<boolean>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_CHUNK = 250
|
|
23
|
+
|
|
24
|
+
/** Turns Markdown into something worth hearing. */
|
|
25
|
+
export function cleanForSpeech(text: string, skipCode: boolean): string {
|
|
26
|
+
let value = text.replace(/\r\n/g, "\n")
|
|
27
|
+
|
|
28
|
+
if (skipCode) {
|
|
29
|
+
value = value.replace(/```[\s\S]*?```/g, " ")
|
|
30
|
+
} else {
|
|
31
|
+
value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
value = value.replace(/`([^`]+)`/g, "$1")
|
|
35
|
+
value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
36
|
+
value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
37
|
+
value = value.replace(/^\s*#{1,6}\s+/gm, "")
|
|
38
|
+
value = value.replace(/^\s*>\s?/gm, "")
|
|
39
|
+
value = value.replace(/^\s*[-*+]\s+/gm, "")
|
|
40
|
+
value = value.replace(/^\s*\d+\.\s+/gm, "")
|
|
41
|
+
value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
|
42
|
+
value = value.replace(/(\*|_)(.*?)\1/g, "$2")
|
|
43
|
+
value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
|
|
44
|
+
value = value.replace(/\n{2,}/g, ". ")
|
|
45
|
+
value = value.replace(/\n/g, " ")
|
|
46
|
+
value = value.replace(/\s+/g, " ")
|
|
47
|
+
value = value.replace(/\s+([.,;:!?])/g, "$1")
|
|
48
|
+
value = value.replace(/([.,;:!?]){2,}/g, "$1")
|
|
49
|
+
|
|
50
|
+
return value.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Splits at sentence boundaries, then at word boundaries for long sentences. */
|
|
54
|
+
export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
|
|
55
|
+
const trimmed = text.trim()
|
|
56
|
+
if (!trimmed) return []
|
|
57
|
+
|
|
58
|
+
const sentences = trimmed.split(/(?<=[.!?…])\s+/)
|
|
59
|
+
const chunks: string[] = []
|
|
60
|
+
let current = ""
|
|
61
|
+
|
|
62
|
+
const pushWords = (sentence: string) => {
|
|
63
|
+
let part = ""
|
|
64
|
+
for (const word of sentence.split(/\s+/)) {
|
|
65
|
+
if (!word) continue
|
|
66
|
+
if (part && part.length + 1 + word.length > max) {
|
|
67
|
+
chunks.push(part)
|
|
68
|
+
part = word
|
|
69
|
+
} else {
|
|
70
|
+
part = part ? `${part} ${word}` : word
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return part
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const sentence of sentences) {
|
|
77
|
+
if (sentence.length > max) {
|
|
78
|
+
if (current) {
|
|
79
|
+
chunks.push(current)
|
|
80
|
+
current = ""
|
|
81
|
+
}
|
|
82
|
+
current = pushWords(sentence)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (current && current.length + 1 + sentence.length > max) {
|
|
87
|
+
chunks.push(current)
|
|
88
|
+
current = sentence
|
|
89
|
+
} else {
|
|
90
|
+
current = current ? `${current} ${sentence}` : sentence
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (current) chunks.push(current)
|
|
95
|
+
return chunks
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createSpeaker(
|
|
99
|
+
ctx: SetupContext<typeof definition>,
|
|
100
|
+
speech: IpcClient<typeof Speech.spec>,
|
|
101
|
+
): Speaker {
|
|
102
|
+
const prefs = ctx.stores.speech
|
|
103
|
+
|
|
104
|
+
const [speaking, setSpeaking] = createSignal(false)
|
|
105
|
+
const [voices, setVoices] = createSignal<VoiceOption[]>([])
|
|
106
|
+
const [backend, setBackend] = createSignal<"web" | "sapi" | "none">("none")
|
|
107
|
+
|
|
108
|
+
let generation = 0
|
|
109
|
+
let queue: string[] = []
|
|
110
|
+
|
|
111
|
+
const synth = () =>
|
|
112
|
+
typeof globalThis.speechSynthesis !== "undefined" ? globalThis.speechSynthesis : undefined
|
|
113
|
+
|
|
114
|
+
const pickVoice = () => {
|
|
115
|
+
const available = synth()?.getVoices() ?? []
|
|
116
|
+
if (available.length === 0) return undefined
|
|
117
|
+
|
|
118
|
+
const wanted = prefs.value.voiceURI
|
|
119
|
+
if (wanted) {
|
|
120
|
+
const chosen = available.find((voice) => voice.voiceURI === wanted)
|
|
121
|
+
if (chosen) return chosen
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const base = ctx.locale.locale().split("-")[0]?.toLowerCase() ?? "es"
|
|
125
|
+
return available.find((voice) => voice.lang.toLowerCase().startsWith(base)) ?? available[0]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const refreshVoices = async () => {
|
|
129
|
+
const available = synth()?.getVoices() ?? []
|
|
130
|
+
|
|
131
|
+
if (available.length > 0) {
|
|
132
|
+
setVoices(
|
|
133
|
+
available.map((voice) => ({
|
|
134
|
+
id: voice.voiceURI,
|
|
135
|
+
label: `${voice.name} — ${voice.lang}`,
|
|
136
|
+
})),
|
|
137
|
+
)
|
|
138
|
+
setBackend("web")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const names = await speech.voices()
|
|
144
|
+
if (names.length > 0) {
|
|
145
|
+
setVoices(names.map((name) => ({ id: name, label: name })))
|
|
146
|
+
setBackend("sapi")
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Main is unreachable or PowerShell failed; treat as no voices.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setBackend("none")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cancel = () => {
|
|
157
|
+
generation++
|
|
158
|
+
queue = []
|
|
159
|
+
synth()?.cancel()
|
|
160
|
+
setSpeaking(false)
|
|
161
|
+
void speech.stop().catch(() => {})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const speak = async (text: string) => {
|
|
165
|
+
const cleaned = cleanForSpeech(text, prefs.value.skipCode)
|
|
166
|
+
if (!cleaned) return false
|
|
167
|
+
|
|
168
|
+
cancel()
|
|
169
|
+
await refreshVoices()
|
|
170
|
+
|
|
171
|
+
if (backend() === "web") {
|
|
172
|
+
const engine = synth()
|
|
173
|
+
if (!engine) return false
|
|
174
|
+
|
|
175
|
+
const voice = pickVoice()
|
|
176
|
+
const rate = prefs.value.rate
|
|
177
|
+
const gen = generation
|
|
178
|
+
queue = chunkForSpeech(cleaned)
|
|
179
|
+
setSpeaking(true)
|
|
180
|
+
|
|
181
|
+
const pump = () => {
|
|
182
|
+
if (gen !== generation) return
|
|
183
|
+
const next = queue.shift()
|
|
184
|
+
if (!next) {
|
|
185
|
+
setSpeaking(false)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const utterance = new SpeechSynthesisUtterance(next)
|
|
190
|
+
if (voice) utterance.voice = voice
|
|
191
|
+
if (voice?.lang) utterance.lang = voice.lang
|
|
192
|
+
utterance.rate = rate
|
|
193
|
+
utterance.onend = pump
|
|
194
|
+
utterance.onerror = pump
|
|
195
|
+
engine.speak(utterance)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
pump()
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (backend() === "sapi") {
|
|
203
|
+
setSpeaking(true)
|
|
204
|
+
try {
|
|
205
|
+
await speech.speak({
|
|
206
|
+
text: cleaned,
|
|
207
|
+
rate: prefs.value.rate,
|
|
208
|
+
voice: prefs.value.voiceURI,
|
|
209
|
+
})
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
setSpeaking(false)
|
|
212
|
+
throw cause
|
|
213
|
+
}
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
setSpeaking(false)
|
|
218
|
+
throw new Error(ctx.t("read.noVoices"))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const lastAssistantText = async () => {
|
|
222
|
+
const session = ctx.sessions.current()
|
|
223
|
+
if (!session) return ""
|
|
224
|
+
|
|
225
|
+
// The session may live on another server than the one that runs the plugin
|
|
226
|
+
// (the desktop app can show WSL and Windows servers side by side), so ask
|
|
227
|
+
// the session's own server instead of `ctx.client`.
|
|
228
|
+
const page = await session.server.client.message.list({
|
|
229
|
+
sessionID: session.id,
|
|
230
|
+
type: "assistant",
|
|
231
|
+
order: "desc",
|
|
232
|
+
limit: 1,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const message = page.data[0]
|
|
236
|
+
if (!message || message.type !== "assistant") return ""
|
|
237
|
+
|
|
238
|
+
return message.content
|
|
239
|
+
.filter((part) => part.type === "text")
|
|
240
|
+
.map((part) => part.text)
|
|
241
|
+
.join("\n")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
speech.on("done", () => setSpeaking(false))
|
|
245
|
+
|
|
246
|
+
const engine = synth()
|
|
247
|
+
engine?.addEventListener?.("voiceschanged", () => void refreshVoices())
|
|
248
|
+
void refreshVoices()
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
speaking,
|
|
252
|
+
backend,
|
|
253
|
+
voices,
|
|
254
|
+
refreshVoices,
|
|
255
|
+
lastAssistantText,
|
|
256
|
+
speak,
|
|
257
|
+
cancel,
|
|
258
|
+
preview: () => speak(ctx.t("speech.preview.text")),
|
|
259
|
+
}
|
|
260
|
+
}
|