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,289 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import { Command, ComposerPill, SettingsPage, type ComposerPillView, type Plugin } from "@opencode/plugin/gui"
|
|
3
|
+
import { Dictation, Speech, VoiceTyping } from "./contract"
|
|
4
|
+
import type { definition } from "./definition"
|
|
5
|
+
import { createSpeaker } from "./speak"
|
|
6
|
+
import { VoiceSettings } from "./settings"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Window setup: the microphone pill above the composer, the keybind and the
|
|
10
|
+
* `/dictado` slash command.
|
|
11
|
+
*/
|
|
12
|
+
export const setup: Plugin.Setup<typeof definition> = (ctx) => {
|
|
13
|
+
const dictation = ctx.main(Dictation)
|
|
14
|
+
const speech = ctx.main(Speech)
|
|
15
|
+
const voiceTyping = ctx.main(VoiceTyping)
|
|
16
|
+
const speaker = createSpeaker(ctx, speech)
|
|
17
|
+
const prefs = ctx.stores.prefs
|
|
18
|
+
const engine = ctx.stores.engine
|
|
19
|
+
const [windowsActive, setWindowsActive] = createSignal(false)
|
|
20
|
+
|
|
21
|
+
let targetSession: string | undefined
|
|
22
|
+
|
|
23
|
+
const reason = (code: string, detail: string, available: readonly string[]) => {
|
|
24
|
+
switch (code) {
|
|
25
|
+
case "LANGUAGE_NOT_AVAILABLE":
|
|
26
|
+
return ctx.t("error.language", {
|
|
27
|
+
locale: detail || prefs.value.locale,
|
|
28
|
+
available: available.length > 0 ? available.join(", ") : "—",
|
|
29
|
+
})
|
|
30
|
+
case "NO_RECOGNIZERS":
|
|
31
|
+
return ctx.t("error.none")
|
|
32
|
+
case "MICROPHONE_UNAVAILABLE":
|
|
33
|
+
return ctx.t("error.mic")
|
|
34
|
+
case "SYSTEM_SPEECH_UNAVAILABLE":
|
|
35
|
+
return ctx.t("error.system")
|
|
36
|
+
case "ENGINE_EXIT":
|
|
37
|
+
return ctx.t("error.exit")
|
|
38
|
+
default:
|
|
39
|
+
return ctx.t("error.unknown", { code })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const start = () => {
|
|
44
|
+
if (windowsActive()) {
|
|
45
|
+
setWindowsActive(false)
|
|
46
|
+
void voiceTyping.toggle().catch(() => {})
|
|
47
|
+
}
|
|
48
|
+
speaker.cancel()
|
|
49
|
+
targetSession = ctx.sessions.current()?.key
|
|
50
|
+
void dictation
|
|
51
|
+
.start({ locale: prefs.value.locale, minConfidence: prefs.value.minConfidence })
|
|
52
|
+
.catch((cause: unknown) => {
|
|
53
|
+
ctx.toast.show({
|
|
54
|
+
variant: "error",
|
|
55
|
+
title: ctx.t("error.title"),
|
|
56
|
+
description: cause instanceof Error ? cause.message : String(cause),
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stop = () => {
|
|
62
|
+
void dictation.stop().catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const toggleWindows = () => {
|
|
66
|
+
speaker.cancel()
|
|
67
|
+
ctx.screen.current()?.composer.focus()
|
|
68
|
+
|
|
69
|
+
void voiceTyping
|
|
70
|
+
.toggle()
|
|
71
|
+
.then(() => setWindowsActive((value) => !value))
|
|
72
|
+
.catch((cause: unknown) => {
|
|
73
|
+
setWindowsActive(false)
|
|
74
|
+
ctx.toast.show({
|
|
75
|
+
variant: "error",
|
|
76
|
+
title: ctx.t("pill.title"),
|
|
77
|
+
description:
|
|
78
|
+
cause instanceof Error ? `${ctx.t("error.winh")} ${cause.message}` : ctx.t("error.winh"),
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const toggle = () => {
|
|
84
|
+
if (engine.value.mode === "windows") {
|
|
85
|
+
toggleWindows()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const status = dictation.state()?.status ?? "idle"
|
|
90
|
+
if (status === "starting" || status === "listening") stop()
|
|
91
|
+
else start()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const read = () => {
|
|
95
|
+
if (speaker.speaking()) {
|
|
96
|
+
speaker.cancel()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!ctx.sessions.current()) {
|
|
101
|
+
ctx.toast.show({ variant: "error", title: ctx.t("read.title"), description: ctx.t("read.noSession") })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const status = dictation.state()?.status ?? "idle"
|
|
106
|
+
if (status === "starting" || status === "listening") void dictation.stop().catch(() => {})
|
|
107
|
+
|
|
108
|
+
void (async () => {
|
|
109
|
+
try {
|
|
110
|
+
const text = await speaker.lastAssistantText()
|
|
111
|
+
if (!text.trim()) {
|
|
112
|
+
ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const started = await speaker.speak(text)
|
|
116
|
+
if (!started) {
|
|
117
|
+
ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
|
|
118
|
+
}
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
ctx.toast.show({
|
|
121
|
+
variant: "error",
|
|
122
|
+
title: ctx.t("read.title"),
|
|
123
|
+
description: cause instanceof Error ? cause.message : ctx.t("read.error"),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
})()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dictation.on("final", (data) => {
|
|
130
|
+
const current = ctx.sessions.current()?.key
|
|
131
|
+
|
|
132
|
+
if (targetSession && current && current !== targetSession) {
|
|
133
|
+
ctx.toast.show({ variant: "error", title: ctx.t("error.title"), description: ctx.t("toast.discarded") })
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const composer = ctx.screen.current()?.composer
|
|
138
|
+
if (!composer) return
|
|
139
|
+
|
|
140
|
+
const existing = composer.text()
|
|
141
|
+
const separator = prefs.value.trailingSpace && existing.length > 0 && !/\s$/.test(existing) ? " " : ""
|
|
142
|
+
composer.insert(separator + data.text)
|
|
143
|
+
|
|
144
|
+
if (prefs.value.autoFocus) composer.focus()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
dictation.on("error", (data) => {
|
|
148
|
+
ctx.toast.show({
|
|
149
|
+
variant: "error",
|
|
150
|
+
title: ctx.t("error.title"),
|
|
151
|
+
description: reason(data.code, data.detail, data.available),
|
|
152
|
+
persistent: data.code === "LANGUAGE_NOT_AVAILABLE" || data.code === "NO_RECOGNIZERS",
|
|
153
|
+
duration: 8000,
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const view = (): ComposerPillView | undefined => {
|
|
158
|
+
if (ctx.app.platform !== "desktop") return undefined
|
|
159
|
+
|
|
160
|
+
if (engine.value.mode === "windows") {
|
|
161
|
+
return windowsActive()
|
|
162
|
+
? {
|
|
163
|
+
label: ctx.t("pill.dictating"),
|
|
164
|
+
icon: "outline-wave-audio",
|
|
165
|
+
tone: "danger",
|
|
166
|
+
title: ctx.t("pill.stop"),
|
|
167
|
+
run: toggle,
|
|
168
|
+
}
|
|
169
|
+
: {
|
|
170
|
+
label: ctx.t("pill.idle"),
|
|
171
|
+
icon: "outline-wave-audio",
|
|
172
|
+
title: ctx.t("pill.winH"),
|
|
173
|
+
run: toggle,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const status = dictation.state()?.status ?? "idle"
|
|
178
|
+
|
|
179
|
+
if (status === "starting") {
|
|
180
|
+
return { label: ctx.t("pill.starting"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (status === "listening") {
|
|
184
|
+
return {
|
|
185
|
+
label: ctx.t("pill.listening"),
|
|
186
|
+
icon: "outline-wave-audio",
|
|
187
|
+
tone: "danger",
|
|
188
|
+
title: ctx.t("pill.stop"),
|
|
189
|
+
run: toggle,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (status === "error") {
|
|
194
|
+
return {
|
|
195
|
+
label: ctx.t("pill.error"),
|
|
196
|
+
icon: "outline-wave-audio",
|
|
197
|
+
tone: "warning",
|
|
198
|
+
title: ctx.t("pill.title"),
|
|
199
|
+
run: toggle,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { label: ctx.t("pill.idle"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
ctx.add(ComposerPill, () => ({ id: "dictate", show: () => view() }))
|
|
207
|
+
|
|
208
|
+
ctx.add(ComposerPill, () => ({
|
|
209
|
+
id: "read",
|
|
210
|
+
order: 1,
|
|
211
|
+
show: () => {
|
|
212
|
+
if (ctx.app.platform !== "desktop") return undefined
|
|
213
|
+
|
|
214
|
+
return speaker.speaking()
|
|
215
|
+
? {
|
|
216
|
+
label: ctx.t("read.speaking"),
|
|
217
|
+
icon: "outline-audio",
|
|
218
|
+
tone: "success",
|
|
219
|
+
title: ctx.t("read.stop"),
|
|
220
|
+
run: read,
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
label: ctx.t("read.idle"),
|
|
224
|
+
icon: "outline-audio",
|
|
225
|
+
title: ctx.t("read.title"),
|
|
226
|
+
run: read,
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
ctx.add(Command, (): Command => ({
|
|
232
|
+
id: "toggle",
|
|
233
|
+
title: ctx.t("command.toggle"),
|
|
234
|
+
description: ctx.t("command.toggle.description"),
|
|
235
|
+
keywords: "voice dictation microphone speech dictado voz microfono",
|
|
236
|
+
group: ctx.t("pill.title"),
|
|
237
|
+
section: "prompt",
|
|
238
|
+
bind: "mod+shift+m",
|
|
239
|
+
slash: { name: "dictado" },
|
|
240
|
+
editable: true,
|
|
241
|
+
enabled: ctx.app.platform === "desktop",
|
|
242
|
+
run: toggle,
|
|
243
|
+
}))
|
|
244
|
+
|
|
245
|
+
ctx.add(Command, (): Command => ({
|
|
246
|
+
id: "configure",
|
|
247
|
+
title: ctx.t("command.configure"),
|
|
248
|
+
keywords: "voice dictation microphone speech dictado voz microfono",
|
|
249
|
+
group: ctx.t("pill.title"),
|
|
250
|
+
enabled: ctx.app.platform === "desktop",
|
|
251
|
+
run: () => ctx.layout.settings(ctx.id),
|
|
252
|
+
}))
|
|
253
|
+
|
|
254
|
+
ctx.add(Command, (): Command => ({
|
|
255
|
+
id: "read",
|
|
256
|
+
title: ctx.t("command.read"),
|
|
257
|
+
description: ctx.t("command.read.description"),
|
|
258
|
+
keywords: "read aloud speech voice leer voz altavoz lectura",
|
|
259
|
+
group: ctx.t("pill.title"),
|
|
260
|
+
section: "prompt",
|
|
261
|
+
bind: "mod+shift+l",
|
|
262
|
+
slash: { name: "leer" },
|
|
263
|
+
editable: true,
|
|
264
|
+
enabled: ctx.app.platform === "desktop",
|
|
265
|
+
run: read,
|
|
266
|
+
}))
|
|
267
|
+
|
|
268
|
+
ctx.add(SettingsPage, {
|
|
269
|
+
id: ctx.id,
|
|
270
|
+
icon: "outline-audio",
|
|
271
|
+
get title() {
|
|
272
|
+
return ctx.t("settings.title")
|
|
273
|
+
},
|
|
274
|
+
get entries() {
|
|
275
|
+
return [
|
|
276
|
+
{ id: "engine", title: ctx.t("settings.engine"), keywords: "engine motor dictation dictado winh voz" },
|
|
277
|
+
{ id: "winh", title: ctx.t("settings.winh"), keywords: "winh windows voice typing dictado" },
|
|
278
|
+
{ id: "language", title: ctx.t("settings.language"), keywords: "voice language idioma voz" },
|
|
279
|
+
{ id: "confidence", title: ctx.t("settings.confidence"), keywords: "voice confidence confianza" },
|
|
280
|
+
{ id: "test", title: ctx.t("settings.test"), keywords: "voice microphone microphone test microfono" },
|
|
281
|
+
{ id: "voice", title: ctx.t("speech.voice"), keywords: "read aloud voice voz lectura altavoz" },
|
|
282
|
+
{ id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
|
|
283
|
+
{ id: "skip-code", title: ctx.t("speech.skipCode"), keywords: "read aloud code codigo lectura" },
|
|
284
|
+
{ id: "preview", title: ctx.t("speech.preview"), keywords: "read aloud test voice probar voz" },
|
|
285
|
+
]
|
|
286
|
+
},
|
|
287
|
+
render: () => <VoiceSettings speaker={speaker} />,
|
|
288
|
+
})
|
|
289
|
+
}
|
package/src/gui.tsx
ADDED
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
|