opencode-voice-dictation 0.0.0-stage → 0.1.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,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
+ }
@@ -0,0 +1,239 @@
1
+ import { Command, ComposerPill, SettingsPage, type ComposerPillView, type Plugin } from "@opencode/plugin/gui"
2
+ import { Dictation, Speech } from "./contract"
3
+ import type { definition } from "./definition"
4
+ import { createSpeaker } from "./speak"
5
+ import { VoiceSettings } from "./settings"
6
+
7
+ /**
8
+ * Window setup: the microphone pill above the composer, the keybind and the
9
+ * `/dictado` slash command.
10
+ */
11
+ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
12
+ const dictation = ctx.main(Dictation)
13
+ const speech = ctx.main(Speech)
14
+ const speaker = createSpeaker(ctx, speech)
15
+ const prefs = ctx.stores.prefs
16
+
17
+ let targetSession: string | undefined
18
+
19
+ const reason = (code: string, detail: string, available: readonly string[]) => {
20
+ switch (code) {
21
+ case "LANGUAGE_NOT_AVAILABLE":
22
+ return ctx.t("error.language", {
23
+ locale: detail || prefs.value.locale,
24
+ available: available.length > 0 ? available.join(", ") : "—",
25
+ })
26
+ case "NO_RECOGNIZERS":
27
+ return ctx.t("error.none")
28
+ case "MICROPHONE_UNAVAILABLE":
29
+ return ctx.t("error.mic")
30
+ case "SYSTEM_SPEECH_UNAVAILABLE":
31
+ return ctx.t("error.system")
32
+ case "ENGINE_EXIT":
33
+ return ctx.t("error.exit")
34
+ default:
35
+ return ctx.t("error.unknown", { code })
36
+ }
37
+ }
38
+
39
+ const start = () => {
40
+ speaker.cancel()
41
+ targetSession = ctx.sessions.current()?.key
42
+ void dictation
43
+ .start({ locale: prefs.value.locale, minConfidence: prefs.value.minConfidence })
44
+ .catch((cause: unknown) => {
45
+ ctx.toast.show({
46
+ variant: "error",
47
+ title: ctx.t("error.title"),
48
+ description: cause instanceof Error ? cause.message : String(cause),
49
+ })
50
+ })
51
+ }
52
+
53
+ const stop = () => {
54
+ void dictation.stop().catch(() => {})
55
+ }
56
+
57
+ const toggle = () => {
58
+ const status = dictation.state()?.status ?? "idle"
59
+ if (status === "starting" || status === "listening") stop()
60
+ else start()
61
+ }
62
+
63
+ const read = () => {
64
+ if (speaker.speaking()) {
65
+ speaker.cancel()
66
+ return
67
+ }
68
+
69
+ if (!ctx.sessions.current()) {
70
+ ctx.toast.show({ variant: "error", title: ctx.t("read.title"), description: ctx.t("read.noSession") })
71
+ return
72
+ }
73
+
74
+ const status = dictation.state()?.status ?? "idle"
75
+ if (status === "starting" || status === "listening") void dictation.stop().catch(() => {})
76
+
77
+ void (async () => {
78
+ try {
79
+ const text = await speaker.lastAssistantText()
80
+ if (!text.trim()) {
81
+ ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
82
+ return
83
+ }
84
+ const started = await speaker.speak(text)
85
+ if (!started) {
86
+ ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
87
+ }
88
+ } catch (cause) {
89
+ ctx.toast.show({
90
+ variant: "error",
91
+ title: ctx.t("read.title"),
92
+ description: cause instanceof Error ? cause.message : ctx.t("read.error"),
93
+ })
94
+ }
95
+ })()
96
+ }
97
+
98
+ dictation.on("final", (data) => {
99
+ const current = ctx.sessions.current()?.key
100
+
101
+ if (targetSession && current && current !== targetSession) {
102
+ ctx.toast.show({ variant: "error", title: ctx.t("error.title"), description: ctx.t("toast.discarded") })
103
+ return
104
+ }
105
+
106
+ const composer = ctx.screen.current()?.composer
107
+ if (!composer) return
108
+
109
+ const existing = composer.text()
110
+ const separator = prefs.value.trailingSpace && existing.length > 0 && !/\s$/.test(existing) ? " " : ""
111
+ composer.insert(separator + data.text)
112
+
113
+ if (prefs.value.autoFocus) composer.focus()
114
+ })
115
+
116
+ dictation.on("error", (data) => {
117
+ ctx.toast.show({
118
+ variant: "error",
119
+ title: ctx.t("error.title"),
120
+ description: reason(data.code, data.detail, data.available),
121
+ persistent: data.code === "LANGUAGE_NOT_AVAILABLE" || data.code === "NO_RECOGNIZERS",
122
+ duration: 8000,
123
+ })
124
+ })
125
+
126
+ const view = (): ComposerPillView | undefined => {
127
+ if (ctx.app.platform !== "desktop") return undefined
128
+
129
+ const status = dictation.state()?.status ?? "idle"
130
+
131
+ if (status === "starting") {
132
+ return { label: ctx.t("pill.starting"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
133
+ }
134
+
135
+ if (status === "listening") {
136
+ return {
137
+ label: ctx.t("pill.listening"),
138
+ icon: "outline-wave-audio",
139
+ tone: "danger",
140
+ title: ctx.t("pill.stop"),
141
+ run: toggle,
142
+ }
143
+ }
144
+
145
+ if (status === "error") {
146
+ return {
147
+ label: ctx.t("pill.error"),
148
+ icon: "outline-wave-audio",
149
+ tone: "warning",
150
+ title: ctx.t("pill.title"),
151
+ run: toggle,
152
+ }
153
+ }
154
+
155
+ return { label: ctx.t("pill.idle"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
156
+ }
157
+
158
+ ctx.add(ComposerPill, () => ({ id: "dictate", show: () => view() }))
159
+
160
+ ctx.add(ComposerPill, () => ({
161
+ id: "read",
162
+ order: 1,
163
+ show: () => {
164
+ if (ctx.app.platform !== "desktop") return undefined
165
+
166
+ return speaker.speaking()
167
+ ? {
168
+ label: ctx.t("read.speaking"),
169
+ icon: "outline-audio",
170
+ tone: "success",
171
+ title: ctx.t("read.stop"),
172
+ run: read,
173
+ }
174
+ : {
175
+ label: ctx.t("read.idle"),
176
+ icon: "outline-audio",
177
+ title: ctx.t("read.title"),
178
+ run: read,
179
+ }
180
+ },
181
+ }))
182
+
183
+ ctx.add(Command, (): Command => ({
184
+ id: "toggle",
185
+ title: ctx.t("command.toggle"),
186
+ description: ctx.t("command.toggle.description"),
187
+ keywords: "voice dictation microphone speech dictado voz microfono",
188
+ group: ctx.t("pill.title"),
189
+ section: "prompt",
190
+ bind: "mod+shift+m",
191
+ slash: { name: "dictado" },
192
+ editable: true,
193
+ enabled: ctx.app.platform === "desktop",
194
+ run: toggle,
195
+ }))
196
+
197
+ ctx.add(Command, (): Command => ({
198
+ id: "configure",
199
+ title: ctx.t("command.configure"),
200
+ keywords: "voice dictation microphone speech dictado voz microfono",
201
+ group: ctx.t("pill.title"),
202
+ enabled: ctx.app.platform === "desktop",
203
+ run: () => ctx.layout.settings(ctx.id),
204
+ }))
205
+
206
+ ctx.add(Command, (): Command => ({
207
+ id: "read",
208
+ title: ctx.t("command.read"),
209
+ description: ctx.t("command.read.description"),
210
+ keywords: "read aloud speech voice leer voz altavoz lectura",
211
+ group: ctx.t("pill.title"),
212
+ section: "prompt",
213
+ bind: "mod+shift+l",
214
+ slash: { name: "leer" },
215
+ editable: true,
216
+ enabled: ctx.app.platform === "desktop",
217
+ run: read,
218
+ }))
219
+
220
+ ctx.add(SettingsPage, {
221
+ id: ctx.id,
222
+ icon: "outline-audio",
223
+ get title() {
224
+ return ctx.t("settings.title")
225
+ },
226
+ get entries() {
227
+ return [
228
+ { id: "language", title: ctx.t("settings.language"), keywords: "voice language idioma voz" },
229
+ { id: "confidence", title: ctx.t("settings.confidence"), keywords: "voice confidence confianza" },
230
+ { id: "test", title: ctx.t("settings.test"), keywords: "voice microphone microphone test microfono" },
231
+ { id: "voice", title: ctx.t("speech.voice"), keywords: "read aloud voice voz lectura altavoz" },
232
+ { id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
233
+ { id: "skip-code", title: ctx.t("speech.skipCode"), keywords: "read aloud code codigo lectura" },
234
+ { id: "preview", title: ctx.t("speech.preview"), keywords: "read aloud test voice probar voz" },
235
+ ]
236
+ },
237
+ render: () => <VoiceSettings speaker={speaker} />,
238
+ })
239
+ }
package/src/gui.tsx ADDED
@@ -0,0 +1,6 @@
1
+ import { Plugin } from "@opencode/plugin/gui"
2
+ import { definition } from "./gui/definition"
3
+ import { main } from "./gui/main"
4
+ import { setup } from "./gui/window"
5
+
6
+ export default Plugin.define({ ...definition, setup, main })
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * Server part of the plugin.
5
+ *
6
+ * It does nothing on its own: it exists so the desktop app assigns the plugin
7
+ * the id `voice-dictation` (used for commands, settings pages and the Ipc
8
+ * contract) and so the GUI part is loaded from the global `opencode.json`.
9
+ *
10
+ * `@opencode/plugin` is a type-only import on purpose: the server loads this
11
+ * file directly, and a runtime import would make the plugin depend on a
12
+ * `node_modules` tree next to it.
13
+ */
14
+ const plugin: Plugin.Plugin = {
15
+ id: "voice-dictation",
16
+ setup() {},
17
+ }
18
+
19
+ export default plugin