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.
- package/README.md +144 -22
- package/dist/gui-experimental.cjs +8 -8
- package/docs/whispercpp.md +143 -0
- package/docs/whisperx.md +183 -0
- package/package.json +6 -3
- package/src/gui/definition.ts +69 -11
- package/src/gui/settings.tsx +186 -17
- package/src/gui/window.tsx +59 -20
- package/src/index.ts +34 -4
- package/src/rpc.ts +80 -0
- package/src/whispercpp/server.ts +277 -0
|
@@ -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
|
+
}
|