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.
@@ -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
+ }
@@ -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
+ }