opencode-voice-dictation 0.6.1 → 0.7.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.
@@ -31,24 +31,57 @@ export function VoiceSettings(props: { speaker: Speaker }) {
31
31
  const ttsPrefs = ctx.stores.tts
32
32
  const enginePrefs = ctx.stores.engine
33
33
  const whisperPrefs = ctx.stores.whisper
34
+ const whisperCppPrefs = ctx.stores.whispercpp
34
35
  const dictation = ctx.main(Dictation)
35
36
  const voiceTyping = ctx.main(VoiceTyping)
36
37
 
37
38
  const [locales, setLocales] = createSignal<readonly string[]>([])
38
39
  const [busy, setBusy] = createSignal(false)
39
40
  const [whisperBusy, setWhisperBusy] = createSignal(false)
41
+ const [whisperCppBusy, setWhisperCppBusy] = createSignal(false)
42
+ const [whisperCppCheckBusy, setWhisperCppCheckBusy] = createSignal(false)
43
+ const [platform, setPlatform] = createSignal("")
40
44
 
41
45
  onMount(() => {
42
46
  props.speaker.setConfig({ engine: ttsPrefs.value.engine, voice: ttsPrefs.value.voice })
43
- void dictation
44
- .diagnose()
45
- .then((result) => {
46
- if (result.locales.length > 0) setLocales(result.locales)
47
+
48
+ const loadLocales = () => {
49
+ void dictation
50
+ .diagnose()
51
+ .then((result) => {
52
+ if (result.locales.length > 0) setLocales(result.locales)
53
+ })
54
+ .catch(() => {})
55
+ }
56
+
57
+ void ctx.client
58
+ .rpc(VoiceRpc)
59
+ .capabilities({})
60
+ .then((value) => {
61
+ const caps = value as { platform: string; engines: string[] }
62
+ setPlatform(caps.platform)
63
+ if (!caps.engines.includes(enginePrefs.value.mode) && caps.engines.length > 0) {
64
+ patchEngine(caps.engines[0] ?? "windows")
65
+ }
66
+ if (caps.platform !== "linux") loadLocales()
47
67
  })
48
- .catch(() => {})
68
+ .catch(() => loadLocales())
69
+
49
70
  void props.speaker.refreshVoices()
50
71
  })
51
72
 
73
+ const engineOptions = () => {
74
+ if (platform() === "linux") {
75
+ return [{ value: "whispercpp", label: ctx.t("engine.whispercpp") }]
76
+ }
77
+
78
+ return [
79
+ { value: "windows", label: ctx.t("engine.windows") },
80
+ { value: "system", label: ctx.t("engine.system") },
81
+ { value: "whisper", label: ctx.t("engine.whisper") },
82
+ ]
83
+ }
84
+
52
85
  const localeOptions = () => {
53
86
  const list = locales()
54
87
  const current = prefs.value.locale
@@ -107,6 +140,88 @@ export function VoiceSettings(props: { speaker: Speaker }) {
107
140
  })
108
141
  }
109
142
 
143
+ const patchWhisperCpp = (change: { model?: string; language?: string }) => {
144
+ whisperCppPrefs.update((draft) => {
145
+ if (change.model !== undefined) draft.model = change.model
146
+ if (change.language !== undefined) draft.language = change.language
147
+ })
148
+ }
149
+
150
+ const installWhisperCpp = async () => {
151
+ setWhisperCppBusy(true)
152
+ try {
153
+ const result = (await ctx.client.rpc(VoiceRpc).whisperCppInstall({
154
+ model: whisperCppPrefs.value.model,
155
+ })) as { ok: boolean; model: string; error: string }
156
+
157
+ if (result.ok) {
158
+ ctx.toast.show({
159
+ variant: "success",
160
+ title: ctx.t("pill.title"),
161
+ description: ctx.t("whispercpp.install.ok", { model: result.model }),
162
+ })
163
+ } else {
164
+ ctx.toast.show({
165
+ variant: "error",
166
+ title: ctx.t("pill.title"),
167
+ description: ctx.t("whispercpp.install.error", { error: result.error || "?" }),
168
+ persistent: true,
169
+ })
170
+ }
171
+ } catch (cause) {
172
+ ctx.toast.show({
173
+ variant: "error",
174
+ title: ctx.t("pill.title"),
175
+ description: ctx.t("whispercpp.install.error", {
176
+ error: cause instanceof Error ? cause.message : String(cause),
177
+ }),
178
+ persistent: true,
179
+ })
180
+ } finally {
181
+ setWhisperCppBusy(false)
182
+ }
183
+ }
184
+
185
+ const checkWhisperCpp = async () => {
186
+ setWhisperCppCheckBusy(true)
187
+ try {
188
+ const status = (await ctx.client.rpc(VoiceRpc).whisperCppStatus({
189
+ model: whisperCppPrefs.value.model,
190
+ })) as { installed: boolean; ready: boolean; model: string; error: string }
191
+
192
+ if (status.error && !status.installed) {
193
+ ctx.toast.show({
194
+ variant: "error",
195
+ title: ctx.t("pill.title"),
196
+ description: ctx.t("whispercpp.test.error", { error: status.error }),
197
+ persistent: true,
198
+ })
199
+ } else if (!status.installed) {
200
+ ctx.toast.show({
201
+ variant: "default",
202
+ title: ctx.t("pill.title"),
203
+ description: ctx.t("whispercpp.test.missing"),
204
+ })
205
+ } else {
206
+ ctx.toast.show({
207
+ variant: "success",
208
+ title: ctx.t("pill.title"),
209
+ description: ctx.t("whispercpp.test.ready", { model: status.model }),
210
+ })
211
+ }
212
+ } catch (cause) {
213
+ ctx.toast.show({
214
+ variant: "error",
215
+ title: ctx.t("pill.title"),
216
+ description: ctx.t("whispercpp.test.error", {
217
+ error: cause instanceof Error ? cause.message : String(cause),
218
+ }),
219
+ })
220
+ } finally {
221
+ setWhisperCppCheckBusy(false)
222
+ }
223
+ }
224
+
110
225
  const testWhisper = async () => {
111
226
  setWhisperBusy(true)
112
227
  try {
@@ -232,11 +347,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
232
347
  <ui.Select
233
348
  label={ctx.t("settings.engine")}
234
349
  value={enginePrefs.value.mode}
235
- options={[
236
- { value: "windows", label: ctx.t("engine.windows") },
237
- { value: "system", label: ctx.t("engine.system") },
238
- { value: "whisper", label: ctx.t("engine.whisper") },
239
- ]}
350
+ options={engineOptions()}
240
351
  onChange={(value) => patchEngine(value)}
241
352
  />
242
353
  </ui.SettingsRow>
@@ -310,6 +421,62 @@ export function VoiceSettings(props: { speaker: Speaker }) {
310
421
  </ui.SettingsRow>
311
422
  </Show>
312
423
 
424
+ <Show when={enginePrefs.value.mode === "whispercpp"}>
425
+ <ui.SettingsRow
426
+ id="whispercpp-model"
427
+ title={ctx.t("whispercpp.model")}
428
+ description={ctx.t("whispercpp.model.description")}
429
+ >
430
+ <ui.Select
431
+ label={ctx.t("whispercpp.model")}
432
+ value={whisperCppPrefs.value.model}
433
+ options={[
434
+ { value: "base-q5_1", label: "base-q5_1 · 57 MB" },
435
+ { value: "small-q5_1", label: "small-q5_1 · 180 MB" },
436
+ ]}
437
+ onChange={(value) => patchWhisperCpp({ model: value })}
438
+ />
439
+ </ui.SettingsRow>
440
+
441
+ <ui.SettingsRow
442
+ id="whispercpp-language"
443
+ title={ctx.t("whispercpp.language")}
444
+ description={ctx.t("whispercpp.language.description")}
445
+ >
446
+ <ui.Select
447
+ label={ctx.t("whispercpp.language")}
448
+ value={whisperCppPrefs.value.language}
449
+ options={[
450
+ { value: "auto", label: ctx.t("whispercpp.language.auto") },
451
+ { value: "es", label: "Español" },
452
+ { value: "en", label: "English" },
453
+ { value: "pt", label: "Português" },
454
+ ]}
455
+ onChange={(value) => patchWhisperCpp({ language: value })}
456
+ />
457
+ </ui.SettingsRow>
458
+
459
+ <ui.SettingsRow
460
+ id="whispercpp-install"
461
+ title={ctx.t("whispercpp.install")}
462
+ description={ctx.t("whispercpp.install.description")}
463
+ >
464
+ <ui.Button busy={whisperCppBusy()} onClick={() => void installWhisperCpp()}>
465
+ {ctx.t("whispercpp.install.run")}
466
+ </ui.Button>
467
+ </ui.SettingsRow>
468
+
469
+ <ui.SettingsRow
470
+ id="whispercpp-test"
471
+ title={ctx.t("whispercpp.test")}
472
+ description={ctx.t("whispercpp.test.description")}
473
+ >
474
+ <ui.Button busy={whisperCppCheckBusy()} onClick={() => void checkWhisperCpp()}>
475
+ {ctx.t("whispercpp.test.run")}
476
+ </ui.Button>
477
+ </ui.SettingsRow>
478
+ </Show>
479
+
313
480
  <Show when={enginePrefs.value.mode === "system"}>
314
481
  <ui.SettingsRow
315
482
  id="language"
@@ -439,13 +606,15 @@ export function VoiceSettings(props: { speaker: Speaker }) {
439
606
  </ui.SettingsRow>
440
607
  </ui.SettingsSection>
441
608
 
442
- <ui.SettingsSection title={ctx.t("settings.test.section")}>
443
- <ui.SettingsRow id="test" title={ctx.t("settings.test")} description={ctx.t("settings.test.description")}>
444
- <ui.Button busy={busy()} onClick={() => void test()}>
445
- {ctx.t("settings.test.run")}
446
- </ui.Button>
447
- </ui.SettingsRow>
448
- </ui.SettingsSection>
609
+ <Show when={platform() !== "linux"}>
610
+ <ui.SettingsSection title={ctx.t("settings.test.section")}>
611
+ <ui.SettingsRow id="test" title={ctx.t("settings.test")} description={ctx.t("settings.test.description")}>
612
+ <ui.Button busy={busy()} onClick={() => void test()}>
613
+ {ctx.t("settings.test.run")}
614
+ </ui.Button>
615
+ </ui.SettingsRow>
616
+ </ui.SettingsSection>
617
+ </Show>
449
618
  </ui.SettingsLayout>
450
619
  )
451
620
  }
@@ -56,9 +56,28 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
56
56
  const prefs = ctx.stores.prefs
57
57
  const engine = ctx.stores.engine
58
58
  const whisper = ctx.stores.whisper
59
+ const whisperCpp = ctx.stores.whispercpp
59
60
  const [windowsActive, setWindowsActive] = createSignal(false)
60
61
  const [whisperState, setWhisperState] = createSignal<"idle" | "recording" | "transcribing">("idle")
61
62
 
63
+ // The server owns the OS: it decides which engines exist here. On Linux this
64
+ // switches the default to whisper.cpp and drops the Windows-only engines.
65
+ void ctx.client
66
+ .rpc(VoiceRpc)
67
+ .capabilities({})
68
+ .then((value) => {
69
+ const caps = value as { platform: string; engines: string[] }
70
+ if (!caps.engines.includes(engine.value.mode) && caps.engines.length > 0) {
71
+ engine.update((draft) => {
72
+ draft.mode = caps.engines[0] ?? "windows"
73
+ })
74
+ }
75
+ })
76
+ .catch(() => {})
77
+
78
+ const localMode = () =>
79
+ engine.value.mode === "whisper" || engine.value.mode === "whispercpp" ? engine.value.mode : undefined
80
+
62
81
  let targetSession: string | undefined
63
82
  let recording: Recording | undefined
64
83
 
@@ -122,7 +141,9 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
122
141
  })
123
142
  }
124
143
 
125
- const startWhisper = async () => {
144
+ const localError = () => ctx.t(localMode() === "whispercpp" ? "error.whispercpp" : "error.whisper")
145
+
146
+ const startLocal = async () => {
126
147
  if (whisperState() !== "idle") return
127
148
  speaker.cancel()
128
149
 
@@ -133,19 +154,19 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
133
154
  ctx.toast.show({
134
155
  variant: "error",
135
156
  title: ctx.t("pill.title"),
136
- description: cause instanceof Error ? cause.message : ctx.t("error.whisper"),
157
+ description: cause instanceof Error ? cause.message : localError(),
137
158
  })
138
159
  }
139
160
  }
140
161
 
141
- const cancelWhisper = () => {
162
+ const cancelLocal = () => {
142
163
  const current = recording
143
164
  recording = undefined
144
165
  if (current) void current.stop().catch(() => {})
145
166
  setWhisperState("idle")
146
167
  }
147
168
 
148
- const stopWhisper = async () => {
169
+ const stopLocal = async () => {
149
170
  const current = recording
150
171
  recording = undefined
151
172
 
@@ -155,22 +176,34 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
155
176
  }
156
177
 
157
178
  setWhisperState("transcribing")
179
+ const mode = localMode()
158
180
 
159
181
  try {
160
182
  const blob = await current.stop()
161
183
  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 }
184
+ const result = (mode === "whispercpp"
185
+ ? await ctx.client.rpc(VoiceRpc).whisperCppTranscribe({
186
+ audio,
187
+ mime: blob.type,
188
+ language: whisperCpp.value.language,
189
+ model: whisperCpp.value.model,
190
+ })
191
+ : await ctx.client.rpc(VoiceRpc).whisperTranscribe({
192
+ audio,
193
+ mime: blob.type,
194
+ language: whisper.value.language,
195
+ python: whisper.value.python,
196
+ model: whisper.value.model,
197
+ })) as { text: string; language: string }
169
198
 
170
199
  const text = result.text.trim()
171
200
 
172
201
  if (!text) {
173
- ctx.toast.show({ variant: "default", title: ctx.t("pill.title"), description: ctx.t("whisper.empty") })
202
+ ctx.toast.show({
203
+ variant: "default",
204
+ title: ctx.t("pill.title"),
205
+ description: ctx.t(mode === "whispercpp" ? "whispercpp.empty" : "whisper.empty"),
206
+ })
174
207
  return
175
208
  }
176
209
 
@@ -185,7 +218,7 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
185
218
  ctx.toast.show({
186
219
  variant: "error",
187
220
  title: ctx.t("pill.title"),
188
- description: cause instanceof Error ? cause.message : ctx.t("error.whisper"),
221
+ description: cause instanceof Error ? cause.message : localError(),
189
222
  })
190
223
  } finally {
191
224
  setWhisperState("idle")
@@ -193,9 +226,9 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
193
226
  }
194
227
 
195
228
  const toggle = () => {
196
- if (engine.value.mode === "whisper") {
197
- if (whisperState() === "recording") void stopWhisper()
198
- else if (whisperState() === "idle") void startWhisper()
229
+ if (localMode()) {
230
+ if (whisperState() === "recording") void stopLocal()
231
+ else if (whisperState() === "idle") void startLocal()
199
232
  return
200
233
  }
201
234
 
@@ -210,7 +243,7 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
210
243
  }
211
244
 
212
245
  const read = () => {
213
- if (whisperState() === "recording") cancelWhisper()
246
+ if (whisperState() === "recording") cancelLocal()
214
247
 
215
248
  if (speaker.speaking()) {
216
249
  speaker.cancel()
@@ -318,7 +351,9 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
318
351
  }
319
352
  }
320
353
 
321
- if (engine.value.mode === "whisper") {
354
+ if (engine.value.mode === "whisper" || engine.value.mode === "whispercpp") {
355
+ const title = ctx.t(engine.value.mode === "whispercpp" ? "pill.whispercpp" : "pill.whisper")
356
+
322
357
  if (whisperState() === "recording") {
323
358
  return {
324
359
  label: ctx.t("pill.recording"),
@@ -333,14 +368,14 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
333
368
  return {
334
369
  label: ctx.t("pill.transcribing"),
335
370
  icon: "outline-audio",
336
- title: ctx.t("pill.whisper"),
371
+ title,
337
372
  }
338
373
  }
339
374
 
340
375
  return {
341
376
  label: ctx.t("pill.idle"),
342
377
  icon: "outline-wave-audio",
343
- title: ctx.t("pill.whisper"),
378
+ title,
344
379
  run: toggle,
345
380
  }
346
381
  }
@@ -464,6 +499,10 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
464
499
  { id: "whisper-model", title: ctx.t("whisper.model"), keywords: "whisper model modelo" },
465
500
  { id: "whisper-language", title: ctx.t("whisper.language"), keywords: "whisper language idioma" },
466
501
  { id: "whisper-test", title: ctx.t("whisper.test"), keywords: "whisper check comprobar" },
502
+ { id: "whispercpp-model", title: ctx.t("whispercpp.model"), keywords: "whisper.cpp model modelo" },
503
+ { id: "whispercpp-language", title: ctx.t("whispercpp.language"), keywords: "whisper.cpp language idioma" },
504
+ { id: "whispercpp-install", title: ctx.t("whispercpp.install"), keywords: "whisper.cpp download descargar instalar" },
505
+ { id: "whispercpp-test", title: ctx.t("whispercpp.test"), keywords: "whisper.cpp check comprobar" },
467
506
  { id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
468
507
  { id: "skip-code", title: ctx.t("speech.skipCode"), keywords: "read aloud code codigo lectura" },
469
508
  { id: "preview", title: ctx.t("speech.preview"), keywords: "read aloud test voice probar voz" },
package/src/index.ts CHANGED
@@ -2,20 +2,31 @@ import { Plugin } from "@opencode/plugin"
2
2
  import { listEdgeVoices, synthesizeEdge } from "./edge.js"
3
3
  import { VoiceRpc } from "./rpc.js"
4
4
  import { WhisperEngine } from "./whisper/server.js"
5
+ import { installWhisperCpp, WhisperCppEngine } from "./whispercpp/server.js"
5
6
 
6
7
  /**
7
8
  * Server part of the plugin.
8
9
  *
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.
10
+ * It registers the Edge TTS, WhisperX and whisper.cpp RPCs. Running them here
11
+ * (instead of in the Electron main process) lets their dependencies resolve
12
+ * from the plugin's own node_modules.
12
13
  */
13
14
  export default Plugin.define({
14
15
  id: "voice-dictation",
15
16
  async setup(ctx) {
16
17
  const whisper = new WhisperEngine()
18
+ const whisperCpp = new WhisperCppEngine()
17
19
 
18
20
  await ctx.rpc.register(VoiceRpc, {
21
+ capabilities: async () => ({
22
+ platform: process.platform,
23
+ engines:
24
+ process.platform === "linux"
25
+ ? ["whispercpp"]
26
+ : process.platform === "win32"
27
+ ? ["windows", "system", "whisper"]
28
+ : [],
29
+ }),
19
30
  edgeVoices: async () => listEdgeVoices(),
20
31
  edgeSpeak: async (input) => {
21
32
  const { text, voice, rate } = input as { text: string; voice: string; rate: number }
@@ -42,9 +53,28 @@ export default Plugin.define({
42
53
  }
43
54
  }
44
55
  },
56
+ whisperCppInstall: async (input) => {
57
+ const { model } = input as { model: string }
58
+ return installWhisperCpp(model)
59
+ },
60
+ whisperCppStatus: async (input) => {
61
+ const { model } = input as { model: string }
62
+ return whisperCpp.status(model)
63
+ },
64
+ whisperCppTranscribe: async (input) => {
65
+ return whisperCpp.transcribe(input as {
66
+ audio: string
67
+ mime: string
68
+ language: string
69
+ model: string
70
+ })
71
+ },
45
72
  })
46
73
 
47
- return () => whisper.stop()
74
+ return () => {
75
+ whisper.stop()
76
+ whisperCpp.stop()
77
+ }
48
78
  },
49
79
  })
50
80
 
package/src/rpc.ts CHANGED
@@ -85,6 +85,86 @@ export const VoiceRpc = {
85
85
  additionalProperties: false,
86
86
  },
87
87
  },
88
+ /**
89
+ * Tells the window which OS the server runs on and which dictation engines
90
+ * exist there. The window uses it to pick the default engine and to hide
91
+ * engines that do not apply (Win+H on Linux, whisper.cpp on Windows).
92
+ */
93
+ capabilities: {
94
+ input: { type: "object", additionalProperties: false },
95
+ output: {
96
+ type: "object",
97
+ properties: {
98
+ platform: { type: "string" },
99
+ engines: { type: "array", items: { type: "string" } },
100
+ },
101
+ required: ["platform", "engines"],
102
+ additionalProperties: false,
103
+ },
104
+ },
105
+ whisperCppInstall: {
106
+ input: {
107
+ type: "object",
108
+ properties: {
109
+ model: { type: "string" },
110
+ },
111
+ required: ["model"],
112
+ additionalProperties: false,
113
+ },
114
+ output: {
115
+ type: "object",
116
+ properties: {
117
+ ok: { type: "boolean" },
118
+ model: { type: "string" },
119
+ error: { type: "string" },
120
+ },
121
+ required: ["ok", "model", "error"],
122
+ additionalProperties: false,
123
+ },
124
+ },
125
+ whisperCppStatus: {
126
+ input: {
127
+ type: "object",
128
+ properties: {
129
+ model: { type: "string" },
130
+ },
131
+ required: ["model"],
132
+ additionalProperties: false,
133
+ },
134
+ output: {
135
+ type: "object",
136
+ properties: {
137
+ installed: { type: "boolean" },
138
+ ready: { type: "boolean" },
139
+ model: { type: "string" },
140
+ error: { type: "string" },
141
+ },
142
+ required: ["installed", "ready", "model", "error"],
143
+ additionalProperties: false,
144
+ },
145
+ },
146
+ whisperCppTranscribe: {
147
+ input: {
148
+ type: "object",
149
+ properties: {
150
+ audio: { type: "string" },
151
+ mime: { type: "string" },
152
+ language: { type: "string" },
153
+ model: { type: "string" },
154
+ },
155
+ required: ["audio", "mime", "language", "model"],
156
+ additionalProperties: false,
157
+ },
158
+ output: {
159
+ type: "object",
160
+ properties: {
161
+ text: { type: "string" },
162
+ language: { type: "string" },
163
+ },
164
+ required: ["text", "language"],
165
+ additionalProperties: false,
166
+ },
167
+ },
88
168
  },
89
169
  events: {},
90
170
  } as const satisfies Rpc.PortableDefinition