@tiens.nguyen/gonext-local-worker 1.0.100 → 1.0.102
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/gonext-local-worker.mjs +68 -0
- package/gonext_transcribe.py +130 -0
- package/package.json +2 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1052,6 +1052,42 @@ async function correctOcrTextViaServer(extractedText, baseUrl) {
|
|
|
1052
1052
|
return normalizeCorrection(corrected, extractedText);
|
|
1053
1053
|
}
|
|
1054
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* Call an Ollama host's native chat endpoint (POST {base}/api/chat) for text
|
|
1057
|
+
* correction/translation, e.g. base "https://ollama1.gomarsic.cc" model "qwen3:14b".
|
|
1058
|
+
* `think:false` suppresses reasoning models' <think> preamble so we get clean text.
|
|
1059
|
+
*/
|
|
1060
|
+
async function correctOcrTextViaOllama(extractedText, base, model) {
|
|
1061
|
+
const systemPrompt =
|
|
1062
|
+
"You are a text correction assistant. Fix grammar and language errors in the " +
|
|
1063
|
+
"provided text while preserving the original meaning and language. " +
|
|
1064
|
+
"Return only the corrected text without any explanation.";
|
|
1065
|
+
const res = await fetch(`${base.replace(/\/+$/, "")}/api/chat`, {
|
|
1066
|
+
method: "POST",
|
|
1067
|
+
headers: { "Content-Type": "application/json" },
|
|
1068
|
+
body: JSON.stringify({
|
|
1069
|
+
model,
|
|
1070
|
+
stream: false,
|
|
1071
|
+
think: false,
|
|
1072
|
+
options: { temperature: 0 },
|
|
1073
|
+
messages: [
|
|
1074
|
+
{ role: "system", content: systemPrompt },
|
|
1075
|
+
{ role: "user", content: `Text:\n${extractedText}` },
|
|
1076
|
+
],
|
|
1077
|
+
}),
|
|
1078
|
+
signal: AbortSignal.timeout(OCR_CORRECT_TIMEOUT_MS),
|
|
1079
|
+
});
|
|
1080
|
+
if (!res.ok) {
|
|
1081
|
+
throw new Error(`Ollama ${base} returned ${res.status}`);
|
|
1082
|
+
}
|
|
1083
|
+
const json = await res.json();
|
|
1084
|
+
let corrected = json?.message?.content?.trim() || "";
|
|
1085
|
+
// Safety net for reasoning models (qwen3) if `think:false` isn't honored:
|
|
1086
|
+
// drop any <think>…</think> preamble before normalizing.
|
|
1087
|
+
corrected = corrected.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
1088
|
+
return normalizeCorrection(corrected, extractedText);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1055
1091
|
async function correctOcrText(extractedText, modelOverride = "") {
|
|
1056
1092
|
if (OCR_SKIP_CORRECTION) {
|
|
1057
1093
|
console.log("[gonext-worker] OCR correction skipped (GONEXT_OCR_SKIP_CORRECTION=1)");
|
|
@@ -1060,6 +1096,38 @@ async function correctOcrText(extractedText, modelOverride = "") {
|
|
|
1060
1096
|
|
|
1061
1097
|
const override = modelOverride.trim();
|
|
1062
1098
|
|
|
1099
|
+
// Ollama-backed correction/translation, e.g. "ollama:qwen3:14b@@https://ollama1.gomarsic.cc".
|
|
1100
|
+
// The model may itself contain a colon (qwen3:14b), so only strip the leading
|
|
1101
|
+
// "ollama:" scheme and split the base off on the "@@" delimiter.
|
|
1102
|
+
if (override.toLowerCase().startsWith("ollama:")) {
|
|
1103
|
+
const rest = override.slice("ollama:".length);
|
|
1104
|
+
const at = rest.indexOf("@@");
|
|
1105
|
+
const model = (at >= 0 ? rest.slice(0, at) : rest).trim();
|
|
1106
|
+
const base = (at >= 0 ? rest.slice(at + 2) : "").trim().replace(/\/+$/, "");
|
|
1107
|
+
if (!model || !base) {
|
|
1108
|
+
console.warn(
|
|
1109
|
+
`[gonext-worker] OCR correction: malformed ollama override "${override}" (using raw OCR text)`
|
|
1110
|
+
);
|
|
1111
|
+
return extractedText;
|
|
1112
|
+
}
|
|
1113
|
+
console.log(`[gonext-worker] OCR correction via Ollama ${base} model=${model}`);
|
|
1114
|
+
console.log(`[gonext-worker] OCR correction input: ${extractedText.slice(0, 300)}`);
|
|
1115
|
+
try {
|
|
1116
|
+
const corrected = await correctOcrTextViaOllama(extractedText, base, model);
|
|
1117
|
+
console.log(`[gonext-worker] OCR correction output: ${corrected.slice(0, 300)}`);
|
|
1118
|
+
console.log(
|
|
1119
|
+
`[gonext-worker] OCR correction done: ${extractedText.length} → ${corrected.length} chars`
|
|
1120
|
+
);
|
|
1121
|
+
return corrected;
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1124
|
+
console.warn(
|
|
1125
|
+
`[gonext-worker] OCR correction Ollama failed (using raw OCR text): ${msg.slice(0, 200)}`
|
|
1126
|
+
);
|
|
1127
|
+
return extractedText;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1063
1131
|
// If the override looks like a URL, call it as a running mlx_lm.server endpoint.
|
|
1064
1132
|
// e.g. "http://127.0.0.1:8083/v1" or "http://127.0.0.1:8083"
|
|
1065
1133
|
if (override.startsWith("http://") || override.startsWith("https://")) {
|
|
@@ -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.
|
|
3
|
+
"version": "1.0.102",
|
|
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
|
],
|