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