opencode-voice-dictation 0.6.2 → 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.
@@ -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
@@ -0,0 +1,277 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { randomUUID } from "node:crypto"
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4
+ import { connect } from "node:net"
5
+ import { homedir, tmpdir } from "node:os"
6
+ import { join } from "node:path"
7
+
8
+ const RELEASE = "v1.9.2"
9
+ const X64_TARBALL = `https://github.com/ggml-org/whisper.cpp/releases/download/${RELEASE}/whisper-bin-ubuntu-x64.tar.gz`
10
+ const ARM64_TARBALL = `https://github.com/ggml-org/whisper.cpp/releases/download/${RELEASE}/whisper-bin-ubuntu-arm64.tar.gz`
11
+ const MODEL_BASE = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
12
+
13
+ const MODELS = {
14
+ "base-q5_1": "ggml-base-q5_1.bin",
15
+ "small-q5_1": "ggml-small-q5_1.bin",
16
+ } as const
17
+
18
+ type ModelKey = keyof typeof MODELS
19
+
20
+ export interface WhisperCppStatus {
21
+ installed: boolean
22
+ ready: boolean
23
+ model: string
24
+ error: string
25
+ }
26
+
27
+ export interface WhisperCppResult {
28
+ text: string
29
+ language: string
30
+ }
31
+
32
+ function rootDir(): string {
33
+ return join(homedir(), ".local", "share", "opencode-voice-dictation", "whisper.cpp")
34
+ }
35
+
36
+ function packageDir(): string {
37
+ return join(rootDir(), process.arch === "arm64" ? "whisper-bin-ubuntu-arm64" : "whisper-bin-ubuntu-x64")
38
+ }
39
+
40
+ function binaryPath(): string {
41
+ return join(packageDir(), "whisper-server")
42
+ }
43
+
44
+ function modelFile(model: string): ModelKey {
45
+ return model in MODELS ? (model as ModelKey) : "base-q5_1"
46
+ }
47
+
48
+ function modelPath(model: string): string {
49
+ return join(rootDir(), MODELS[modelFile(model)])
50
+ }
51
+
52
+ async function download(url: string, destination: string): Promise<void> {
53
+ const response = await fetch(url)
54
+
55
+ if (!response.ok) {
56
+ throw new Error(`Download failed (${response.status}): ${url}`)
57
+ }
58
+
59
+ mkdirSync(rootDir(), { recursive: true })
60
+ writeFileSync(destination, Buffer.from(await response.arrayBuffer()))
61
+ }
62
+
63
+ function run(command: string, args: string[]): Promise<string> {
64
+ return new Promise((resolve, reject) => {
65
+ const child = spawn(command, args, { windowsHide: true })
66
+ let stderr = ""
67
+
68
+ child.stderr?.setEncoding("utf8")
69
+ child.stderr?.on("data", (chunk: string) => {
70
+ stderr = (stderr + chunk).slice(-2000)
71
+ })
72
+
73
+ child.on("error", reject)
74
+ child.on("close", (code) => {
75
+ if (code === 0) resolve(stderr)
76
+ else reject(new Error(stderr.trim() || `${command} exited with code ${code ?? "unknown"}`))
77
+ })
78
+ })
79
+ }
80
+
81
+ export async function installWhisperCpp(model: string): Promise<{ ok: boolean; model: string; error: string }> {
82
+ if (process.platform !== "linux") {
83
+ return { ok: false, model: modelFile(model), error: "Whisper.cpp native is only implemented for Linux" }
84
+ }
85
+
86
+ try {
87
+ mkdirSync(rootDir(), { recursive: true })
88
+
89
+ if (!existsSync(binaryPath())) {
90
+ const tarball = join(rootDir(), "whisper-bin.tar.gz")
91
+ await download(process.arch === "arm64" ? ARM64_TARBALL : X64_TARBALL, tarball)
92
+ await run("tar", ["-xzf", tarball, "-C", rootDir()])
93
+ rmSync(tarball, { force: true })
94
+ chmodSync(binaryPath(), 0o755)
95
+ }
96
+
97
+ const file = modelFile(model)
98
+ if (!existsSync(modelPath(file))) {
99
+ await download(`${MODEL_BASE}/${MODELS[file]}`, modelPath(file))
100
+ }
101
+
102
+ return { ok: true, model: file, error: "" }
103
+ } catch (cause) {
104
+ return { ok: false, model: modelFile(model), error: cause instanceof Error ? cause.message : String(cause) }
105
+ }
106
+ }
107
+
108
+ interface StartState {
109
+ child: ChildProcess
110
+ port: number
111
+ model: string
112
+ language: string
113
+ stderr: string
114
+ }
115
+
116
+ export class WhisperCppEngine {
117
+ private state: StartState | undefined
118
+ private starting: Promise<number> | undefined
119
+ private error = ""
120
+
121
+ private waitForPort(port: number, timeout: number): Promise<number> {
122
+ const started = Date.now()
123
+
124
+ return new Promise((resolve, reject) => {
125
+ const attempt = () => {
126
+ const socket = connect(port, "127.0.0.1")
127
+
128
+ socket.once("connect", () => {
129
+ socket.destroy()
130
+ resolve(port)
131
+ })
132
+
133
+ socket.once("error", () => {
134
+ socket.destroy()
135
+ if (Date.now() - started > timeout) {
136
+ reject(new Error("whisper-server did not start in time"))
137
+ return
138
+ }
139
+ setTimeout(attempt, 250)
140
+ })
141
+ }
142
+
143
+ attempt()
144
+ })
145
+ }
146
+
147
+ private async ensure(model: string, language: string): Promise<number> {
148
+ const file = modelFile(model)
149
+ const lang = language || "auto"
150
+
151
+ if (this.state && this.state.model === file && this.state.language === lang) {
152
+ return this.state.port
153
+ }
154
+
155
+ if (this.starting) return this.starting
156
+
157
+ this.stop()
158
+ this.error = ""
159
+
160
+ for (const path of [binaryPath(), modelPath(file)]) {
161
+ if (!existsSync(path)) {
162
+ throw new Error("Whisper.cpp is not installed. Open Settings and download it first.")
163
+ }
164
+ }
165
+
166
+ const port = 18081 + Math.floor(Math.random() * 500)
167
+ const child = spawn(
168
+ binaryPath(),
169
+ ["-m", modelPath(file), "-l", lang, "-t", "4", "--host", "127.0.0.1", "--port", String(port)],
170
+ {
171
+ cwd: packageDir(),
172
+ env: {
173
+ ...process.env,
174
+ LD_LIBRARY_PATH: [packageDir(), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":"),
175
+ },
176
+ windowsHide: true,
177
+ stdio: ["ignore", "ignore", "pipe"],
178
+ },
179
+ )
180
+
181
+ const state: StartState = { child, port, model: file, language: lang, stderr: "" }
182
+ this.state = state
183
+
184
+ child.stderr?.setEncoding("utf8")
185
+ child.stderr?.on("data", (chunk: string) => {
186
+ state.stderr = (state.stderr + chunk).slice(-2000)
187
+ })
188
+
189
+ const exited = new Promise<never>((_, reject) => {
190
+ child.once("error", (cause) => reject(cause))
191
+ child.once("close", (code) => {
192
+ if (this.state === state) this.state = undefined
193
+ reject(new Error(state.stderr.trim() || `whisper-server exited with code ${code ?? "unknown"}`))
194
+ })
195
+ })
196
+ exited.catch(() => {})
197
+
198
+ this.starting = Promise.race([this.waitForPort(port, 120000), exited])
199
+ .then((value) => {
200
+ this.starting = undefined
201
+ return value
202
+ })
203
+ .catch((cause) => {
204
+ this.starting = undefined
205
+ this.error = cause instanceof Error ? cause.message : String(cause)
206
+ this.stop()
207
+ throw cause
208
+ })
209
+
210
+ return this.starting
211
+ }
212
+
213
+ async transcribe(input: {
214
+ audio: string
215
+ mime: string
216
+ language: string
217
+ model: string
218
+ }): Promise<WhisperCppResult> {
219
+ const port = await this.ensure(input.model, input.language)
220
+
221
+ const dir = join(tmpdir(), "opencode-voice-dictation")
222
+ mkdirSync(dir, { recursive: true })
223
+
224
+ const extension = input.mime.includes("webm") ? "webm" : "wav"
225
+ const source = join(dir, `whispercpp-${randomUUID()}.${extension}`)
226
+ const wav = `${source}.wav`
227
+
228
+ writeFileSync(source, Buffer.from(input.audio, "base64"))
229
+
230
+ try {
231
+ await run("ffmpeg", ["-y", "-loglevel", "error", "-i", source, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wav])
232
+
233
+ const form = new FormData()
234
+ form.append("file", new Blob([new Uint8Array(readFileSync(wav))], { type: "audio/wav" }), "audio.wav")
235
+
236
+ const response = await fetch(`http://127.0.0.1:${port}/inference`, { method: "POST", body: form })
237
+
238
+ if (!response.ok) {
239
+ throw new Error(`whisper-server HTTP ${response.status}`)
240
+ }
241
+
242
+ const data = (await response.json()) as { text?: string }
243
+
244
+ return {
245
+ text: (data.text ?? "").trim(),
246
+ language: input.language || "",
247
+ }
248
+ } finally {
249
+ rmSync(source, { force: true })
250
+ rmSync(wav, { force: true })
251
+ }
252
+ }
253
+
254
+ status(model: string): WhisperCppStatus {
255
+ const file = modelFile(model)
256
+ const installed = existsSync(binaryPath()) && existsSync(modelPath(file))
257
+
258
+ return {
259
+ installed,
260
+ ready: this.state !== undefined && this.state.model === file,
261
+ model: file,
262
+ error: this.error,
263
+ }
264
+ }
265
+
266
+ stop() {
267
+ if (this.state) {
268
+ const child = this.state.child
269
+ this.state = undefined
270
+ child.kill()
271
+ if (child.pid) {
272
+ spawn("pkill", ["-P", String(child.pid)], { windowsHide: true, stdio: "ignore" })
273
+ }
274
+ }
275
+ this.starting = undefined
276
+ }
277
+ }