opencode-voice-dictation 0.4.2 → 0.6.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.
@@ -3,6 +3,7 @@ import { usePlugin } from "@opencode/plugin/gui"
3
3
  import { Dictation, VoiceTyping } from "./contract"
4
4
  import type { definition } from "./definition"
5
5
  import type { Speaker } from "./speak"
6
+ import { VoiceRpc } from "../rpc"
6
7
 
7
8
  const FALLBACK_LOCALES = [
8
9
  "es-ES",
@@ -29,11 +30,13 @@ export function VoiceSettings(props: { speaker: Speaker }) {
29
30
  const speechPrefs = ctx.stores.speech
30
31
  const ttsPrefs = ctx.stores.tts
31
32
  const enginePrefs = ctx.stores.engine
33
+ const whisperPrefs = ctx.stores.whisper
32
34
  const dictation = ctx.main(Dictation)
33
35
  const voiceTyping = ctx.main(VoiceTyping)
34
36
 
35
37
  const [locales, setLocales] = createSignal<readonly string[]>([])
36
38
  const [busy, setBusy] = createSignal(false)
39
+ const [whisperBusy, setWhisperBusy] = createSignal(false)
37
40
 
38
41
  onMount(() => {
39
42
  props.speaker.setConfig({ engine: ttsPrefs.value.engine, voice: ttsPrefs.value.voice })
@@ -96,6 +99,48 @@ export function VoiceSettings(props: { speaker: Speaker }) {
96
99
  })
97
100
  }
98
101
 
102
+ const patchWhisper = (change: { python?: string; model?: string; language?: string }) => {
103
+ whisperPrefs.update((draft) => {
104
+ if (change.python !== undefined) draft.python = change.python
105
+ if (change.model !== undefined) draft.model = change.model
106
+ if (change.language !== undefined) draft.language = change.language
107
+ })
108
+ }
109
+
110
+ const testWhisper = async () => {
111
+ setWhisperBusy(true)
112
+ try {
113
+ const status = (await ctx.client.rpc(VoiceRpc).whisperStatus({
114
+ python: whisperPrefs.value.python,
115
+ model: whisperPrefs.value.model,
116
+ })) as { ready: boolean; model: string; device: string; error: string }
117
+
118
+ if (status.ready) {
119
+ ctx.toast.show({
120
+ variant: "success",
121
+ title: ctx.t("pill.title"),
122
+ description: ctx.t("whisper.test.ready", { model: status.model, device: status.device }),
123
+ })
124
+ } else {
125
+ ctx.toast.show({
126
+ variant: "error",
127
+ title: ctx.t("pill.title"),
128
+ description: ctx.t("whisper.test.error", { error: status.error || "?" }),
129
+ })
130
+ }
131
+ } catch (cause) {
132
+ ctx.toast.show({
133
+ variant: "error",
134
+ title: ctx.t("pill.title"),
135
+ description: ctx.t("whisper.test.error", {
136
+ error: cause instanceof Error ? cause.message : String(cause),
137
+ }),
138
+ })
139
+ } finally {
140
+ setWhisperBusy(false)
141
+ }
142
+ }
143
+
99
144
  const patchTts = (engine: string) => {
100
145
  ttsPrefs.update((draft) => {
101
146
  draft.engine = engine
@@ -190,6 +235,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
190
235
  options={[
191
236
  { value: "windows", label: ctx.t("engine.windows") },
192
237
  { value: "system", label: ctx.t("engine.system") },
238
+ { value: "whisper", label: ctx.t("engine.whisper") },
193
239
  ]}
194
240
  onChange={(value) => patchEngine(value)}
195
241
  />
@@ -205,6 +251,65 @@ export function VoiceSettings(props: { speaker: Speaker }) {
205
251
  </ui.SettingsRow>
206
252
  </Show>
207
253
 
254
+ <Show when={enginePrefs.value.mode === "whisper"}>
255
+ <ui.SettingsRow
256
+ id="whisper-python"
257
+ title={ctx.t("whisper.python")}
258
+ description={ctx.t("whisper.python.description")}
259
+ >
260
+ <ui.TextInput
261
+ label={ctx.t("whisper.python")}
262
+ value={whisperPrefs.value.python}
263
+ onInput={(value) => patchWhisper({ python: value })}
264
+ />
265
+ </ui.SettingsRow>
266
+
267
+ <ui.SettingsRow
268
+ id="whisper-model"
269
+ title={ctx.t("whisper.model")}
270
+ description={ctx.t("whisper.model.description")}
271
+ >
272
+ <ui.Select
273
+ label={ctx.t("whisper.model")}
274
+ value={whisperPrefs.value.model}
275
+ options={[
276
+ { value: "large-v3-turbo", label: "large-v3-turbo" },
277
+ { value: "large-v3", label: "large-v3" },
278
+ { value: "medium", label: "medium" },
279
+ ]}
280
+ onChange={(value) => patchWhisper({ model: value })}
281
+ />
282
+ </ui.SettingsRow>
283
+
284
+ <ui.SettingsRow
285
+ id="whisper-language"
286
+ title={ctx.t("whisper.language")}
287
+ description={ctx.t("whisper.language.description")}
288
+ >
289
+ <ui.Select
290
+ label={ctx.t("whisper.language")}
291
+ value={whisperPrefs.value.language}
292
+ options={[
293
+ { value: "", label: ctx.t("whisper.language.auto") },
294
+ { value: "es", label: "Español" },
295
+ { value: "en", label: "English" },
296
+ { value: "pt", label: "Português" },
297
+ ]}
298
+ onChange={(value) => patchWhisper({ language: value })}
299
+ />
300
+ </ui.SettingsRow>
301
+
302
+ <ui.SettingsRow
303
+ id="whisper-test"
304
+ title={ctx.t("whisper.test")}
305
+ description={ctx.t("whisper.test.description")}
306
+ >
307
+ <ui.Button busy={whisperBusy()} onClick={() => void testWhisper()}>
308
+ {ctx.t("whisper.test.run")}
309
+ </ui.Button>
310
+ </ui.SettingsRow>
311
+ </Show>
312
+
208
313
  <Show when={enginePrefs.value.mode === "system"}>
209
314
  <ui.SettingsRow
210
315
  id="language"
package/src/gui/speak.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSignal } from "solid-js"
2
2
  import type { IpcClient, SetupContext } from "@opencode/plugin/gui"
3
+ import { VoiceRpc } from "../rpc"
3
4
  import { Speech } from "./contract"
4
5
  import type { definition } from "./definition"
5
6
 
@@ -153,7 +154,10 @@ export function createSpeaker(
153
154
  const refreshVoices = async () => {
154
155
  if (engine() === "edge") {
155
156
  try {
156
- const list = await speech.edgeVoices()
157
+ const list = (await ctx.client.rpc(VoiceRpc).edgeVoices({})) as unknown as Array<{
158
+ id: string
159
+ label: string
160
+ }>
157
161
  if (list.length > 0) {
158
162
  setVoices(list.map((voice) => ({ id: voice.id, label: voice.label })))
159
163
  setError("")
@@ -279,7 +283,11 @@ export function createSpeaker(
279
283
 
280
284
  try {
281
285
  if (!encoded) {
282
- encoded = await speech.edgeSpeak({ text: blocks[index] ?? "", voice: selected, rate })
286
+ encoded = (await ctx.client.rpc(VoiceRpc).edgeSpeak({
287
+ text: blocks[index] ?? "",
288
+ voice: selected,
289
+ rate,
290
+ })) as string
283
291
  cached[index] = encoded
284
292
  }
285
293
  } catch {
@@ -13,6 +13,8 @@ import { Dictation, Speech, VoiceTyping } from "./contract"
13
13
  import type { definition } from "./definition"
14
14
  import { createSpeaker, type Speaker } from "./speak"
15
15
  import { VoiceSettings } from "./settings"
16
+ import { blobToBase64, startRecording, type Recording } from "./recorder"
17
+ import { VoiceRpc } from "../rpc"
16
18
 
17
19
  interface ReadAction {
18
20
  id: string
@@ -53,9 +55,12 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
53
55
  const speaker = createSpeaker(ctx, speech)
54
56
  const prefs = ctx.stores.prefs
55
57
  const engine = ctx.stores.engine
58
+ const whisper = ctx.stores.whisper
56
59
  const [windowsActive, setWindowsActive] = createSignal(false)
60
+ const [whisperState, setWhisperState] = createSignal<"idle" | "recording" | "transcribing">("idle")
57
61
 
58
62
  let targetSession: string | undefined
63
+ let recording: Recording | undefined
59
64
 
60
65
  const reason = (code: string, detail: string, available: readonly string[]) => {
61
66
  switch (code) {
@@ -117,7 +122,83 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
117
122
  })
118
123
  }
119
124
 
125
+ const startWhisper = async () => {
126
+ if (whisperState() !== "idle") return
127
+ speaker.cancel()
128
+
129
+ try {
130
+ recording = await startRecording()
131
+ setWhisperState("recording")
132
+ } catch (cause) {
133
+ ctx.toast.show({
134
+ variant: "error",
135
+ title: ctx.t("pill.title"),
136
+ description: cause instanceof Error ? cause.message : ctx.t("error.whisper"),
137
+ })
138
+ }
139
+ }
140
+
141
+ const cancelWhisper = () => {
142
+ const current = recording
143
+ recording = undefined
144
+ if (current) void current.stop().catch(() => {})
145
+ setWhisperState("idle")
146
+ }
147
+
148
+ const stopWhisper = async () => {
149
+ const current = recording
150
+ recording = undefined
151
+
152
+ if (!current) {
153
+ setWhisperState("idle")
154
+ return
155
+ }
156
+
157
+ setWhisperState("transcribing")
158
+
159
+ try {
160
+ const blob = await current.stop()
161
+ const audio = await blobToBase64(blob)
162
+ const result = (await ctx.client.rpc(VoiceRpc).whisperTranscribe({
163
+ audio,
164
+ mime: blob.type,
165
+ language: whisper.value.language,
166
+ python: whisper.value.python,
167
+ model: whisper.value.model,
168
+ })) as { text: string; language: string }
169
+
170
+ const text = result.text.trim()
171
+
172
+ if (!text) {
173
+ ctx.toast.show({ variant: "default", title: ctx.t("pill.title"), description: ctx.t("whisper.empty") })
174
+ return
175
+ }
176
+
177
+ const composer = ctx.screen.current()?.composer
178
+ if (composer) {
179
+ const existing = composer.text()
180
+ const separator = existing.length > 0 && !/\s$/.test(existing) ? " " : ""
181
+ composer.insert(separator + text)
182
+ composer.focus()
183
+ }
184
+ } catch (cause) {
185
+ ctx.toast.show({
186
+ variant: "error",
187
+ title: ctx.t("pill.title"),
188
+ description: cause instanceof Error ? cause.message : ctx.t("error.whisper"),
189
+ })
190
+ } finally {
191
+ setWhisperState("idle")
192
+ }
193
+ }
194
+
120
195
  const toggle = () => {
196
+ if (engine.value.mode === "whisper") {
197
+ if (whisperState() === "recording") void stopWhisper()
198
+ else if (whisperState() === "idle") void startWhisper()
199
+ return
200
+ }
201
+
121
202
  if (engine.value.mode === "windows") {
122
203
  toggleWindows()
123
204
  return
@@ -129,6 +210,8 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
129
210
  }
130
211
 
131
212
  const read = () => {
213
+ if (whisperState() === "recording") cancelWhisper()
214
+
132
215
  if (speaker.speaking()) {
133
216
  speaker.cancel()
134
217
  return
@@ -235,6 +318,33 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
235
318
  }
236
319
  }
237
320
 
321
+ if (engine.value.mode === "whisper") {
322
+ if (whisperState() === "recording") {
323
+ return {
324
+ label: ctx.t("pill.recording"),
325
+ icon: "outline-wave-audio",
326
+ tone: "danger",
327
+ title: ctx.t("pill.stop"),
328
+ run: toggle,
329
+ }
330
+ }
331
+
332
+ if (whisperState() === "transcribing") {
333
+ return {
334
+ label: ctx.t("pill.transcribing"),
335
+ icon: "outline-audio",
336
+ title: ctx.t("pill.whisper"),
337
+ }
338
+ }
339
+
340
+ return {
341
+ label: ctx.t("pill.idle"),
342
+ icon: "outline-wave-audio",
343
+ title: ctx.t("pill.whisper"),
344
+ run: toggle,
345
+ }
346
+ }
347
+
238
348
  const status = dictation.state()?.status ?? "idle"
239
349
 
240
350
  if (status === "starting") {
@@ -350,6 +460,10 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
350
460
  { id: "test", title: ctx.t("settings.test"), keywords: "voice microphone microphone test microfono" },
351
461
  { id: "voice", title: ctx.t("speech.voice"), keywords: "read aloud voice voz lectura altavoz" },
352
462
  { id: "tts-engine", title: ctx.t("speech.engine"), keywords: "voice engine motor edge neural sistema" },
463
+ { id: "whisper-python", title: ctx.t("whisper.python"), keywords: "whisper python venv entorno" },
464
+ { id: "whisper-model", title: ctx.t("whisper.model"), keywords: "whisper model modelo" },
465
+ { id: "whisper-language", title: ctx.t("whisper.language"), keywords: "whisper language idioma" },
466
+ { id: "whisper-test", title: ctx.t("whisper.test"), keywords: "whisper check comprobar" },
353
467
  { id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
354
468
  { id: "skip-code", title: ctx.t("speech.skipCode"), keywords: "read aloud code codigo lectura" },
355
469
  { id: "preview", title: ctx.t("speech.preview"), keywords: "read aloud test voice probar voz" },
package/src/index.ts CHANGED
@@ -1,19 +1,50 @@
1
- import type { Plugin } from "@opencode/plugin"
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { listEdgeVoices, synthesizeEdge } from "./edge.js"
3
+ import { VoiceRpc } from "./rpc.js"
4
+ import { WhisperEngine } from "./whisper/server.js"
2
5
 
3
6
  /**
4
7
  * Server part of the plugin.
5
8
  *
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.
9
+ * It registers the Edge TTS and Whisper RPCs. Running them here (instead of
10
+ * in the Electron main process) lets their dependencies resolve from the
11
+ * plugin's own node_modules.
13
12
  */
14
- const plugin: Plugin.Plugin = {
13
+ export default Plugin.define({
15
14
  id: "voice-dictation",
16
- setup() {},
17
- }
15
+ async setup(ctx) {
16
+ const whisper = new WhisperEngine()
17
+
18
+ await ctx.rpc.register(VoiceRpc, {
19
+ edgeVoices: async () => listEdgeVoices(),
20
+ edgeSpeak: async (input) => {
21
+ const { text, voice, rate } = input as { text: string; voice: string; rate: number }
22
+ return synthesizeEdge({ text, voice, rate })
23
+ },
24
+ whisperTranscribe: async (input) => {
25
+ return whisper.transcribe(input as {
26
+ audio: string
27
+ mime: string
28
+ language: string
29
+ python: string
30
+ model: string
31
+ })
32
+ },
33
+ whisperStatus: async (input) => {
34
+ const { python, model } = input as { python: string; model: string }
35
+ try {
36
+ return await whisper.warm(python, model)
37
+ } catch (cause) {
38
+ return {
39
+ ...whisper.statusOf(),
40
+ ready: false,
41
+ error: cause instanceof Error ? cause.message : String(cause),
42
+ }
43
+ }
44
+ },
45
+ })
46
+
47
+ return () => whisper.stop()
48
+ },
49
+ })
18
50
 
19
- export default plugin
package/src/rpc.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Rpc } from "@opencode/plugin"
2
+
3
+ /**
4
+ * Edge TTS runs in the OpenCode server, where the plugin's own `node_modules`
5
+ * resolves. The GUI only calls these methods and plays the returned MP3.
6
+ *
7
+ * The definition is a plain object (Rpc.define is the identity function), so
8
+ * the GUI bundle does not need to load the RPC module at runtime.
9
+ */
10
+ export const VoiceRpc = {
11
+ id: "voice-dictation.voice",
12
+ methods: {
13
+ edgeVoices: {
14
+ input: { type: "object", additionalProperties: false },
15
+ output: {
16
+ type: "array",
17
+ items: {
18
+ type: "object",
19
+ properties: {
20
+ id: { type: "string" },
21
+ label: { type: "string" },
22
+ locale: { type: "string" },
23
+ gender: { type: "string" },
24
+ },
25
+ required: ["id", "label", "locale", "gender"],
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ },
30
+ edgeSpeak: {
31
+ input: {
32
+ type: "object",
33
+ properties: {
34
+ text: { type: "string" },
35
+ voice: { type: "string" },
36
+ rate: { type: "number" },
37
+ },
38
+ required: ["text", "voice", "rate"],
39
+ additionalProperties: false,
40
+ },
41
+ output: { type: "string" },
42
+ },
43
+ whisperTranscribe: {
44
+ input: {
45
+ type: "object",
46
+ properties: {
47
+ audio: { type: "string" },
48
+ mime: { type: "string" },
49
+ language: { type: "string" },
50
+ python: { type: "string" },
51
+ model: { type: "string" },
52
+ },
53
+ required: ["audio", "mime", "language", "python", "model"],
54
+ additionalProperties: false,
55
+ },
56
+ output: {
57
+ type: "object",
58
+ properties: {
59
+ text: { type: "string" },
60
+ language: { type: "string" },
61
+ },
62
+ required: ["text", "language"],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ whisperStatus: {
67
+ input: {
68
+ type: "object",
69
+ properties: {
70
+ python: { type: "string" },
71
+ model: { type: "string" },
72
+ },
73
+ required: ["python", "model"],
74
+ additionalProperties: false,
75
+ },
76
+ output: {
77
+ type: "object",
78
+ properties: {
79
+ ready: { type: "boolean" },
80
+ model: { type: "string" },
81
+ device: { type: "string" },
82
+ error: { type: "string" },
83
+ },
84
+ required: ["ready", "model", "device", "error"],
85
+ additionalProperties: false,
86
+ },
87
+ },
88
+ },
89
+ events: {},
90
+ } as const satisfies Rpc.PortableDefinition