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,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
@@ -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