omnius 1.0.423 → 1.0.425
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/dist/index.js +1399 -1363
- package/dist/scripts/live-whisper.py +335 -139
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -5,30 +5,42 @@ live-whisper.py — Self-contained live transcription worker using openai-whispe
|
|
|
5
5
|
Fallback for transcribe-cli on platforms where faster-whisper / ONNX fails
|
|
6
6
|
(e.g. linux-arm64). Auto-creates a Python venv and installs openai-whisper.
|
|
7
7
|
|
|
8
|
+
Architecture (EGG voice/whisper lineage):
|
|
9
|
+
- Utterance-chunked transcription: speech-start → sustained silence →
|
|
10
|
+
transcribe the COMPLETE utterance once. No sliding-window re-transcription
|
|
11
|
+
(which duplicated partials, fed whisper silence-heavy windows, and
|
|
12
|
+
hallucinated on quiet audio).
|
|
13
|
+
- ReSpeaker hardware VAD/AEC/DOA: when a ReSpeaker 4-Mic array is present
|
|
14
|
+
(USB 2886:0018), the XMOS DSP's SPEECHDETECTED register gates utterances,
|
|
15
|
+
on-chip echo cancellation is enabled, and the DOA angle is attached to
|
|
16
|
+
transcripts. Falls back to adaptive energy VAD otherwise.
|
|
17
|
+
- Dual-model consensus: a second whisper model transcribes finalized
|
|
18
|
+
utterances in parallel; transcripts are only emitted when both agree.
|
|
19
|
+
|
|
8
20
|
Protocol:
|
|
9
21
|
stdin — raw PCM16 audio (16kHz, mono, 16-bit signed LE)
|
|
10
22
|
stdout — JSON lines:
|
|
11
23
|
{"type":"status","message":"Installing dependencies..."}
|
|
12
24
|
{"type":"status","message":"Loading model..."}
|
|
13
25
|
{"type":"ready"}
|
|
26
|
+
{"type":"vad","speech":true,"doa":90}
|
|
14
27
|
{"type":"transcript","text":"hello world","isFinal":false}
|
|
15
|
-
{"type":"transcript","text":"hello world how are you","isFinal":true}
|
|
28
|
+
{"type":"transcript","text":"hello world how are you","isFinal":true,"doa":90}
|
|
29
|
+
{"type":"consensus_rejected","primary":"...","secondary":"...","reason":"..."}
|
|
16
30
|
{"type":"error","message":"..."}
|
|
17
31
|
|
|
18
32
|
Usage:
|
|
19
33
|
arecord -f S16_LE -r 16000 -c 1 -t raw -q - | python3 live-whisper.py --model base
|
|
20
|
-
|
|
21
|
-
Based on the proven ARM-compatible approach from hydra/whisper_asr.
|
|
22
34
|
"""
|
|
23
35
|
|
|
24
36
|
import sys
|
|
25
37
|
import os
|
|
26
38
|
import json
|
|
27
39
|
import subprocess
|
|
28
|
-
import importlib
|
|
29
40
|
import struct
|
|
30
41
|
import time
|
|
31
42
|
import threading
|
|
43
|
+
from collections import deque
|
|
32
44
|
from pathlib import Path
|
|
33
45
|
|
|
34
46
|
# ---------------------------------------------------------------------------
|
|
@@ -43,8 +55,8 @@ PIP = VENV / "bin" / "pip"
|
|
|
43
55
|
SAMPLE_RATE = 16000
|
|
44
56
|
CHANNELS = 1
|
|
45
57
|
SAMPLE_WIDTH = 2 # 16-bit
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
BLOCK_SECONDS = 0.2
|
|
59
|
+
BLOCK_SAMPLES = int(SAMPLE_RATE * BLOCK_SECONDS)
|
|
48
60
|
|
|
49
61
|
# ---------------------------------------------------------------------------
|
|
50
62
|
# Output helpers (JSON lines to stdout)
|
|
@@ -64,11 +76,14 @@ def emit_error(msg: str):
|
|
|
64
76
|
emit({"type": "error", "message": msg})
|
|
65
77
|
|
|
66
78
|
|
|
67
|
-
def emit_transcript(text: str, is_final: bool = False):
|
|
68
|
-
|
|
79
|
+
def emit_transcript(text: str, is_final: bool = False, doa=None):
|
|
80
|
+
event = {"type": "transcript", "text": text, "isFinal": is_final}
|
|
81
|
+
if doa is not None:
|
|
82
|
+
event["doa"] = doa
|
|
83
|
+
emit(event)
|
|
69
84
|
|
|
70
85
|
# ---------------------------------------------------------------------------
|
|
71
|
-
# Venv bootstrap
|
|
86
|
+
# Venv bootstrap
|
|
72
87
|
# ---------------------------------------------------------------------------
|
|
73
88
|
|
|
74
89
|
def _in_venv() -> bool:
|
|
@@ -81,7 +96,6 @@ def _ensure_venv():
|
|
|
81
96
|
emit_status("Creating Python venv for Whisper...")
|
|
82
97
|
import venv
|
|
83
98
|
venv.EnvBuilder(with_pip=True).create(str(VENV))
|
|
84
|
-
# Upgrade pip quietly
|
|
85
99
|
subprocess.check_call(
|
|
86
100
|
[str(PY), "-m", "pip", "install", "--upgrade", "pip"],
|
|
87
101
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
@@ -106,27 +120,132 @@ def _ensure_deps():
|
|
|
106
120
|
[str(PIP), "install", *need],
|
|
107
121
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
108
122
|
)
|
|
109
|
-
# Force reimport
|
|
110
123
|
for mod in ["numpy", "whisper"]:
|
|
111
124
|
if mod in sys.modules:
|
|
112
125
|
del sys.modules[mod]
|
|
113
126
|
|
|
127
|
+
# pyusb is optional — only needed for ReSpeaker DSP tuning (hardware
|
|
128
|
+
# VAD/AEC/DOA). Failure is tolerated; we fall back to energy VAD.
|
|
129
|
+
try:
|
|
130
|
+
import usb.core # noqa: F401
|
|
131
|
+
except ImportError:
|
|
132
|
+
try:
|
|
133
|
+
subprocess.check_call(
|
|
134
|
+
[str(PIP), "install", "pyusb"],
|
|
135
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
136
|
+
)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
114
140
|
# ---------------------------------------------------------------------------
|
|
115
141
|
# Bootstrap: ensure we're in the venv with deps
|
|
116
142
|
# ---------------------------------------------------------------------------
|
|
117
143
|
|
|
118
144
|
if not _in_venv():
|
|
119
145
|
_ensure_venv()
|
|
120
|
-
# Re-exec this script inside the venv
|
|
121
146
|
os.execv(str(PY), [str(PY)] + sys.argv)
|
|
122
147
|
|
|
123
148
|
_ensure_deps()
|
|
124
149
|
|
|
125
|
-
# Now safe to import
|
|
126
150
|
import numpy as np
|
|
127
151
|
|
|
128
152
|
# ---------------------------------------------------------------------------
|
|
129
|
-
#
|
|
153
|
+
# ReSpeaker DSP tuning (EGG voice/whisper lineage)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
#
|
|
156
|
+
# The ReSpeaker 4-Mic array (USB 2886:0018) exposes its XMOS DSP over USB
|
|
157
|
+
# vendor control transfers. The on-chip beamformed VAD (SPEECHDETECTED) is far
|
|
158
|
+
# more robust than software energy gating on quiet capture chains, the echo
|
|
159
|
+
# canceller stops TTS playback from leaking into ASR, and DOAANGLE localizes
|
|
160
|
+
# the speaker.
|
|
161
|
+
|
|
162
|
+
RESPEAKER_PARAMS = {
|
|
163
|
+
"SPEECHDETECTED": (19, 22, "int"),
|
|
164
|
+
"DOAANGLE": (21, 0, "int"),
|
|
165
|
+
"AECFREEZEONOFF": (18, 7, "int"),
|
|
166
|
+
"AECSILENCELEVEL": (18, 30, "float"),
|
|
167
|
+
"ECHOONOFF": (19, 14, "int"),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ReSpeakerTuning:
|
|
172
|
+
TIMEOUT = 1000 # ms — polled every block; must never stall the FSM
|
|
173
|
+
|
|
174
|
+
def __init__(self, dev, usb_util):
|
|
175
|
+
self.dev = dev
|
|
176
|
+
self.usb_util = usb_util
|
|
177
|
+
self.failures = 0
|
|
178
|
+
|
|
179
|
+
def write(self, name, value):
|
|
180
|
+
param_id, offset, kind = RESPEAKER_PARAMS[name]
|
|
181
|
+
if kind == "int":
|
|
182
|
+
payload = struct.pack(b"iii", offset, int(value), 1)
|
|
183
|
+
else:
|
|
184
|
+
payload = struct.pack(b"ifi", offset, float(value), 0)
|
|
185
|
+
self.dev.ctrl_transfer(0x40, 0, 0, param_id, payload, self.TIMEOUT)
|
|
186
|
+
|
|
187
|
+
def read(self, name):
|
|
188
|
+
param_id, offset, kind = RESPEAKER_PARAMS[name]
|
|
189
|
+
cmd = 0x80 | offset
|
|
190
|
+
if kind == "int":
|
|
191
|
+
cmd |= 0x40
|
|
192
|
+
response = self.dev.ctrl_transfer(0xC0, 0, cmd, param_id, 8, self.TIMEOUT)
|
|
193
|
+
low, high = struct.unpack(b"ii", response.tobytes())
|
|
194
|
+
return low if kind == "int" else low * (2.0 ** high)
|
|
195
|
+
|
|
196
|
+
def speech_detected(self):
|
|
197
|
+
"""True/False from the DSP VAD, or None when the read fails."""
|
|
198
|
+
try:
|
|
199
|
+
value = self.read("SPEECHDETECTED")
|
|
200
|
+
self.failures = 0
|
|
201
|
+
return bool(value)
|
|
202
|
+
except Exception:
|
|
203
|
+
self.failures += 1
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def doa(self):
|
|
207
|
+
try:
|
|
208
|
+
return int(self.read("DOAANGLE"))
|
|
209
|
+
except Exception:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def close(self):
|
|
213
|
+
try:
|
|
214
|
+
self.usb_util.dispose_resources(self.dev)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def find_respeaker():
|
|
220
|
+
"""Locate the ReSpeaker array and enable on-chip echo cancellation."""
|
|
221
|
+
try:
|
|
222
|
+
import usb.core
|
|
223
|
+
import usb.util
|
|
224
|
+
except ImportError:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
dev = usb.core.find(idVendor=0x2886, idProduct=0x0018)
|
|
228
|
+
if dev is None:
|
|
229
|
+
return None
|
|
230
|
+
tuning = ReSpeakerTuning(dev, usb.util)
|
|
231
|
+
# EGG-recommended AEC settings: adaptive echo canceller on, echo
|
|
232
|
+
# suppression on, raised silence level to mask self-generated audio.
|
|
233
|
+
try:
|
|
234
|
+
tuning.write("AECFREEZEONOFF", 0)
|
|
235
|
+
tuning.write("ECHOONOFF", 1)
|
|
236
|
+
tuning.write("AECSILENCELEVEL", 1e-4)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass # tuning writes need udev permissions; VAD reads may still work
|
|
239
|
+
# Probe the VAD once — if it doesn't answer, don't use the device.
|
|
240
|
+
if tuning.speech_detected() is None:
|
|
241
|
+
tuning.close()
|
|
242
|
+
return None
|
|
243
|
+
return tuning
|
|
244
|
+
except Exception:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Transcript quality helpers
|
|
130
249
|
# ---------------------------------------------------------------------------
|
|
131
250
|
|
|
132
251
|
def _norm_tokens(text):
|
|
@@ -143,7 +262,6 @@ def _texts_agree(a: str, b: str, threshold: float) -> bool:
|
|
|
143
262
|
ta, tb = _norm_tokens(a), _norm_tokens(b)
|
|
144
263
|
if not ta or not tb:
|
|
145
264
|
return False
|
|
146
|
-
# Containment: short utterances often differ only by dropped filler words
|
|
147
265
|
ja, jb = " ".join(ta), " ".join(tb)
|
|
148
266
|
if ja in jb or jb in ja:
|
|
149
267
|
return True
|
|
@@ -167,22 +285,63 @@ def _segment_filtered_text(result) -> str:
|
|
|
167
285
|
return " ".join(part.strip() for part in kept if part.strip()).strip()
|
|
168
286
|
|
|
169
287
|
|
|
288
|
+
def amplify(samples):
|
|
289
|
+
"""Bring quiet-but-real speech toward a healthy level for whisper.
|
|
290
|
+
|
|
291
|
+
Gain targets the SPEECH RMS (median of the loudest half of 0.2s blocks),
|
|
292
|
+
not the raw peak — a single click/pop no longer suppresses amplification
|
|
293
|
+
of otherwise-quiet speech. Gain is capped at 40x (~32dB) and bounded so
|
|
294
|
+
the peak never clips."""
|
|
295
|
+
if len(samples) == 0:
|
|
296
|
+
return samples
|
|
297
|
+
peak = float(np.max(np.abs(samples)))
|
|
298
|
+
if peak <= 0:
|
|
299
|
+
return samples
|
|
300
|
+
block = max(1, SAMPLE_RATE // 5)
|
|
301
|
+
energies = sorted(
|
|
302
|
+
float(np.sqrt(np.mean(samples[i:i + block] ** 2)))
|
|
303
|
+
for i in range(0, max(1, len(samples) - block + 1), block)
|
|
304
|
+
)
|
|
305
|
+
loud_half = energies[len(energies) // 2:] or energies
|
|
306
|
+
speech_rms = loud_half[len(loud_half) // 2] # median of the loud half
|
|
307
|
+
target_rms = 0.08 # ≈ -22dBFS — comfortable whisper input level
|
|
308
|
+
if speech_rms >= target_rms:
|
|
309
|
+
return samples
|
|
310
|
+
# No-clip bound uses the 99th-percentile amplitude, not the absolute
|
|
311
|
+
# peak — an isolated click/pop may clip (harmless to ASR) instead of
|
|
312
|
+
# suppressing amplification of the actual speech.
|
|
313
|
+
p99 = float(np.percentile(np.abs(samples), 99.0))
|
|
314
|
+
gain = min(40.0, target_rms / max(speech_rms, 1e-5), 0.98 / max(p99, 1e-4))
|
|
315
|
+
if gain <= 1.05:
|
|
316
|
+
return samples
|
|
317
|
+
return np.clip(samples * gain, -1.0, 1.0)
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Main transcription loop — utterance FSM
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
170
323
|
def main():
|
|
171
324
|
import argparse
|
|
172
325
|
parser = argparse.ArgumentParser(description="Live Whisper transcription worker")
|
|
173
|
-
parser.add_argument("--model", default="
|
|
174
|
-
|
|
175
|
-
parser.add_argument("--
|
|
326
|
+
parser.add_argument("--model", default="medium", help="Whisper model size (tiny/base/small/medium/large)")
|
|
327
|
+
# Accepted for backward compatibility with older launchers (sliding-window era).
|
|
328
|
+
parser.add_argument("--chunk-seconds", type=float, default=3, help=argparse.SUPPRESS)
|
|
329
|
+
parser.add_argument("--window-seconds", type=float, default=10, help=argparse.SUPPRESS)
|
|
176
330
|
parser.add_argument("--language", default=None, help="Language code (e.g. en, es, fr). Auto-detect if omitted.")
|
|
177
331
|
parser.add_argument("--consensus-model", default="tiny",
|
|
178
|
-
help="Second whisper model run in parallel on
|
|
332
|
+
help="Second whisper model run in parallel on finalized utterances; transcripts are only emitted when both models agree. 'off' disables.")
|
|
179
333
|
parser.add_argument("--consensus-threshold", type=float, default=0.55,
|
|
180
334
|
help="Token similarity (0-1) required between the two models' transcripts.")
|
|
335
|
+
parser.add_argument("--silence-ms", type=float, default=900,
|
|
336
|
+
help="Sustained silence that finalizes an utterance.")
|
|
337
|
+
parser.add_argument("--preroll-ms", type=float, default=600,
|
|
338
|
+
help="Audio kept from before speech onset.")
|
|
339
|
+
parser.add_argument("--max-utterance-seconds", type=float, default=22,
|
|
340
|
+
help="Force-finalize utterances longer than this.")
|
|
181
341
|
args = parser.parse_args()
|
|
182
342
|
|
|
183
343
|
import whisper
|
|
184
344
|
|
|
185
|
-
# Load model
|
|
186
345
|
emit_status(f"Loading Whisper {args.model} model...")
|
|
187
346
|
try:
|
|
188
347
|
device = "cpu"
|
|
@@ -192,14 +351,12 @@ def main():
|
|
|
192
351
|
device = "cuda"
|
|
193
352
|
except ImportError:
|
|
194
353
|
pass
|
|
195
|
-
|
|
196
354
|
model = whisper.load_model(args.model, device=device)
|
|
197
355
|
except Exception as e:
|
|
198
356
|
emit_error(f"Failed to load model: {e}")
|
|
199
357
|
sys.exit(1)
|
|
200
358
|
|
|
201
|
-
# Consensus model:
|
|
202
|
-
# window in parallel. Hallucinations (mixed-language garbage on noisy or
|
|
359
|
+
# Consensus model: hallucinations (mixed-language garbage on noisy or
|
|
203
360
|
# quiet audio) don't reproduce across models, so disagreement = reject.
|
|
204
361
|
consensus_model = None
|
|
205
362
|
consensus_name = (args.consensus_model or "off").strip().lower()
|
|
@@ -211,43 +368,36 @@ def main():
|
|
|
211
368
|
emit_status(f"Consensus model unavailable ({e}); running single-model.")
|
|
212
369
|
consensus_model = None
|
|
213
370
|
|
|
371
|
+
# ReSpeaker DSP: hardware VAD + echo cancellation + DOA
|
|
372
|
+
tuning = find_respeaker()
|
|
373
|
+
if tuning is not None:
|
|
374
|
+
emit_status("ReSpeaker DSP active: hardware VAD, echo cancellation, DOA.")
|
|
375
|
+
|
|
214
376
|
emit({"type": "ready"})
|
|
215
377
|
|
|
216
|
-
|
|
378
|
+
fp16 = (device == "cuda")
|
|
379
|
+
|
|
380
|
+
def run_transcribe(m, samples):
|
|
381
|
+
return m.transcribe(
|
|
382
|
+
samples,
|
|
383
|
+
fp16=fp16,
|
|
384
|
+
language=args.language,
|
|
385
|
+
no_speech_threshold=0.6,
|
|
386
|
+
condition_on_previous_text=False,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Shared PCM intake
|
|
217
390
|
audio_buf = np.zeros(0, dtype=np.float32)
|
|
218
391
|
buf_lock = threading.Lock()
|
|
219
|
-
chunk_bytes = int(args.chunk_seconds * SAMPLE_RATE * SAMPLE_WIDTH)
|
|
220
|
-
window_samples = int(args.window_seconds * SAMPLE_RATE)
|
|
221
|
-
last_text = ""
|
|
222
392
|
running = True
|
|
223
|
-
noise_floor = None # EMA of the quietest 0.5s block RMS — adapts to the capture chain
|
|
224
|
-
|
|
225
|
-
def block_energies(samples):
|
|
226
|
-
"""RMS of consecutive 0.5s blocks — lets speech anywhere in the
|
|
227
|
-
window register even when most of the window is silence."""
|
|
228
|
-
block = SAMPLE_RATE // 2
|
|
229
|
-
n = max(1, len(samples) // block)
|
|
230
|
-
return [float(np.sqrt(np.mean(samples[i * block:(i + 1) * block] ** 2))) for i in range(n)]
|
|
231
|
-
|
|
232
|
-
def amplify(samples):
|
|
233
|
-
"""Bring quiet-but-real speech toward full scale. Whisper performs far
|
|
234
|
-
better near normal levels; quiet capture chains (distant speakers,
|
|
235
|
-
low source gain, beamformed arrays) otherwise force users to shout
|
|
236
|
-
into the mic. Gain capped at 40x (~32dB), output clipped to [-1, 1]."""
|
|
237
|
-
peak = float(np.max(np.abs(samples))) if len(samples) else 0.0
|
|
238
|
-
if 0 < peak < 0.5:
|
|
239
|
-
samples = np.clip(samples * min(40.0, 0.9 / max(peak, 1e-4)), -1.0, 1.0)
|
|
240
|
-
return samples
|
|
241
393
|
|
|
242
394
|
def read_stdin():
|
|
243
|
-
"""Read PCM16 from stdin in a background thread."""
|
|
244
395
|
nonlocal audio_buf, running
|
|
245
396
|
try:
|
|
246
397
|
while running:
|
|
247
|
-
data = sys.stdin.buffer.read(
|
|
398
|
+
data = sys.stdin.buffer.read(BLOCK_SAMPLES * SAMPLE_WIDTH)
|
|
248
399
|
if not data:
|
|
249
400
|
break # EOF
|
|
250
|
-
# Convert PCM16 LE to float32 [-1, 1]
|
|
251
401
|
samples = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
252
402
|
with buf_lock:
|
|
253
403
|
audio_buf = np.concatenate([audio_buf, samples])
|
|
@@ -256,112 +406,158 @@ def main():
|
|
|
256
406
|
finally:
|
|
257
407
|
running = False
|
|
258
408
|
|
|
259
|
-
# Start stdin reader thread
|
|
260
409
|
reader = threading.Thread(target=read_stdin, daemon=True)
|
|
261
410
|
reader.start()
|
|
262
411
|
|
|
412
|
+
# Utterance FSM state
|
|
413
|
+
preroll_blocks = max(1, int(round(args.preroll_ms / 1000.0 / BLOCK_SECONDS)))
|
|
414
|
+
finalize_blocks = max(2, int(round(args.silence_ms / 1000.0 / BLOCK_SECONDS)))
|
|
415
|
+
max_utter_samples = int(args.max_utterance_seconds * SAMPLE_RATE)
|
|
416
|
+
|
|
417
|
+
preroll = deque(maxlen=preroll_blocks)
|
|
418
|
+
utterance = [] # list of float32 blocks while capturing
|
|
419
|
+
capturing = False
|
|
420
|
+
silence_run = 0
|
|
421
|
+
noise_floor = None
|
|
422
|
+
last_partial_at = 0.0
|
|
423
|
+
last_final_text = ""
|
|
424
|
+
last_vad_emit = (False, 0.0)
|
|
425
|
+
pending = np.zeros(0, dtype=np.float32)
|
|
426
|
+
cursor = 0
|
|
427
|
+
|
|
428
|
+
def energy_speech(block_rms):
|
|
429
|
+
nonlocal noise_floor
|
|
430
|
+
if noise_floor is None:
|
|
431
|
+
noise_floor = block_rms
|
|
432
|
+
elif block_rms < noise_floor:
|
|
433
|
+
noise_floor = 0.7 * noise_floor + 0.3 * block_rms # drop fast
|
|
434
|
+
else:
|
|
435
|
+
noise_floor = 0.995 * noise_floor + 0.005 * block_rms # creep up slowly
|
|
436
|
+
gate = max(1.5e-4, noise_floor * 2.5) # ≥ ~8dB above floor
|
|
437
|
+
return block_rms >= gate
|
|
438
|
+
|
|
439
|
+
def transcribe_partial(samples):
|
|
440
|
+
try:
|
|
441
|
+
result = run_transcribe(model, amplify(samples))
|
|
442
|
+
text = _segment_filtered_text(result)
|
|
443
|
+
if text:
|
|
444
|
+
emit_transcript(text, is_final=False)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
emit_error(f"Transcription error: {e}")
|
|
447
|
+
|
|
448
|
+
def transcribe_final(samples):
|
|
449
|
+
nonlocal last_final_text
|
|
450
|
+
if len(samples) < int(0.4 * SAMPLE_RATE):
|
|
451
|
+
return
|
|
452
|
+
samples = amplify(samples)
|
|
453
|
+
try:
|
|
454
|
+
if consensus_model is not None:
|
|
455
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
456
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
457
|
+
primary_future = pool.submit(run_transcribe, model, samples)
|
|
458
|
+
secondary_future = pool.submit(run_transcribe, consensus_model, samples)
|
|
459
|
+
result = primary_future.result()
|
|
460
|
+
secondary = secondary_future.result()
|
|
461
|
+
else:
|
|
462
|
+
result = run_transcribe(model, samples)
|
|
463
|
+
secondary = None
|
|
464
|
+
|
|
465
|
+
text = _segment_filtered_text(result)
|
|
466
|
+
if not text:
|
|
467
|
+
return
|
|
468
|
+
if secondary is not None:
|
|
469
|
+
secondary_text = _segment_filtered_text(secondary)
|
|
470
|
+
lang_a = str(result.get("language", "")).lower()
|
|
471
|
+
lang_b = str(secondary.get("language", "")).lower()
|
|
472
|
+
lang_disagree = bool(lang_a and lang_b and lang_a != lang_b and not args.language)
|
|
473
|
+
if lang_disagree or not _texts_agree(text, secondary_text, args.consensus_threshold):
|
|
474
|
+
emit({
|
|
475
|
+
"type": "consensus_rejected",
|
|
476
|
+
"primary": text[:200],
|
|
477
|
+
"secondary": (secondary_text or "")[:200],
|
|
478
|
+
"reason": "language_mismatch" if lang_disagree else "low_similarity",
|
|
479
|
+
})
|
|
480
|
+
return
|
|
481
|
+
if text == last_final_text:
|
|
482
|
+
return
|
|
483
|
+
last_final_text = text
|
|
484
|
+
emit_transcript(text, is_final=True, doa=tuning.doa() if tuning else None)
|
|
485
|
+
except Exception as e:
|
|
486
|
+
emit_error(f"Transcription error: {e}")
|
|
487
|
+
|
|
263
488
|
try:
|
|
264
489
|
while running:
|
|
265
|
-
time.sleep(
|
|
490
|
+
time.sleep(BLOCK_SECONDS / 2)
|
|
266
491
|
|
|
267
492
|
with buf_lock:
|
|
268
|
-
if len(audio_buf)
|
|
493
|
+
if len(audio_buf) > cursor:
|
|
494
|
+
pending = np.concatenate([pending, audio_buf[cursor:]])
|
|
495
|
+
cursor = len(audio_buf)
|
|
496
|
+
# Bound memory: keep the buffer from growing without limit.
|
|
497
|
+
if len(audio_buf) > 120 * SAMPLE_RATE:
|
|
498
|
+
audio_buf = audio_buf[-SAMPLE_RATE:]
|
|
499
|
+
cursor = len(audio_buf)
|
|
500
|
+
|
|
501
|
+
while len(pending) >= BLOCK_SAMPLES:
|
|
502
|
+
block = pending[:BLOCK_SAMPLES]
|
|
503
|
+
pending = pending[BLOCK_SAMPLES:]
|
|
504
|
+
|
|
505
|
+
block_rms = float(np.sqrt(np.mean(block ** 2)))
|
|
506
|
+
energy = energy_speech(block_rms)
|
|
507
|
+
hw = tuning.speech_detected() if tuning is not None else None
|
|
508
|
+
if tuning is not None and tuning.failures > 5:
|
|
509
|
+
# Repeated USB read failures (unplug, permission loss) —
|
|
510
|
+
# stop polling so control-transfer timeouts can't stall
|
|
511
|
+
# the FSM; energy VAD takes over.
|
|
512
|
+
emit_status("ReSpeaker DSP unresponsive — falling back to energy VAD.")
|
|
513
|
+
tuning.close()
|
|
514
|
+
tuning = None
|
|
515
|
+
hw = None
|
|
516
|
+
# Hardware VAD is authoritative when the DSP answers; energy
|
|
517
|
+
# VAD covers non-ReSpeaker mics and USB read failures.
|
|
518
|
+
speech = hw if hw is not None else energy
|
|
519
|
+
|
|
520
|
+
now = time.time()
|
|
521
|
+
if speech != last_vad_emit[0] and now - last_vad_emit[1] > 0.3:
|
|
522
|
+
last_vad_emit = (speech, now)
|
|
523
|
+
emit({"type": "vad", "speech": bool(speech), "doa": tuning.doa() if tuning else None})
|
|
524
|
+
|
|
525
|
+
if not capturing:
|
|
526
|
+
preroll.append(block)
|
|
527
|
+
if speech:
|
|
528
|
+
capturing = True
|
|
529
|
+
silence_run = 0
|
|
530
|
+
utterance = list(preroll)
|
|
531
|
+
preroll.clear()
|
|
269
532
|
continue
|
|
270
|
-
# Take the last window_seconds of audio
|
|
271
|
-
window = audio_buf[-window_samples:].copy() if len(audio_buf) > window_samples else audio_buf.copy()
|
|
272
|
-
|
|
273
|
-
# Adaptive silence gate: whisper hallucinates repetitive garbage
|
|
274
|
-
# on silent input, but absolute thresholds reject real speech on
|
|
275
|
-
# quiet capture chains. Instead track the noise floor (quietest
|
|
276
|
-
# 0.5s block) and require the loudest block to rise above it.
|
|
277
|
-
energies = block_energies(window)
|
|
278
|
-
quiet = min(energies)
|
|
279
|
-
loud = max(energies)
|
|
280
|
-
if noise_floor is None:
|
|
281
|
-
noise_floor = quiet
|
|
282
|
-
elif quiet < noise_floor:
|
|
283
|
-
noise_floor = 0.7 * noise_floor + 0.3 * quiet # drop fast
|
|
284
|
-
else:
|
|
285
|
-
noise_floor = 0.995 * noise_floor + 0.005 * quiet # creep up slowly
|
|
286
|
-
gate = max(1.5e-4, noise_floor * 2.5) # ≥ ~8dB above floor, ~-76dB min
|
|
287
|
-
if loud < gate:
|
|
288
|
-
continue
|
|
289
|
-
|
|
290
|
-
# Amplify toward full scale before transcription
|
|
291
|
-
window = amplify(window)
|
|
292
|
-
|
|
293
|
-
# Transcribe — primary and consensus models in parallel threads
|
|
294
|
-
# (torch releases the GIL during compute, so this is real
|
|
295
|
-
# parallelism on multicore ARM).
|
|
296
|
-
try:
|
|
297
|
-
fp16 = (device == "cuda")
|
|
298
|
-
|
|
299
|
-
def run_transcribe(m):
|
|
300
|
-
return m.transcribe(
|
|
301
|
-
window,
|
|
302
|
-
fp16=fp16,
|
|
303
|
-
language=args.language,
|
|
304
|
-
no_speech_threshold=0.6,
|
|
305
|
-
condition_on_previous_text=False,
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
if consensus_model is not None:
|
|
309
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
310
|
-
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
311
|
-
primary_future = pool.submit(run_transcribe, model)
|
|
312
|
-
secondary_future = pool.submit(run_transcribe, consensus_model)
|
|
313
|
-
result = primary_future.result()
|
|
314
|
-
secondary = secondary_future.result()
|
|
315
|
-
else:
|
|
316
|
-
result = run_transcribe(model)
|
|
317
|
-
secondary = None
|
|
318
|
-
|
|
319
|
-
text = _segment_filtered_text(result)
|
|
320
|
-
|
|
321
|
-
if text and secondary is not None:
|
|
322
|
-
secondary_text = _segment_filtered_text(secondary)
|
|
323
|
-
lang_a = str(result.get("language", "")).lower()
|
|
324
|
-
lang_b = str(secondary.get("language", "")).lower()
|
|
325
|
-
lang_disagree = bool(lang_a and lang_b and lang_a != lang_b and not args.language)
|
|
326
|
-
if lang_disagree or not _texts_agree(text, secondary_text, args.consensus_threshold):
|
|
327
|
-
emit({
|
|
328
|
-
"type": "consensus_rejected",
|
|
329
|
-
"primary": text[:200],
|
|
330
|
-
"secondary": (secondary_text or "")[:200],
|
|
331
|
-
"reason": "language_mismatch" if lang_disagree else "low_similarity",
|
|
332
|
-
})
|
|
333
|
-
continue
|
|
334
|
-
|
|
335
|
-
if text and text != last_text:
|
|
336
|
-
last_text = text
|
|
337
|
-
emit_transcript(text, is_final=False)
|
|
338
|
-
except Exception as e:
|
|
339
|
-
emit_error(f"Transcription error: {e}")
|
|
340
533
|
|
|
534
|
+
utterance.append(block)
|
|
535
|
+
if speech:
|
|
536
|
+
silence_run = 0
|
|
537
|
+
else:
|
|
538
|
+
silence_run += 1
|
|
539
|
+
|
|
540
|
+
utter_samples = sum(len(b) for b in utterance)
|
|
541
|
+
if silence_run >= finalize_blocks or utter_samples >= max_utter_samples:
|
|
542
|
+
samples = np.concatenate(utterance)
|
|
543
|
+
capturing = False
|
|
544
|
+
utterance = []
|
|
545
|
+
silence_run = 0
|
|
546
|
+
transcribe_final(samples)
|
|
547
|
+
elif utter_samples >= int(3.5 * SAMPLE_RATE) and now - last_partial_at >= 2.5:
|
|
548
|
+
last_partial_at = now
|
|
549
|
+
transcribe_partial(np.concatenate(utterance))
|
|
341
550
|
except KeyboardInterrupt:
|
|
342
551
|
pass
|
|
343
552
|
|
|
344
|
-
#
|
|
345
|
-
|
|
346
|
-
full_audio = audio_buf.copy()
|
|
347
|
-
|
|
348
|
-
final_loud = max(block_energies(full_audio)) if len(full_audio) >= SAMPLE_RATE else 0.0
|
|
349
|
-
final_gate = max(1.5e-4, (noise_floor or 0.0) * 2.5)
|
|
350
|
-
if len(full_audio) >= SAMPLE_RATE and final_loud >= final_gate:
|
|
553
|
+
# EOF: finalize any in-flight utterance.
|
|
554
|
+
if utterance:
|
|
351
555
|
try:
|
|
352
|
-
|
|
353
|
-
fp16 = (device == "cuda")
|
|
354
|
-
result = model.transcribe(
|
|
355
|
-
full_audio,
|
|
356
|
-
fp16=fp16,
|
|
357
|
-
language=args.language,
|
|
358
|
-
)
|
|
359
|
-
text = _segment_filtered_text(result)
|
|
360
|
-
if text:
|
|
361
|
-
emit_transcript(text, is_final=True)
|
|
556
|
+
transcribe_final(np.concatenate(utterance))
|
|
362
557
|
except Exception:
|
|
363
558
|
pass
|
|
364
|
-
|
|
559
|
+
if tuning is not None:
|
|
560
|
+
tuning.close()
|
|
365
561
|
running = False
|
|
366
562
|
|
|
367
563
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omnius",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.425",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "omnius",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.425",
|
|
10
10
|
"bundleDependencies": [
|
|
11
11
|
"image-to-ascii"
|
|
12
12
|
],
|
package/package.json
CHANGED