@vaibhavgupta0786/opencode-voice 0.3.7 → 0.3.8
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/package.json +2 -2
- package/servers/mlx_server.py +39 -0
- package/skills/voice-check/SKILL.md +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaibhavgupta0786/opencode-voice",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Local-only push-to-talk dictation for OpenCode v2 \u2014 press F9, speak, press F9.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@opentui/solid": "^0.5.0",
|
|
54
54
|
"@types/node": "^26.0.0",
|
|
55
55
|
"solid-js": "^1.9.0",
|
|
56
|
-
"typescript": "^
|
|
56
|
+
"typescript": "^7.0.2"
|
|
57
57
|
},
|
|
58
58
|
"repository": {
|
|
59
59
|
"type": "git",
|
package/servers/mlx_server.py
CHANGED
|
@@ -28,6 +28,39 @@ import mlx_whisper
|
|
|
28
28
|
MODEL = os.environ.get("MLX_MODEL", "mlx-community/whisper-small-mlx")
|
|
29
29
|
PORT = int(os.environ.get("VOICE_STT_PORT", "8080"))
|
|
30
30
|
|
|
31
|
+
# No-speech gate: refuse to transcribe clips that don't look spoken.
|
|
32
|
+
# faster-whisper's default path has VAD + no_speech_threshold for this;
|
|
33
|
+
# the mlx path inherits no such guard and hallucinates on room noise
|
|
34
|
+
# (e.g. pure noise decoded into repeating nonsense Syllables).
|
|
35
|
+
# Config: VOICE_NOSPEECH_MIN_MS / VOICE_NOSPEECH_ZCR, "0" disables.
|
|
36
|
+
NOSPEECH_MIN_MS = float(os.environ.get("VOICE_NOSPEECH_MIN_MS", "200"))
|
|
37
|
+
NOSPEECH_ZCR = float(os.environ.get("VOICE_NOSPEECH_ZCR", "0.4"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def speech_gate(samples: np.ndarray) -> tuple[bool, str]:
|
|
41
|
+
"""Cheap numpy voice-likeness gate; (pass, reason).
|
|
42
|
+
|
|
43
|
+
Frames are 20ms hops at 16kHz. A frame counts as voiced when its RMS
|
|
44
|
+
exceeds 2% full scale. White noise has near-50% zero crossings vs
|
|
45
|
+
speech tones well under; noise that is loud enough to be voiced gets
|
|
46
|
+
refused on zero-crossing rate instead.
|
|
47
|
+
"""
|
|
48
|
+
frame = int(16000 * 0.02)
|
|
49
|
+
n = len(samples) // frame
|
|
50
|
+
voiced_ms = 0.0
|
|
51
|
+
zcr = 0.0
|
|
52
|
+
if n:
|
|
53
|
+
rms = np.sqrt((samples[: n * frame].reshape(n, frame) ** 2).mean(axis=1))
|
|
54
|
+
voiced = int((rms > 0.02).sum())
|
|
55
|
+
voiced_ms = voiced * frame / 16000 * 1000
|
|
56
|
+
if len(samples) > 1:
|
|
57
|
+
zcr = (samples[1:] * samples[:-1] < 0).mean()
|
|
58
|
+
if NOSPEECH_MIN_MS and voiced_ms < NOSPEECH_MIN_MS:
|
|
59
|
+
return False, f"voiced={voiced_ms:.0f}ms < {NOSPEECH_MIN_MS:.0f}ms"
|
|
60
|
+
if NOSPEECH_ZCR and zcr > NOSPEECH_ZCR:
|
|
61
|
+
return False, f"zcr={zcr:.2f} > {NOSPEECH_ZCR} (noise-like)"
|
|
62
|
+
return True, f"voiced={voiced_ms:.0f}ms zcr={zcr:.2f}"
|
|
63
|
+
|
|
31
64
|
print(f"loading {MODEL} ...", flush=True)
|
|
32
65
|
# Warm up by loading weights once; per-request calls reuse them.
|
|
33
66
|
_MODEL_REF = MODEL
|
|
@@ -80,6 +113,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
80
113
|
self._json(400, {"error": "no file part"})
|
|
81
114
|
return
|
|
82
115
|
samples = wav_bytes_to_float32(bytes(audio))
|
|
116
|
+
passed, gate = speech_gate(samples)
|
|
117
|
+
if not passed:
|
|
118
|
+
print(f"no-speech guard: {gate}", flush=True)
|
|
119
|
+
self._json(200, {"text": ""})
|
|
120
|
+
return
|
|
121
|
+
print(f"speech gate: {gate}", flush=True)
|
|
83
122
|
try:
|
|
84
123
|
result = mlx_whisper.transcribe(samples, path_or_hf_repo=_MODEL_REF)
|
|
85
124
|
finally:
|
|
@@ -57,12 +57,27 @@ model loads from cache in roughly 10-25 s on a cold start:
|
|
|
57
57
|
|
|
58
58
|
curl -s -m 3 http://127.0.0.1:8080/health
|
|
59
59
|
|
|
60
|
-
## 5.
|
|
60
|
+
## 5. Memory check
|
|
61
|
+
|
|
62
|
+
Long-running STT servers ratchet memory silently — buffer allocators
|
|
63
|
+
retain everything (measured: the mlx server held 7.7 GB after 25 days
|
|
64
|
+
because each unique audio length adds GPU buffers that are never freed;
|
|
65
|
+
a per-request `mx.clear_cache()` now caps it). Verify after boot:
|
|
66
|
+
|
|
67
|
+
PID=$(lsof -ti:8080 | head -1)
|
|
68
|
+
vmmap --summary $PID 2>/dev/null | grep "Physical footprint"
|
|
69
|
+
|
|
70
|
+
Healthy: mlx server ≈ 0.7–1.5 GB · CPU (faster-whisper) ≈ 0.4–0.9 GB.
|
|
71
|
+
If the footprint is already high (mlx > 2 GB, CPU > 1 GB), return to
|
|
72
|
+
step 3, restart, and re-verify after one real take — the plateau is the
|
|
73
|
+
health signal, not the size.
|
|
74
|
+
|
|
75
|
+
## 6. If it still will not start
|
|
61
76
|
|
|
62
77
|
Show the last 10-20 lines of the server log (`mlx-server.log` or
|
|
63
78
|
`stt-server.log`) and report the error you see. Do not guess further.
|
|
64
79
|
|
|
65
|
-
##
|
|
80
|
+
## 7. Report
|
|
66
81
|
|
|
67
|
-
One line: server was up / has been restarted / failed (with the error)
|
|
68
|
-
Remind the user to press Fn+F9 to dictate.
|
|
82
|
+
One line: server was up / has been restarted / failed (with the error),
|
|
83
|
+
plus the footprint reading. Remind the user to press Fn+F9 to dictate.
|