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

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 &&
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.100",
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",