@tiens.nguyen/gonext-local-worker 1.0.99 → 1.0.101

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.
@@ -1309,6 +1309,171 @@ async function runOcrJob(job) {
1309
1309
  }
1310
1310
  }
1311
1311
 
1312
+ const TRANSCRIBE_TIMEOUT_MS =
1313
+ Number(process.env.GONEXT_TRANSCRIBE_TIMEOUT_MS ?? "300000") || 300000;
1314
+
1315
+ function resolveWorkerPython() {
1316
+ return (
1317
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
1318
+ "python3"
1319
+ );
1320
+ }
1321
+
1322
+ /** Spawn a Python script, write `input` to stdin, capture stdout/stderr. */
1323
+ function runPythonCapture(python, args, input, timeoutMs) {
1324
+ return new Promise((resolve) => {
1325
+ const child = spawn(python, args, { stdio: ["pipe", "pipe", "pipe"] });
1326
+ let stdout = "";
1327
+ let stderr = "";
1328
+ let settled = false;
1329
+ const timer = setTimeout(() => {
1330
+ if (settled) return;
1331
+ settled = true;
1332
+ try {
1333
+ child.kill("SIGKILL");
1334
+ } catch {
1335
+ /* ignore */
1336
+ }
1337
+ resolve({ code: null, stdout, stderr, timedOut: true });
1338
+ }, timeoutMs);
1339
+ child.stdout.on("data", (d) => {
1340
+ stdout += d.toString();
1341
+ });
1342
+ child.stderr.on("data", (d) => {
1343
+ stderr += d.toString();
1344
+ });
1345
+ child.on("error", (e) => {
1346
+ if (settled) return;
1347
+ settled = true;
1348
+ clearTimeout(timer);
1349
+ resolve({ code: null, stdout, stderr: stderr + String(e), timedOut: false });
1350
+ });
1351
+ child.on("close", (code) => {
1352
+ if (settled) return;
1353
+ settled = true;
1354
+ clearTimeout(timer);
1355
+ resolve({ code, stdout, stderr, timedOut: false });
1356
+ });
1357
+ try {
1358
+ child.stdin.write(input);
1359
+ child.stdin.end();
1360
+ } catch {
1361
+ /* the error/close handler will settle */
1362
+ }
1363
+ });
1364
+ }
1365
+
1366
+ async function runTranscribeJob(job) {
1367
+ const { jobId, payload } = job;
1368
+ const start = Date.now();
1369
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1370
+ method: "PATCH",
1371
+ body: JSON.stringify({ jobStatus: "running" }),
1372
+ });
1373
+ await ensureWorkerOk(runRes, `mark running transcribe jobId=${jobId}`);
1374
+ const tempDir = await mkdtemp(join(tmpdir(), "gonext-transcribe-"));
1375
+ try {
1376
+ if (!payload || typeof payload !== "object") {
1377
+ throw new Error("Invalid transcribe payload.");
1378
+ }
1379
+ const audio = payload.audio;
1380
+ const mimeType = typeof audio?.mimeType === "string" ? audio.mimeType : "";
1381
+ const s3Object = audio && typeof audio === "object" ? audio.s3Object : undefined;
1382
+ const s3GetUrl =
1383
+ s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
1384
+ const model =
1385
+ typeof payload.model === "string" && payload.model.trim()
1386
+ ? payload.model.trim()
1387
+ : "whisper-large-v3-turbo";
1388
+ const language =
1389
+ typeof payload.language === "string" ? payload.language.trim() : "";
1390
+ if (!mimeType.startsWith("audio/")) {
1391
+ throw new Error("Transcribe job attachment must be audio.");
1392
+ }
1393
+ if (!s3GetUrl) {
1394
+ throw new Error("Transcribe job is missing the audio download URL.");
1395
+ }
1396
+ const dlRes = await fetch(s3GetUrl, { method: "GET" });
1397
+ if (!dlRes.ok) {
1398
+ throw new Error(`Failed to download audio from S3 (${dlRes.status}).`);
1399
+ }
1400
+ const bytes = Buffer.from(await dlRes.arrayBuffer());
1401
+ if (!bytes.length) {
1402
+ throw new Error("Downloaded audio is empty.");
1403
+ }
1404
+ const audioPath = join(tempDir, "input.wav");
1405
+ await writeFile(audioPath, bytes);
1406
+
1407
+ const python = resolveWorkerPython();
1408
+ const scriptPath = join(WORKER_DIR, "gonext_transcribe.py");
1409
+ const { code, stdout, stderr, timedOut } = await runPythonCapture(
1410
+ python,
1411
+ [scriptPath],
1412
+ JSON.stringify({ audioPath, model, language: language || undefined }),
1413
+ TRANSCRIBE_TIMEOUT_MS
1414
+ );
1415
+ if (timedOut) {
1416
+ throw new Error("Transcription timed out.");
1417
+ }
1418
+ if (code !== 0) {
1419
+ const reason = String(stderr ?? "").split("\n")[0]?.trim() || "error";
1420
+ const hint =
1421
+ reason === "lib-missing"
1422
+ ? "Whisper library not installed. Run: pip install mlx-whisper"
1423
+ : reason === "model-missing"
1424
+ ? `Whisper model not downloaded. Run: hf download mlx-community/${model}`
1425
+ : reason === "audio-error"
1426
+ ? "Could not decode the recorded audio."
1427
+ : "Transcription failed.";
1428
+ throw new Error(`${hint} (${compactPreview(stderr)})`);
1429
+ }
1430
+ let text = "";
1431
+ try {
1432
+ const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
1433
+ text = typeof parsed.text === "string" ? parsed.text.trim() : "";
1434
+ } catch {
1435
+ throw new Error(`Transcription returned malformed output: ${compactPreview(stdout)}`);
1436
+ }
1437
+ if (!text) {
1438
+ throw new Error("Transcription returned empty text.");
1439
+ }
1440
+ const totalTimeSeconds = (Date.now() - start) / 1000;
1441
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1442
+ method: "PATCH",
1443
+ body: JSON.stringify({
1444
+ jobStatus: "completed",
1445
+ resultText: text,
1446
+ tokenCount: Math.max(1, Math.ceil(text.length / 4)),
1447
+ totalTimeSeconds,
1448
+ }),
1449
+ });
1450
+ await ensureWorkerOk(doneRes, `complete transcribe jobId=${jobId}`);
1451
+ console.log(
1452
+ `[gonext-worker] completed transcribe ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${model}`
1453
+ );
1454
+ } catch (e) {
1455
+ const message = e instanceof Error ? e.message : String(e);
1456
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1457
+ method: "PATCH",
1458
+ body: JSON.stringify({
1459
+ jobStatus: "failed",
1460
+ errorMessage: message,
1461
+ totalTimeSeconds: (Date.now() - start) / 1000,
1462
+ }),
1463
+ });
1464
+ if (!failRes.ok) {
1465
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
1466
+ console.error(
1467
+ `[gonext-worker] transcribe fail PATCH also failed ${failRes.status} jobId=${jobId}` +
1468
+ (snippet ? ` response=${snippet}` : "")
1469
+ );
1470
+ }
1471
+ console.error(`[gonext-worker] failed transcribe ${jobId}:`, message);
1472
+ } finally {
1473
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
1474
+ }
1475
+ }
1476
+
1312
1477
  async function runAgentChatJob(job) {
1313
1478
  const { jobId, payload } = job;
1314
1479
  const start = Date.now();
@@ -1618,6 +1783,55 @@ async function checkMlxLmNativeImport() {
1618
1783
  };
1619
1784
  }
