opencode-voice-dictation 0.6.2 → 0.7.1
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 +79 -31
- package/dist/gui-experimental.cjs +8 -8
- package/docs/whispercpp.md +143 -0
- package/docs/whisperx.md +3 -2
- package/package.json +4 -2
- 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
package/src/gui/window.tsx
CHANGED
|
@@ -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
|
|
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 :
|
|
157
|
+
description: cause instanceof Error ? cause.message : localError(),
|
|
137
158
|
})
|
|
138
159
|
}
|
|
139
160
|
}
|
|
140
161
|
|
|
141
|
-
const
|
|
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
|
|
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 = (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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({
|
|
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 :
|
|
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 (
|
|
197
|
-
if (whisperState() === "recording") void
|
|
198
|
-
else if (whisperState() === "idle") void
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
10
|
-
* in the Electron main process) lets their dependencies resolve
|
|
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 () =>
|
|
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
|
+
}
|