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.
@@ -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
+ }