1620
1785
 
1786
+ // Probe whether voice transcription is ready (Audio support, task #21):
1787
+ // mlx-whisper importable AND the requested model present in the HF hub cache
1788
+ // (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). Returns
1789
+ // { available, model, reason } where reason is "ok" | "lib-missing" | "model-missing".
1790
+ async function checkWhisperAvailability(model) {
1791
+ const preferred =
1792
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
1793
+ "python3";
1794
+ const repoName = `models--mlx-community--${model}`;
1795
+ const code = [
1796
+ "import os, sys, json, glob",
1797
+ "model = sys.argv[1] if len(sys.argv) > 1 else 'whisper-large-v3-turbo'",
1798
+ "try:",
1799
+ " import mlx_whisper # noqa: F401",
1800
+ "except Exception:",
1801
+ " print(json.dumps({'available': False, 'reason': 'lib-missing'})); sys.exit(0)",
1802
+ "home = os.environ.get('HF_HOME') or os.path.join(os.path.expanduser('~'), '.cache', 'huggingface')",
1803
+ "hub = os.environ.get('HF_HUB_CACHE') or os.path.join(home, 'hub')",
1804
+ `repo = os.path.join(hub, ${JSON.stringify(repoName)})`,
1805
+ "snaps = glob.glob(os.path.join(repo, 'snapshots', '*'))",
1806
+ "ok = False",
1807
+ "for s in snaps:",
1808
+ " if glob.glob(os.path.join(s, '*.safetensors')) or glob.glob(os.path.join(s, '*.npz')) or os.path.exists(os.path.join(s, 'weights.npz')):",
1809
+ " ok = True; break",
1810
+ "print(json.dumps({'available': ok, 'reason': 'ok' if ok else 'model-missing', 'cache': hub}))",
1811
+ ].join("\n");
1812
+ const candidates = [preferred];
1813
+ if (preferred === "python3") candidates.push("python");
1814
+ for (const exe of [...new Set(candidates)]) {
1815
+ try {
1816
+ const { stdout } = await execFile(exe, ["-c", code, model], {
1817
+ timeout: 15000,
1818
+ maxBuffer: 65536,
1819
+ windowsHide: true,
1820
+ });
1821
+ const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
1822
+ return {
1823
+ available: parsed.available === true,
1824
+ model,
1825
+ reason:
1826
+ typeof parsed.reason === "string" ? parsed.reason : "model-missing",
1827
+ };
1828
+ } catch {
1829
+ /* try next candidate */
1830
+ }
1831
+ }
1832
+ return { available: false, model, reason: "lib-missing" };
1833
+ }
1834
+
1621
1835
  async function runLocalHealthJob(job) {
1622
1836
  const { jobId, payload } = job;
1623
1837
  const start = Date.now();
@@ -1746,6 +1960,19 @@ async function runLocalHealthJob(job) {
1746
1960
  modelPathStatus.push(check);
1747
1961
  }
1748
1962
 
1963
+ const whisperModel =
1964
+ typeof payload?.voiceModel === "string" && payload.voiceModel.trim()
1965
+ ? payload.voiceModel.trim()
1966
+ : "whisper-large-v3-turbo";
1967
+ let whisper = null;
1968
+ if (platform() === "darwin") {
1969
+ const tW = Date.now();
1970
+ whisper = await checkWhisperAvailability(whisperModel);
1971
+ console.log(
1972
+ `[gonext-worker] local_health ${jobId} whisper available=${whisper.available} reason=${whisper.reason} took=${((Date.now() - tW) / 1000).toFixed(2)}s`
1973
+ );
1974
+ }
1975
+
1749
1976
  let mlx = null;
1750
1977
  if (mlxRoot || mlxNative?.available) {
1751
1978
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
@@ -1800,6 +2027,7 @@ async function runLocalHealthJob(job) {
1800
2027
  }
1801
2028
  : undefined,
1802
2029
  mlx,
2030
+ whisper: whisper ?? undefined,
1803
2031
  setup:
1804
2032
  modelPathStatus.length > 0
1805
2033
  ? {
@@ -1905,6 +2133,10 @@ async function pollOnce() {
1905
2133
  await runAgentChatJob(job);
1906
2134
  return;
1907
2135
  }
2136
+ if (job.jobType === "transcribe") {
2137
+ await runTranscribeJob(job);
2138
+ return;
2139
+ }
1908
2140
  const isOcrByType = job.jobType === "ocr";
1909
2141
  const isOcrByPayload =
1910
2142
  job.payload &&
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Voice transcription for the GoNext local worker (Audio support, task #21).
3
+
4
+ Reads a JSON job on stdin: {"audioPath": str, "model": str, "language": str?}.
5
+ Transcribes with mlx-whisper and prints {"text": "..."} on stdout.
6
+
7
+ Design notes:
8
+ - The browser sends a 16 kHz mono 16-bit PCM WAV, so we decode it with the
9
+ stdlib `wave` module + numpy and hand mlx-whisper a float32 array. Passing an
10
+ array (not a file path) skips mlx-whisper's ffmpeg-based loader, so NO ffmpeg
11
+ install is required on the worker.
12
+ - The model is resolved by HF repo id ("mlx-community/<model>") from the HF hub
13
+ cache (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). This is the
14
+ same location the wizard installs to and the readiness probe checks.
15
+ - On failure we exit non-zero and print a machine-parseable reason token as the
16
+ first stderr line: "lib-missing" | "model-missing" | "audio-error" | "error".
17
+ """
18
+
19
+ import json
20
+ import sys
21
+ import wave
22
+
23
+
24
+ TARGET_SR = 16000
25
+
26
+
27
+ def fail(reason: str, detail: str = "") -> None:
28
+ sys.stderr.write(reason + "\n")
29
+ if detail:
30
+ sys.stderr.write(detail + "\n")
31
+ sys.exit(1)
32
+
33
+
34
+ def load_wav_mono_16k(path: str):
35
+ """Decode a PCM WAV to a float32 mono array at 16 kHz without ffmpeg."""
36
+ import numpy as np
37
+
38
+ with wave.open(path, "rb") as w:
39
+ n_channels = w.getnchannels()
40
+ sampwidth = w.getsampwidth()
41
+ framerate = w.getframerate()
42
+ n_frames = w.getnframes()
43
+ raw = w.readframes(n_frames)
44
+
45
+ if sampwidth == 2:
46
+ audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
47
+ elif sampwidth == 4:
48
+ audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0
49
+ elif sampwidth == 1:
50
+ audio = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
51
+ else:
52
+ raise ValueError(f"unsupported WAV sample width: {sampwidth} bytes")
53
+
54
+ if n_channels > 1:
55
+ audio = audio.reshape(-1, n_channels).mean(axis=1)
56
+
57
+ if framerate != TARGET_SR and audio.size > 0:
58
+ # Linear resample; the browser already sends 16 kHz so this is a safety net.
59
+ duration = audio.size / float(framerate)
60
+ target_len = int(round(duration * TARGET_SR))
61
+ if target_len > 0:
62
+ src_x = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
63
+ dst_x = np.linspace(0.0, 1.0, num=target_len, endpoint=False)
64
+ audio = np.interp(dst_x, src_x, audio).astype(np.float32)
65
+
66
+ return np.ascontiguousarray(audio, dtype=np.float32)
67
+
68
+
69
+ def main() -> None:
70
+ try:
71
+ payload = json.loads(sys.stdin.read() or "{}")
72
+ except Exception as e: # noqa: BLE001
73
+ fail("error", f"invalid job json: {e}")
74
+ return
75
+
76
+ audio_path = str(payload.get("audioPath") or "").strip()
77
+ model = str(payload.get("model") or "whisper-large-v3-turbo").strip()
78
+ language = str(payload.get("language") or "").strip() or None
79
+ if not audio_path:
80
+ fail("error", "missing audioPath")
81
+ return
82
+
83
+ try:
84
+ import mlx_whisper # noqa: F401
85
+ except Exception as e: # noqa: BLE001
86
+ fail("lib-missing", str(e))
87
+ return
88
+
89
+ try:
90
+ audio = load_wav_mono_16k(audio_path)
91
+ except Exception as e: # noqa: BLE001
92
+ fail("audio-error", str(e))
93
+ return
94
+
95
+ repo = f"mlx-community/{model}"
96
+ try:
97
+ result = mlx_whisper.transcribe(
98
+ audio,
99
+ path_or_hf_repo=repo,
100
+ task="transcribe",
101
+ language=language,
102
+ )
103
+ except Exception as e: # noqa: BLE001
104
+ msg = str(e).lower()
105
+ if any(
106
+ token in msg
107
+ for token in (
108
+ "not found",
109
+ "repository",
110
+ "couldn't find",
111
+ "could not find",
112
+ "no such file",
113
+ "connection",
114
+ "offline",
115
+ "resolve",
116
+ "404",
117
+ )
118
+ ):
119
+ fail("model-missing", str(e))
120
+ else:
121
+ fail("error", str(e))
122
+ return
123
+
124
+ text = str(result.get("text", "")).strip() if isinstance(result, dict) else ""
125
+ sys.stdout.write(json.dumps({"text": text}))
126
+ sys.stdout.flush()
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.99",
3
+ "version": "1.0.101",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "gonext-local-worker.mjs",
25
25
  "gonext_probe_agent.py",
26
26
  "gonext_agent_chat.py",
27
+ "gonext_transcribe.py",
27
28
  "README.md",
28
29
  "launchd/"
29
30
  ],