omnius 1.0.422 → 1.0.424

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,42 @@ 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 full scale. Whisper performs far
290
+ better near normal levels; quiet capture chains (distant speakers, low
291
+ source gain, beamformed arrays) otherwise force users to shout into the
292
+ mic. Gain capped at 40x (~32dB), output clipped to [-1, 1]."""
293
+ peak = float(np.max(np.abs(samples))) if len(samples) else 0.0
294
+ if 0 < peak < 0.5:
295
+ samples = np.clip(samples * min(40.0, 0.9 / max(peak, 1e-4)), -1.0, 1.0)
296
+ return samples
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Main transcription loop — utterance FSM
300
+ # ---------------------------------------------------------------------------
301
+
170
302
  def main():
171
303
  import argparse
172
304
  parser = argparse.ArgumentParser(description="Live Whisper transcription worker")
173
305
  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")
306
+ # Accepted for backward compatibility with older launchers (sliding-window era).
307
+ parser.add_argument("--chunk-seconds", type=float, default=3, help=argparse.SUPPRESS)
308
+ parser.add_argument("--window-seconds", type=float, default=10, help=argparse.SUPPRESS)
176
309
  parser.add_argument("--language", default=None, help="Language code (e.g. en, es, fr). Auto-detect if omitted.")
177
310
  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.")
311
+ help="Second whisper model run in parallel on finalized utterances; transcripts are only emitted when both models agree. 'off' disables.")
179
312
  parser.add_argument("--consensus-threshold", type=float, default=0.55,
180
313
  help="Token similarity (0-1) required between the two models' transcripts.")
314
+ parser.add_argument("--silence-ms", type=float, default=900,
315
+ help="Sustained silence that finalizes an utterance.")
316
+ parser.add_argument("--preroll-ms", type=float, default=600,
317
+ help="Audio kept from before speech onset.")
318
+ parser.add_argument("--max-utterance-seconds", type=float, default=22,
319
+ help="Force-finalize utterances longer than this.")
181
320
  args = parser.parse_args()
182
321
 
183
322
  import whisper
184
323
 
185
- # Load model
186
324
  emit_status(f"Loading Whisper {args.model} model...")
187
325
  try:
188
326
  device = "cpu"
@@ -192,14 +330,12 @@ def main():
192
330
  device = "cuda"
193
331
  except ImportError:
194
332
  pass
195
-
196
333
  model = whisper.load_model(args.model, device=device)
197
334
  except Exception as e:
198
335
  emit_error(f"Failed to load model: {e}")
199
336
  sys.exit(1)
200
337
 
201
- # Consensus model: a second (usually smaller) model transcribes the same
202
- # window in parallel. Hallucinations (mixed-language garbage on noisy or
338
+ # Consensus model: hallucinations (mixed-language garbage on noisy or
203
339
  # quiet audio) don't reproduce across models, so disagreement = reject.
204
340
  consensus_model = None
205
341
  consensus_name = (args.consensus_model or "off").strip().lower()
@@ -211,43 +347,36 @@ def main():
211
347
  emit_status(f"Consensus model unavailable ({e}); running single-model.")
212
348
  consensus_model = None
213
349
 
350
+ # ReSpeaker DSP: hardware VAD + echo cancellation + DOA
351
+ tuning = find_respeaker()
352
+ if tuning is not None:
353
+ emit_status("ReSpeaker DSP active: hardware VAD, echo cancellation, DOA.")
354
+
214
355
  emit({"type": "ready"})
215
356
 
216
- # Audio buffer accumulates PCM16 as float32 @ 16kHz
357
+ fp16 = (device == "cuda")
358
+
359
+ def run_transcribe(m, samples):
360
+ return m.transcribe(
361
+ samples,
362
+ fp16=fp16,
363
+ language=args.language,
364
+ no_speech_threshold=0.6,
365
+ condition_on_previous_text=False,
366
+ )
367
+
368
+ # Shared PCM intake
217
369
  audio_buf = np.zeros(0, dtype=np.float32)
218
370
  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
371
  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
372
 
242
373
  def read_stdin():
243
- """Read PCM16 from stdin in a background thread."""
244
374
  nonlocal audio_buf, running
245
375
  try:
246
376
  while running:
247
- data = sys.stdin.buffer.read(chunk_bytes)
377
+ data = sys.stdin.buffer.read(BLOCK_SAMPLES * SAMPLE_WIDTH)
248
378
  if not data:
249
379
  break # EOF
250
- # Convert PCM16 LE to float32 [-1, 1]
251
380
  samples = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
252
381
  with buf_lock:
253
382
  audio_buf = np.concatenate([audio_buf, samples])
@@ -256,112 +385,158 @@ def main():
256
385
  finally:
257
386
  running = False
258
387
 
259
- # Start stdin reader thread
260
388
  reader = threading.Thread(target=read_stdin, daemon=True)
261
389
  reader.start()
262
390
 
391
+ # Utterance FSM state
392
+ preroll_blocks = max(1, int(round(args.preroll_ms / 1000.0 / BLOCK_SECONDS)))
393
+ finalize_blocks = max(2, int(round(args.silence_ms / 1000.0 / BLOCK_SECONDS)))
394
+ max_utter_samples = int(args.max_utterance_seconds * SAMPLE_RATE)
395
+
396
+ preroll = deque(maxlen=preroll_blocks)
397
+ utterance = [] # list of float32 blocks while capturing
398
+ capturing = False
399
+ silence_run = 0
400
+ noise_floor = None
401
+ last_partial_at = 0.0
402
+ last_final_text = ""
403
+ last_vad_emit = (False, 0.0)
404
+ pending = np.zeros(0, dtype=np.float32)
405
+ cursor = 0
406
+
407
+ def energy_speech(block_rms):
408
+ nonlocal noise_floor
409
+ if noise_floor is None:
410
+ noise_floor = block_rms
411
+ elif block_rms < noise_floor:
412
+ noise_floor = 0.7 * noise_floor + 0.3 * block_rms # drop fast
413
+ else:
414
+ noise_floor = 0.995 * noise_floor + 0.005 * block_rms # creep up slowly
415
+ gate = max(1.5e-4, noise_floor * 2.5) # ≥ ~8dB above floor
416
+ return block_rms >= gate
417
+
418
+ def transcribe_partial(samples):
419
+ try:
420
+ result = run_transcribe(model, amplify(samples))
421
+ text = _segment_filtered_text(result)
422
+ if text:
423
+ emit_transcript(text, is_final=False)
424
+ except Exception as e:
425
+ emit_error(f"Transcription error: {e}")
426
+
427
+ def transcribe_final(samples):
428
+ nonlocal last_final_text
429
+ if len(samples) < int(0.4 * SAMPLE_RATE):
430
+ return
431
+ samples = amplify(samples)
432
+ try:
433
+ if consensus_model is not None:
434
+ from concurrent.futures import ThreadPoolExecutor
435
+ with ThreadPoolExecutor(max_workers=2) as pool:
436
+ primary_future = pool.submit(run_transcribe, model, samples)
437
+ secondary_future = pool.submit(run_transcribe, consensus_model, samples)
438
+ result = primary_future.result()
439
+ secondary = secondary_future.result()
440
+ else:
441
+ result = run_transcribe(model, samples)
442
+ secondary = None
443
+
444
+ text = _segment_filtered_text(result)
445
+ if not text:
446
+ return
447
+ if secondary is not None:
448
+ secondary_text = _segment_filtered_text(secondary)
449
+ lang_a = str(result.get("language", "")).lower()
450
+ lang_b = str(secondary.get("language", "")).lower()
451
+ lang_disagree = bool(lang_a and lang_b and lang_a != lang_b and not args.language)
452
+ if lang_disagree or not _texts_agree(text, secondary_text, args.consensus_threshold):
453
+ emit({
454
+ "type": "consensus_rejected",
455
+ "primary": text[:200],
456
+ "secondary": (secondary_text or "")[:200],
457
+ "reason": "language_mismatch" if lang_disagree else "low_similarity",
458
+ })
459
+ return
460
+ if text == last_final_text:
461
+ return
462
+ last_final_text = text
463
+ emit_transcript(text, is_final=True, doa=tuning.doa() if tuning else None)
464
+ except Exception as e:
465
+ emit_error(f"Transcription error: {e}")
466
+
263
467
  try:
264
468
  while running:
265
- time.sleep(args.chunk_seconds)
469
+ time.sleep(BLOCK_SECONDS / 2)
266
470
 
267
471
  with buf_lock:
268
- if len(audio_buf) < SAMPLE_RATE: # Need at least 1s of audio
472
+ if len(audio_buf) > cursor:
473
+ pending = np.concatenate([pending, audio_buf[cursor:]])
474
+ cursor = len(audio_buf)
475
+ # Bound memory: keep the buffer from growing without limit.
476
+ if len(audio_buf) > 120 * SAMPLE_RATE:
477
+ audio_buf = audio_buf[-SAMPLE_RATE:]
478
+ cursor = len(audio_buf)
479
+
480
+ while len(pending) >= BLOCK_SAMPLES:
481
+ block = pending[:BLOCK_SAMPLES]
482
+ pending = pending[BLOCK_SAMPLES:]
483
+
484
+ block_rms = float(np.sqrt(np.mean(block ** 2)))
485
+ energy = energy_speech(block_rms)
486
+ hw = tuning.speech_detected() if tuning is not None else None
487
+ if tuning is not None and tuning.failures > 5:
488
+ # Repeated USB read failures (unplug, permission loss) —
489
+ # stop polling so control-transfer timeouts can't stall
490
+ # the FSM; energy VAD takes over.
491
+ emit_status("ReSpeaker DSP unresponsive — falling back to energy VAD.")
492
+ tuning.close()
493
+ tuning = None
494
+ hw = None
495
+ # Hardware VAD is authoritative when the DSP answers; energy
496
+ # VAD covers non-ReSpeaker mics and USB read failures.
497
+ speech = hw if hw is not None else energy
498
+
499
+ now = time.time()
500
+ if speech != last_vad_emit[0] and now - last_vad_emit[1] > 0.3:
501
+ last_vad_emit = (speech, now)
502
+ emit({"type": "vad", "speech": bool(speech), "doa": tuning.doa() if tuning else None})
503
+
504
+ if not capturing:
505
+ preroll.append(block)
506
+ if speech:
507
+ capturing = True
508
+ silence_run = 0
509
+ utterance = list(preroll)
510
+ preroll.clear()
269
511
  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
512
 
513
+ utterance.append(block)
514
+ if speech:
515
+ silence_run = 0
516
+ else:
517
+ silence_run += 1
518
+
519
+ utter_samples = sum(len(b) for b in utterance)
520
+ if silence_run >= finalize_blocks or utter_samples >= max_utter_samples:
521
+ samples = np.concatenate(utterance)
522
+ capturing = False
523
+ utterance = []
524
+ silence_run = 0
525
+ transcribe_final(samples)
526
+ elif utter_samples >= int(3.5 * SAMPLE_RATE) and now - last_partial_at >= 2.5:
527
+ last_partial_at = now
528
+ transcribe_partial(np.concatenate(utterance))
341
529
  except KeyboardInterrupt:
342
530
  pass
343
531
 
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:
532
+ # EOF: finalize any in-flight utterance.
533
+ if utterance:
351
534
  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)
535
+ transcribe_final(np.concatenate(utterance))
362
536
  except Exception:
363
537
  pass
364
-
538
+ if tuning is not None:
539
+ tuning.close()
365
540
  running = False
366
541
 
367
542
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "omnius",
3
- "version": "1.0.422",
3
+ "version": "1.0.424",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "omnius",
9
- "version": "1.0.422",
9
+ "version": "1.0.424",
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.422",
3
+ "version": "1.0.424",
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",