opencode-voice-dictation 0.4.2 → 0.6.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/dist/gui-experimental.cjs +8 -8
- package/package.json +4 -4
- package/src/{gui/engines/edge-tts.ts → edge.ts} +9 -30
- package/src/gui/contract.ts +0 -18
- package/src/gui/definition.ts +50 -0
- package/src/gui/main.ts +0 -9
- package/src/gui/recorder.ts +51 -0
- package/src/gui/settings.tsx +105 -0
- package/src/gui/speak.ts +10 -2
- package/src/gui/window.tsx +114 -0
- package/src/index.ts +43 -12
- package/src/rpc.ts +90 -0
- package/src/whisper/server.ts +261 -0
- package/src/whisper/worker.py +53 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { dirname, join } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PYTHON = "~/peopleby/voice_to_text/api_voice/demo/whisperx-env/bin/python"
|
|
9
|
+
|
|
10
|
+
export interface WhisperInput {
|
|
11
|
+
audio: string
|
|
12
|
+
mime: string
|
|
13
|
+
language: string
|
|
14
|
+
python: string
|
|
15
|
+
model: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WhisperResult {
|
|
19
|
+
text: string
|
|
20
|
+
language: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WhisperStatus {
|
|
24
|
+
ready: boolean
|
|
25
|
+
model: string
|
|
26
|
+
device: string
|
|
27
|
+
error: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function workerPath(): string {
|
|
31
|
+
return join(dirname(fileURLToPath(import.meta.url)), "worker.py")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Converts a Windows path to the WSL mount path, e.g. C:\a\b -> /mnt/c/a/b. */
|
|
35
|
+
function toWslPath(path: string): string {
|
|
36
|
+
if (process.platform !== "win32") return path
|
|
37
|
+
|
|
38
|
+
return path
|
|
39
|
+
.replace(/^([A-Za-z]):[\\/]/, (_, drive: string) => `/mnt/${drive.toLowerCase()}/`)
|
|
40
|
+
.replace(/\\/g, "/")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function spawnWorker(python: string, model: string): ChildProcess {
|
|
44
|
+
const env = { ...process.env, WHISPER_MODEL: model, WHISPER_DEVICE: "cuda" }
|
|
45
|
+
const command = `"${python}" "${toWslPath(workerPath())}"`
|
|
46
|
+
|
|
47
|
+
if (process.platform === "win32") {
|
|
48
|
+
return spawn("wsl.exe", ["-d", "Ubuntu-22.04", "-e", "bash", "-lc", command], {
|
|
49
|
+
env,
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return spawn("bash", ["-lc", command], {
|
|
56
|
+
env,
|
|
57
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Waiter {
|
|
62
|
+
resolve: (value: WhisperResult) => void
|
|
63
|
+
reject: (error: Error) => void
|
|
64
|
+
timer: NodeJS.Timeout
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class WhisperEngine {
|
|
68
|
+
private child: ChildProcess | undefined
|
|
69
|
+
private readyPromise: Promise<void> | undefined
|
|
70
|
+
private readyResolve: (() => void) | undefined
|
|
71
|
+
private readyReject: ((error: Error) => void) | undefined
|
|
72
|
+
private buffer = ""
|
|
73
|
+
private stderr = ""
|
|
74
|
+
private waiters = new Map<string, Waiter>()
|
|
75
|
+
private status: WhisperStatus = {
|
|
76
|
+
ready: false,
|
|
77
|
+
model: "",
|
|
78
|
+
device: "cuda",
|
|
79
|
+
error: "",
|
|
80
|
+
}
|
|
81
|
+
private python = ""
|
|
82
|
+
private model = ""
|
|
83
|
+
|
|
84
|
+
private start(python: string, model: string): Promise<void> {
|
|
85
|
+
this.stop()
|
|
86
|
+
this.python = python || DEFAULT_PYTHON
|
|
87
|
+
this.model = model || "large-v3-turbo"
|
|
88
|
+
this.status = { ready: false, model: this.model, device: "cuda", error: "" }
|
|
89
|
+
this.stderr = ""
|
|
90
|
+
|
|
91
|
+
const child = spawnWorker(this.python, this.model)
|
|
92
|
+
this.child = child
|
|
93
|
+
|
|
94
|
+
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
95
|
+
this.readyResolve = resolve
|
|
96
|
+
this.readyReject = reject
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
this.status.error = "Whisper took too long to load the model"
|
|
101
|
+
this.readyReject?.(new Error(this.status.error))
|
|
102
|
+
this.stop()
|
|
103
|
+
}, 180000)
|
|
104
|
+
|
|
105
|
+
child.stdout?.setEncoding("utf8")
|
|
106
|
+
child.stdout?.on("data", (chunk: string) => this.onStdout(chunk, timer))
|
|
107
|
+
|
|
108
|
+
child.stderr?.setEncoding("utf8")
|
|
109
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
110
|
+
this.stderr = (this.stderr + chunk).slice(-3000)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
child.on("error", (error) => {
|
|
114
|
+
clearTimeout(timer)
|
|
115
|
+
this.status.error = error.message
|
|
116
|
+
this.readyReject?.(error)
|
|
117
|
+
this.failWaiters(error)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
child.on("close", (code) => {
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
if (this.status.ready) {
|
|
123
|
+
this.status = {
|
|
124
|
+
ready: false,
|
|
125
|
+
model: this.model,
|
|
126
|
+
device: "cuda",
|
|
127
|
+
error: this.stderr.trim() || `Whisper worker exited with code ${code ?? "unknown"}`,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.failWaiters(new Error(this.status.error || "Whisper worker stopped"))
|
|
131
|
+
this.child = undefined
|
|
132
|
+
this.readyPromise = undefined
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return this.readyPromise
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private onStdout(chunk: string, timer: NodeJS.Timeout) {
|
|
139
|
+
this.buffer += chunk
|
|
140
|
+
|
|
141
|
+
let index = this.buffer.indexOf("\n")
|
|
142
|
+
while (index >= 0) {
|
|
143
|
+
const line = this.buffer.slice(0, index).trim()
|
|
144
|
+
this.buffer = this.buffer.slice(index + 1)
|
|
145
|
+
|
|
146
|
+
if (line) {
|
|
147
|
+
try {
|
|
148
|
+
const event = JSON.parse(line) as {
|
|
149
|
+
type?: string
|
|
150
|
+
id?: string
|
|
151
|
+
text?: string
|
|
152
|
+
language?: string
|
|
153
|
+
model?: string
|
|
154
|
+
detail?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.type === "ready") {
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
this.status = {
|
|
160
|
+
ready: true,
|
|
161
|
+
model: event.model ?? this.model,
|
|
162
|
+
device: "cuda",
|
|
163
|
+
error: "",
|
|
164
|
+
}
|
|
165
|
+
this.readyResolve?.()
|
|
166
|
+
} else if (event.type === "result" && event.id) {
|
|
167
|
+
const waiter = this.waiters.get(event.id)
|
|
168
|
+
if (waiter) {
|
|
169
|
+
clearTimeout(waiter.timer)
|
|
170
|
+
this.waiters.delete(event.id)
|
|
171
|
+
waiter.resolve({ text: event.text ?? "", language: event.language ?? "" })
|
|
172
|
+
}
|
|
173
|
+
} else if (event.type === "error") {
|
|
174
|
+
const error = new Error(event.detail || "Whisper failed")
|
|
175
|
+
if (event.id) {
|
|
176
|
+
const waiter = this.waiters.get(event.id)
|
|
177
|
+
if (waiter) {
|
|
178
|
+
clearTimeout(waiter.timer)
|
|
179
|
+
this.waiters.delete(event.id)
|
|
180
|
+
waiter.reject(error)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
this.status.error = error.message
|
|
184
|
+
this.readyReject?.(error)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Ignore non-JSON output such as warnings.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
index = this.buffer.indexOf("\n")
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private failWaiters(error: Error) {
|
|
197
|
+
for (const waiter of this.waiters.values()) {
|
|
198
|
+
clearTimeout(waiter.timer)
|
|
199
|
+
waiter.reject(error)
|
|
200
|
+
}
|
|
201
|
+
this.waiters.clear()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async transcribe(input: WhisperInput): Promise<WhisperResult> {
|
|
205
|
+
if (!this.child || !this.readyPromise || !this.status.ready) {
|
|
206
|
+
await this.start(input.python || DEFAULT_PYTHON, input.model || "large-v3-turbo")
|
|
207
|
+
}
|
|
208
|
+
await this.readyPromise
|
|
209
|
+
|
|
210
|
+
const dir = join(tmpdir(), "opencode-voice-dictation")
|
|
211
|
+
mkdirSync(dir, { recursive: true })
|
|
212
|
+
const extension = input.mime.includes("webm") ? "webm" : "wav"
|
|
213
|
+
const file = join(dir, `whisper-${randomUUID()}.${extension}`)
|
|
214
|
+
writeFileSync(file, Buffer.from(input.audio, "base64"))
|
|
215
|
+
|
|
216
|
+
const id = randomUUID()
|
|
217
|
+
const path = toWslPath(file)
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
return await new Promise<WhisperResult>((resolve, reject) => {
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
this.waiters.delete(id)
|
|
223
|
+
reject(new Error("Whisper transcription timed out"))
|
|
224
|
+
}, 120000)
|
|
225
|
+
|
|
226
|
+
this.waiters.set(id, { resolve, reject, timer })
|
|
227
|
+
this.child?.stdin?.write(`${JSON.stringify({ id, path, language: input.language })}\n`)
|
|
228
|
+
})
|
|
229
|
+
} finally {
|
|
230
|
+
try {
|
|
231
|
+
rmSync(file, { force: true })
|
|
232
|
+
} catch {
|
|
233
|
+
// Best effort.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async warm(python: string, model: string): Promise<WhisperStatus> {
|
|
239
|
+
if (!this.status.ready) {
|
|
240
|
+
await this.start(python || DEFAULT_PYTHON, model || "large-v3-turbo")
|
|
241
|
+
}
|
|
242
|
+
return this.statusOf()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
statusOf(): WhisperStatus {
|
|
246
|
+
return { ...this.status }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
stop() {
|
|
250
|
+
this.failWaiters(new Error("Whisper worker stopped"))
|
|
251
|
+
if (this.child) {
|
|
252
|
+
const child = this.child
|
|
253
|
+
this.child = undefined
|
|
254
|
+
this.readyPromise = undefined
|
|
255
|
+
child.kill()
|
|
256
|
+
if (process.platform === "win32" && child.pid) {
|
|
257
|
+
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import whisperx
|
|
6
|
+
|
|
7
|
+
MODEL = os.environ.get("WHISPER_MODEL", "large-v3-turbo")
|
|
8
|
+
DEVICE = os.environ.get("WHISPER_DEVICE", "cuda")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(payload):
|
|
12
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
13
|
+
sys.stdout.flush()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
model = whisperx.load_model(
|
|
18
|
+
MODEL,
|
|
19
|
+
DEVICE,
|
|
20
|
+
compute_type="float16",
|
|
21
|
+
vad_options={"vad_onset": 0.4, "vad_offset": 0.3},
|
|
22
|
+
)
|
|
23
|
+
except Exception as exc: # noqa: BLE001
|
|
24
|
+
emit({"type": "error", "code": "MODEL_LOAD", "detail": str(exc)})
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
emit({"type": "ready", "model": MODEL, "device": DEVICE})
|
|
28
|
+
|
|
29
|
+
for raw in sys.stdin:
|
|
30
|
+
line = raw.strip()
|
|
31
|
+
if not line:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
request_id = None
|
|
35
|
+
try:
|
|
36
|
+
request = json.loads(line)
|
|
37
|
+
request_id = request.get("id")
|
|
38
|
+
language = request.get("language") or None
|
|
39
|
+
audio = whisperx.load_audio(request["path"])
|
|
40
|
+
result = model.transcribe(audio, language=language)
|
|
41
|
+
text = " ".join(
|
|
42
|
+
segment.get("text", "").strip() for segment in result.get("segments", [])
|
|
43
|
+
).strip()
|
|
44
|
+
emit(
|
|
45
|
+
{
|
|
46
|
+
"id": request_id,
|
|
47
|
+
"type": "result",
|
|
48
|
+
"text": text,
|
|
49
|
+
"language": result.get("language", ""),
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
except Exception as exc: # noqa: BLE001
|
|
53
|
+
emit({"id": request_id, "type": "error", "detail": str(exc)})
|