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.
@@ -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
- CHUNK_SECONDS = 3 # Transcribe every N seconds of audio
47
- WINDOW_SECONDS = 10 # Transcribe last N seconds (sliding window)
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
- emit({"type": "transcript", "text": text, "isFinal": is_final})
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 (inspired by hydra/whisper_asr)
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
- # Main transcription loop
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="base", help="Whisper model size (tiny/base/small/medium/large)")
174
- parser.add_argument("--chunk-seconds", type=float, default=CHUNK_SECONDS, help="Transcribe interval")
175
- parser.add_argument("--window-seconds", type=float, default=WINDOW_SECONDS, help="Sliding window size")
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 the same audio; transcripts are only emitted when both models agree. 'off' disables.")
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: a second (usually smaller) model transcribes the same
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
- # Audio buffer accumulates PCM16 as float32 @ 16kHz
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(chunk_bytes)
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(args.chunk_seconds)
490
+ time.sleep(BLOCK_SECONDS / 2)
266
491
 
267
492
  with buf_lock:
268
- if len(audio_buf) < SAMPLE_RATE: # Need at least 1s of audio
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
- # Final transcription of the full buffer
345
- with buf_lock:
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
- full_audio = amplify(full_audio)
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
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "omnius",
3
- "version": "1.0.423",
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.423",
9
+ "version": "1.0.425",
10
10
  "bundleDependencies": [
11
11
  "image-to-ascii"
12
12
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnius",
3
- "version": "1.0.423",
3
+ "version": "1.0.425",
4
4
  "description": "AI coding agent powered by open-source models (Ollama/vLLM) — interactive TUI with agentic tool-calling loop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",