@tiens.nguyen/gonext-local-worker 1.0.100 → 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.
@@ -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.100",
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
  ],