Recently Written · git

desktop-subtitle-replay

git clone https://github.com/equwal/desktop-subtitle-replay

Log | Files | Refs


livecap.py (46121 bytes)

1 #!/usr/bin/env python3
2 """
3 livecap.py - live speech-to-captions for OBS.
4 
5 Captures audio (WASAPI loopback = "whatever your speakers play", or any mic),
6 segments it with an energy VAD, transcribes with faster-whisper, and publishes
7 captions over WebSocket to an OBS Browser Source overlay.
8 
9     python livecap.py --list-devices
10     python livecap.py --selftest 12
11     python livecap.py --lang fi --model small
12 """
13 
14 import argparse
15 import asyncio
16 import json
17 import os
18 import queue
19 import re
20 import sys
21 import threading
22 import time
23 import wave
24 from collections import deque
25 from functools import partial
26 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
27 from pathlib import Path
28 
29 import warnings
30 
31 import numpy as np
32 import soundcard as sc
33 from scipy.signal import resample_poly
34 
35 # Loopback capture reports gaps whenever the device goes idle. Harmless for
36 # speech recognition, but it otherwise floods the console during quiet moments.
37 try:
38     warnings.filterwarnings("ignore", category=sc.SoundcardRuntimeWarning)
39 except AttributeError:
40     warnings.filterwarnings("ignore", message="data discontinuity in recording")
41 
42 HERE = Path(__file__).resolve().parent
43 TARGET_SR = 16000
44 CAPTURE_SR = 48000     # WASAPI shared-mode mix rate on virtually every Windows box
45 BLOCK_MS = 32
46 
47 # Whisper invents these when fed silence or noise. Only applied to short results.
48 HALLUCINATIONS = [
49     # Finnish
50     r"^tekstitys",
51     r"^tekstityksen tuotti",
52     r"^k[aa]a?nn[oo]s",
53     r"^kiitos( kun katsoit| paljon| katsomisesta)?[.!]?$",
54     r"^suomennos",
55     # Japanese - Whisper emits these constantly over silence
56     r"ご(視聴|清聴)(いただき)?ありがとうございま",
57     r"チャンネル登録",
58     r"^字幕",
59     r"^おわり[。]?$",
60     r"^(お|ご)?しまい[。]?$",
61     r"^ありがとうございま(した|す)[。]?$",
62     r"^(えー|あー|ん|う)+[ー。]?$",
63     # Russian
64     r"^субтитры",
65     r"^спасибо за просмотр",
66     r"^продолжение следует",
67     r"^редактор субтитров",
68     # English / generic
69     r"^subtitles? by",
70     r"^amara\.org",
71     r"^thanks? for watching",
72     r"^please subscribe",
73     r"^\W*$",
74 ]
75 HALLUCINATION_RE = [re.compile(p, re.I) for p in HALLUCINATIONS]
76 
77 
78 # Captions are frequently non-Latin (ru, ja, ...). Windows otherwise encodes
79 # stdout with the console codepage, which mangles them once output is redirected.
80 for _s in (sys.stdout, sys.stderr):
81     try:
82         _s.reconfigure(encoding="utf-8", errors="replace")
83     except (AttributeError, ValueError):
84         pass
85 
86 
87 def log(*a):
88     print("[" + time.strftime("%H:%M:%S") + "]", *a, flush=True)
89 
90 
91 # ---------------------------------------------------------------- devices ---
92 
93 def candidates(loopback):
94     """Devices usable in the current mode, in a stable order."""
95     mics = sc.all_microphones(include_loopback=True)
96     return [m for m in mics if bool(m.isloopback) == bool(loopback)]
97 
98 
99 def list_devices():
100     print("\n=== LOOPBACK sources (default mode: captures what this device plays) ===")
101     for i, m in enumerate(candidates(True)):
102         print("  %2d  %s  (%dch)" % (i, m.name, m.channels))
103     try:
104         print("\n  default (used when you pass no --audio-device): %s"
105               % sc.default_speaker().name)
106     except Exception:
107         pass
108 
109     print("\n=== INPUT devices (--mic mode: microphones, virtual cables) ===")
110     for i, m in enumerate(candidates(False)):
111         print("  %2d  %s  (%dch)" % (i, m.name, m.channels))
112     try:
113         print("\n  default (--mic with no --audio-device): %s" % sc.default_microphone().name)
114     except Exception:
115         pass
116     print("\nSelect with --audio-device followed by the number above, or any\n"
117           "case-insensitive part of the name (e.g. --audio-device CABLE).\n")
118 
119 
120 def resolve_device(spec, loopback):
121     """spec: None, an index into the listing, or a substring of the device name."""
122     pool = candidates(loopback)
123     if not pool:
124         raise RuntimeError("No %s devices found." % ("loopback" if loopback else "input"))
125 
126     if spec is None:
127         try:
128             if loopback:
129                 return sc.get_microphone(sc.default_speaker().id, include_loopback=True)
130             return sc.default_microphone()
131         except Exception:
132             return pool[0]
133 
134     try:
135         idx = int(spec)
136     except ValueError:
137         pass
138     else:
139         if 0 <= idx < len(pool):
140             return pool[idx]
141         raise RuntimeError("Device index %d out of range (0-%d)." % (idx, len(pool) - 1))
142 
143     want = spec.lower()
144     for m in pool:
145         if want in m.name.lower():
146             return m
147     raise RuntimeError("No %s device matching %r. Run --list-devices."
148                        % ("loopback" if loopback else "input", spec))
149 
150 
151 # ---------------------------------------------------------------- capture ---
152 
153 class Capture:
154     """Pumps mono float32 blocks off a soundcard device onto a queue."""
155 
156     def __init__(self, mic, loopback=True):
157         self.mic = mic
158         self.name = mic.name
159         self.loopback = loopback
160         self.sr = CAPTURE_SR
161         self.channels = max(1, int(mic.channels))
162         self.blocksize = int(self.sr * BLOCK_MS / 1000)
163         self.q = queue.Queue(maxsize=256)
164         self.dropped = 0
165         self.error = None
166         self._stop = threading.Event()
167         self._ready = threading.Event()
168         self._thread = None
169 
170     def _pump(self):
171         try:
172             with self.mic.recorder(samplerate=self.sr, channels=self.channels,
173                                    blocksize=self.blocksize) as rec:
174                 self._ready.set()
175                 while not self._stop.is_set():
176                     data = rec.record(numframes=self.blocksize)
177                     if data.ndim > 1 and data.shape[1] > 1:
178                         mono = data.mean(axis=1)
179                     else:
180                         mono = data.reshape(-1)
181                     try:
182                         self.q.put_nowait(np.ascontiguousarray(mono, dtype=np.float32))
183                     except queue.Full:
184                         self.dropped += 1
185         except Exception as e:
186             self.error = e
187             log("capture failed:", repr(e))
188         finally:
189             self._ready.set()
190 
191     def __enter__(self):
192         self._thread = threading.Thread(target=self._pump, daemon=True)
193         self._thread.start()
194         self._ready.wait(timeout=10)
195         if self.error is not None:
196             raise self.error
197         log("capturing: %s  (%dch @ %dHz, %s)"
198             % (self.name, self.channels, self.sr, "loopback" if self.loopback else "input"))
199         return self
200 
201     def __exit__(self, *a):
202         self._stop.set()
203         if self._thread is not None:
204             self._thread.join(timeout=2)
205 
206 
207 def to_whisper(audio, sr):
208     """native-rate mono float32 -> 16 kHz float32, gently gain-staged."""
209     if sr != TARGET_SR:
210         g = int(np.gcd(sr, TARGET_SR))
211         audio = resample_poly(audio, TARGET_SR // g, sr // g)
212     audio = np.asarray(audio, dtype=np.float32)
213     peak = float(np.max(np.abs(audio))) if audio.size else 0.0
214     if 0.0 < peak < 0.35:
215         audio = audio * (0.35 / peak)
216     return np.clip(audio, -1.0, 1.0).astype(np.float32)
217 
218 
219 # -------------------------------------------------------------- publishing --
220 
221 class Bus:
222     def __init__(self, args):
223         self.args = args
224         self.loop = None
225         self.clients = set()
226         self.history = deque(maxlen=12)
227         self.finals = deque(maxlen=max(1, args.txt_lines))
228         self.txt = HERE / args.txt
229         self.logf = HERE / "captions.log"
230 
231     def attach(self, loop):
232         self.loop = loop
233 
234     def mark_session(self):
235         """A marker so the reader can show this session's history after a reload."""
236         try:
237             with self.logf.open("a", encoding="utf-8") as fh:
238                 fh.write("=== session %s ===\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
239         except OSError as e:
240             log("could not write captions.log:", e)
241 
242     def publish(self, msg):
243         if msg["type"] == "final":
244             self.history.append(msg)
245             self.finals.append(msg["text"])
246             self._write_files(msg)
247         if self.loop is not None:
248             payload = json.dumps(msg, ensure_ascii=False)
249             self.loop.call_soon_threadsafe(self._fanout, payload)
250 
251     def _fanout(self, payload):
252         for ws in list(self.clients):
253             try:
254                 asyncio.get_running_loop().create_task(ws.send(payload))
255             except Exception:
256                 self.clients.discard(ws)
257 
258     def _write_files(self, msg):
259         try:
260             self.txt.write_text("\n".join(self.finals) + "\n", encoding="utf-8")
261             line = time.strftime("%Y-%m-%d %H:%M:%S") + "\t" + msg["text"]
262             if msg.get("tr"):
263                 line += "\t|| " + msg["tr"]
264             with self.logf.open("a", encoding="utf-8") as fh:
265                 fh.write(line + "\n")
266         except OSError as e:
267             log("file write failed:", e)
268 
269 
270 async def ws_server(bus, args, ctl, stop):
271     import websockets
272 
273     async def handler(ws):
274         bus.clients.add(ws)
275         log("client connected (%d total)" % len(bus.clients))
276         try:
277             await ws.send(json.dumps(ctl.status(), ensure_ascii=False))
278             for m in list(bus.history)[-4:]:
279                 await ws.send(json.dumps(m, ensure_ascii=False))
280             async for raw in ws:
281                 ctl.handle(raw)
282         except Exception:
283             pass
284         finally:
285             bus.clients.discard(ws)
286             log("client disconnected (%d total)" % len(bus.clients))
287 
288     async with websockets.serve(handler, "127.0.0.1", args.ws_port, ping_interval=20):
289         log("websocket  ws://127.0.0.1:%d" % args.ws_port)
290         await stop.wait()
291 
292 
293 class QuietHandler(SimpleHTTPRequestHandler):
294     def log_message(self, *a):
295         pass
296 
297 
298 def http_server(args):
299     handler = partial(QuietHandler, directory=str(HERE))
300     srv = ThreadingHTTPServer(("127.0.0.1", args.http_port), handler)
301     threading.Thread(target=srv.serve_forever, daemon=True).start()
302     url = ("http://127.0.0.1:%d/overlay.html?ws=%d&lines=%d&size=%d&hide=%s"
303            % (args.http_port, args.ws_port, args.lines, args.size, args.hide))
304     if args.translate:
305         url += "&tr=1"
306     log("OBS Browser Source URL (copy this):")
307     log("  " + url)
308     log("Control panel (open in any browser):")
309     log("  http://127.0.0.1:%d/control.html?ws=%d" % (args.http_port, args.ws_port))
310     log("Reader for Yomitan mining (open in your real browser):")
311     log("  http://127.0.0.1:%d/reader.html?ws=%d" % (args.http_port, args.ws_port))
312     return srv
313 
314 
315 # ----------------------------------------------------------- transcription --
316 
317 class Controller:
318     """Applies live language/model changes coming from the control panel."""
319 
320     def __init__(self, args, bus):
321         self.args = args
322         self.bus = bus
323         self.tr = None                      # set once the Transcriber exists
324         self.cmds = queue.Queue()           # model swaps run on the worker thread
325         self.loading = False
326         self.detected = None                # what 'auto' has settled on
327 
328     def status(self):
329         return {
330             "type": "status",
331             "lang": self.args.lang,
332             "detected": self.detected,
333             "model": self.args.model,
334             "loading": self.loading,
335             "translate": bool(self.args.translate),
336             "partials": bool(self.args.partials),
337             "langs": self.args.langs,
338             "models": self.args.model_choices,
339         }
340 
341     def push_status(self):
342         self.bus.publish(self.status())
343 
344     def handle(self, raw):
345         try:
346             m = json.loads(raw)
347         except (ValueError, TypeError):
348             return
349         cmd, val = m.get("cmd"), m.get("value")
350 
351         if cmd == "set_lang":
352             if not valid_language(val):
353                 log("ignoring unknown language %r" % (val,))
354                 return
355             if val != self.args.lang:
356                 self.args.lang = val
357                 self.detected = None
358                 if self.tr is not None:
359                     self.tr.context = ""        # old-language prompt would poison it
360                     # Evidence gathered for a different language is worthless.
361                     self.tr.detector.reset()
362                     self.tr._last_detect = 0.0
363                 log("language -> %s" % val)
364                 self.bus.publish({"type": "clear"})
365             self.push_status()
366 
367         elif cmd == "set_model":
368             if val and val != self.args.model:
369                 self.cmds.put(("model", val))
370             else:
371                 self.push_status()
372 
373         elif cmd == "set_translate":
374             self.args.translate = bool(val)
375             log("translate -> %s" % self.args.translate)
376             self.push_status()
377 
378         elif cmd == "set_partials":
379             self.args.partials = bool(val)
380             log("partials -> %s" % self.args.partials)
381             self.push_status()
382 
383         elif cmd == "clear":
384             self.bus.publish({"type": "clear"})
385 
386         elif cmd == "test":
387             # Lets you position the OBS overlay without having to talk.
388             text = val if isinstance(val, str) and val.strip() else \
389                 "Testiteksti — проверка — テスト — caption preview"
390             self.bus.publish({"type": "final", "text": text, "tr": "",
391                               "ts": time.time()})
392 
393         elif cmd == "status":
394             self.push_status()
395 
396 
397 # Whisper spreads probability across close relatives. When the relative is not
398 # one of the languages in use, its mass belongs to the neighbour that is -
399 # otherwise a Finnish clip can lose to Finnish-plus-Estonian splitting the vote.
400 CONFUSABLE = {
401     "et": "fi",                                          # Estonian
402     "uk": "ru", "be": "ru", "bg": "ru", "mk": "ru",      # Cyrillic Slavic
403     "sr": "ru", "kk": "ru",
404     "gl": "pt",                                          # Galician leans Portuguese
405     "ca": "es", "oc": "es", "an": "es",                  # Iberian Romance
406     "cy": "en", "gd": "en",                              # frequent English mis-picks
407 }
408 
409 
410 class LanguageDetector:
411     """Language identification restricted to the languages actually in use.
412 
413     Three things make Whisper's own per-window detection unreliable here:
414     it may answer with any of 99 languages, it decides afresh every call so the
415     answer flaps mid-conversation, and short utterances carry little evidence.
416 
417     So: fold the distribution onto the allowed set, accumulate it over time with
418     a decay, and only switch when a challenger leads by a margin for several
419     observations in a row.
420     """
421 
422     def __init__(self, allowed, margin=1.3, hold=2, decay=0.55, min_audio=1.6,
423                  min_mass=0.5, min_confidence=0.55):
424         self.allowed = [c for c in allowed if c != "auto"] or ["en"]
425         self.margin = margin
426         self.hold = hold
427         self.decay = decay
428         self.min_audio = min_audio
429         # Restricting the distribution deletes the competitors, which inflates
430         # confidence: "en 0.38, ko 0.25, nn 0.10" becomes a commanding en 0.85.
431         # How much mass landed inside the allowed set is the honest signal of
432         # whether this audio is any of these languages at all.
433         self.min_mass = min_mass
434         self.min_confidence = min_confidence
435         self.scores = {c: 0.0 for c in self.allowed}
436         self.current = None
437         self._pending = None
438         self._pending_n = 0
439 
440     def reset(self):
441         self.scores = {c: 0.0 for c in self.allowed}
442         self.current = None
443         self._pending, self._pending_n = None, 0
444 
445     def fold(self, probs):
446         """Restrict the distribution to the allowed set, returning (dict, mass)."""
447         out = {c: 0.0 for c in self.allowed}
448         for lang, p in probs:
449             target = lang if lang in out else CONFUSABLE.get(lang)
450             if target in out:
451                 out[target] += p
452         return out, sum(out.values())
453 
454     def confidence(self):
455         total = sum(self.scores.values())
456         if total <= 0 or self.current is None:
457             return 0.0
458         return self.scores[self.current] / total
459 
460     def observe(self, probs, duration=3.0):
461         """Feed one detection result; returns the language to actually use."""
462         folded, mass = self.fold(probs)
463         if mass <= 0:
464             return self.current
465 
466         # Longer audio is better evidence than a two-word utterance, and mass
467         # outside the allowed set means this probably is not one of them.
468         reliability = min(1.0, mass / self.min_mass) if self.min_mass > 0 else 1.0
469         weight = max(0.05, min(1.0, duration / 3.0)) * reliability
470         for code in self.scores:
471             self.scores[code] = (self.scores[code] * self.decay
472                                  + (folded[code] / mass) * weight)
473 
474         best = max(self.scores, key=lambda c: self.scores[c])
475         if self.current is None:
476             # Adopting a language from one ambiguous reading is how a whole
477             # session ends up locked to the wrong one.
478             total = sum(self.scores.values())
479             share = self.scores[best] / total if total > 0 else 0.0
480             if mass >= self.min_mass and share >= self.min_confidence:
481                 self.current = best
482             return self.current
483 
484         leader, held = self.scores[best], self.scores[self.current]
485         if best != self.current and leader > held * self.margin:
486             self._pending_n = self._pending_n + 1 if self._pending == best else 1
487             self._pending = best
488             if self._pending_n >= self.hold:
489                 self.current = best
490                 self._pending, self._pending_n = None, 0
491         else:
492             self._pending, self._pending_n = None, 0
493         return self.current
494 
495 
496 def valid_language(code):
497     if code == "auto":
498         return True
499     if not isinstance(code, str) or not code:
500         return False
501     try:
502         from faster_whisper.tokenizer import _LANGUAGE_CODES
503         return code in _LANGUAGE_CODES
504     except Exception:
505         return bool(re.fullmatch(r"[a-z]{2,3}", code))
506 
507 
508 def collapse_looping_tail(s, min_reps=3):
509     """Trim a unit that repeats to the end of the string.
510 
511     The loop does not necessarily start at the beginning - "この日の日の日の日"
512     is "こ" followed by "の日" four times - so anchor the search at the end.
513     """
514     n = len(s)
515     for p in range(1, n // min_reps + 1):
516         unit = s[n - p:]
517         k = 0
518         while (k + 1) * p <= n and s[n - (k + 1) * p: n - k * p] == unit:
519             k += 1
520         if k >= min_reps:
521             return s[: n - (k - 1) * p]
522     return s
523 
524 
525 def collapse_repeats(text):
526     """Whisper degenerates into repeating a phrase; keep one copy.
527 
528     Two shapes show up: identical chunks joined from consecutive segments
529     ("A A A"), and a unit looped inside one chunk ("この日の日の日").
530     """
531     s = " ".join(text.split())
532     if not s:
533         return s
534 
535     out = []
536     for c in s.split(" "):
537         # Only whole phrases collapse on a single repeat; short words legitimately
538         # repeat ("very very good"), so those need three in a row.
539         run = 1 if len(c) >= 8 else 2
540         if len(out) >= run and all(x == c for x in out[-run:]):
541             continue
542         out.append(c)
543 
544     return " ".join(collapse_looping_tail(c) for c in out)
545 
546 
547 def is_repetitive(text):
548     """True when a caption is mostly one phrase repeated."""
549     s = " ".join(text.split())
550     if len(s) < 8:
551         return False
552     return len(collapse_repeats(s)) * 2 < len(s)
553 
554 
555 def looks_hallucinated(text):
556     t = text.strip().lower()
557     if len(t) > 40:
558         return False
559     return any(r.search(t) for r in HALLUCINATION_RE)
560 
561 
562 class Transcriber:
563     def __init__(self, args, bus):
564         from faster_whisper import WhisperModel
565 
566         self.args = args
567         self.bus = bus
568         log("loading model %r on %s/%s (first run downloads it)..."
569             % (args.model, args.compute_device, args.compute))
570         t0 = time.time()
571         self.model = WhisperModel(
572             args.model, device=args.compute_device, compute_type=args.compute,
573             cpu_threads=args.threads, num_workers=1)
574         log("model ready in %.1fs" % (time.time() - t0))
575         self.context = ""
576         self.busy = threading.Event()
577         self.detector = LanguageDetector(
578             args.langs, margin=args.detect_margin, hold=args.detect_hold,
579             min_audio=args.detect_min_audio)
580         self._last_detect = 0.0
581 
582     def pick_language(self, audio, duration, ctl=None):
583         """Resolve 'auto' to one of the configured languages.
584 
585         Detection runs on a schedule rather than every segment: it costs an
586         encoder pass, and once the answer is settled re-deciding constantly is
587         what makes it flap. Passing an explicit language into transcribe() also
588         skips Whisper's own internal detection, so this is close to free.
589         """
590         a = self.args
591         if a.lang != "auto":
592             return a.lang
593 
594         due = (self.detector.current is None
595                or time.time() - self._last_detect >= a.detect_every)
596         if due and duration >= self.detector.min_audio:
597             self._last_detect = time.time()
598             try:
599                 _, _, probs = self.model.detect_language(audio)
600             except Exception as e:
601                 log("language detection failed:", repr(e))
602                 return self.detector.current
603             before = self.detector.current
604             now = self.detector.observe(probs, duration)
605             if now != before:
606                 log("detected language: %s (confidence %.2f)"
607                     % (now, self.detector.confidence()))
608                 self.context = ""          # prompt from another language misleads
609                 if ctl is not None:
610                     ctl.detected = now
611                     ctl.push_status()
612         return self.detector.current
613 
614     def reload(self, name, ctl):
615         """Swap the model in place. Runs on the worker thread."""
616         from faster_whisper import WhisperModel
617 
618         a = self.args
619         ctl.loading = True
620         ctl.push_status()
621         log("loading model %r ..." % name)
622         t0 = time.time()
623         try:
624             new = WhisperModel(name, device=a.compute_device, compute_type=a.compute,
625                                cpu_threads=a.threads, num_workers=1)
626         except Exception as e:
627             log("model %r failed to load (%s); staying on %r" % (name, e, a.model))
628             ctl.loading = False
629             ctl.push_status()
630             return
631         old, self.model = self.model, new
632         del old
633         a.model = name
634         self.context = ""
635         ctl.loading = False
636         ctl.push_status()
637         log("model -> %s (%.1fs)" % (name, time.time() - t0))
638 
639     def run(self, audio, final, language=None):
640         a = self.args
641         lang = language or (None if a.lang == "auto" else a.lang)
642         segs, info = self.model.transcribe(
643             audio,
644             language=lang,
645             task="transcribe",
646             beam_size=a.beam if final else 1,
647             temperature=0.0,
648             condition_on_previous_text=False,
649             initial_prompt=(self.context or None) if (final and not a.no_context) else None,
650             vad_filter=final,
651             no_speech_threshold=0.6,
652             log_prob_threshold=-1.0,
653             repetition_penalty=a.repetition_penalty,
654             without_timestamps=True,
655         )
656         parts, nsp = [], []
657         for s in segs:
658             parts.append(s.text.strip())
659             nsp.append(getattr(s, "no_speech_prob", 0.0))
660         text = re.sub(r"\s+", " ", " ".join(parts)).strip()
661         return text, (max(nsp) if nsp else 1.0), info
662 
663     def translate(self, audio):
664         segs, _ = self.model.transcribe(
665             audio, language=None if self.args.lang == "auto" else self.args.lang,
666             task="translate", beam_size=1, temperature=0.0,
667             condition_on_previous_text=False, vad_filter=True, without_timestamps=True)
668         return re.sub(r"\s+", " ", " ".join(s.text.strip() for s in segs)).strip()
669 
670     def worker(self, jobs, stop, ctl):
671         while not stop.is_set():
672             # Model swaps take priority and must happen on this thread.
673             try:
674                 what, value = ctl.cmds.get_nowait()
675             except queue.Empty:
676                 pass
677             else:
678                 if what == "model":
679                     self.busy.set()
680                     try:
681                         while True:                 # drop audio queued for the old model
682                             jobs.get_nowait()
683                             jobs.task_done()
684                     except queue.Empty:
685                         pass
686                     try:
687                         self.reload(value, ctl)
688                     finally:
689                         self.busy.clear()
690                 continue
691 
692             try:
693                 kind, audio, sr = jobs.get(timeout=0.25)
694             except queue.Empty:
695                 continue
696             self.busy.set()
697             try:
698                 t0 = time.time()
699                 a16 = to_whisper(audio, sr)
700                 dur = len(a16) / TARGET_SR
701                 lang = (self.pick_language(a16, dur, ctl) if kind == "final"
702                         else self.detector.current)
703                 text, nsp, _ = self.run(a16, final=(kind == "final"), language=lang)
704                 if not text:
705                     if kind == "final":
706                         log("no speech recognised in %.1fs segment "
707                             "(music/noise, or wrong --lang)" % dur)
708                     continue
709                 if kind == "final":
710                     if nsp > 0.75 or looks_hallucinated(text):
711                         log("dropped (no_speech=%.2f): %r" % (nsp, text))
712                         continue
713                     looped = is_repetitive(text)
714                     text = collapse_repeats(text)
715                     if looped:
716                         # Feeding a looped caption back as the prompt is how the
717                         # loop sustains itself across segments.
718                         self.context = ""
719                     tr = self.translate(a16) if self.args.translate else ""
720                     if not self.args.no_context and not looped:
721                         self.context = (self.context + " " + text)[-220:]
722                     took = time.time() - t0
723                     log("FINAL %4.1fs audio in %4.1fs (rtf %.2f)  %s"
724                         % (dur, took, took / max(dur, 0.01), text))
725                     self.bus.publish({"type": "final", "text": text,
726                                       "tr": tr, "ts": time.time()})
727                 else:
728                     self.bus.publish({"type": "partial", "text": text, "ts": time.time()})
729             except Exception as e:
730                 log("transcribe error:", repr(e))
731             finally:
732                 self.busy.clear()
733                 jobs.task_done()
734 
735 
736 # ---------------------------------------------------------- segmentation ----
737 
738 SENT_END = (".", "!", "?", "…", "。", "!", "?")
739 
740 
741 def norm_word(w):
742     return re.sub(r"[^\w]", "", w.strip().lower(), flags=re.UNICODE)
743 
744 
745 class StreamDecoder:
746     """LocalAgreement streaming (Macháček et al.).
747 
748     Whisper cannot decode incrementally, so instead we re-decode a growing
749     buffer and only commit the prefix that two consecutive hypotheses agree
750     on. Agreement is a good proxy for stability: text that survives another
751     decode with more audio behind it rarely changes again. This trades CPU
752     for latency - words appear while someone is still talking, rather than
753     a whole sentence landing after they stop.
754     """
755 
756     def __init__(self, tr, bus, args):
757         self.tr, self.bus, self.args = tr, bus, args
758         self.native = []            # unconsumed audio at capture rate
759         self.prev = []              # previous hypothesis, uncommitted part
760         self.sentence = []          # committed words of the sentence in progress
761         self.speech = 0.0           # seconds of speech currently buffered
762 
763     # ---- audio -------------------------------------------------------
764     def add(self, block, voiced, dur):
765         self.native.append(block)
766         if voiced:
767             self.speech += dur
768 
769     def buffered_seconds(self, sr):
770         return sum(len(b) for b in self.native) / sr
771 
772     def _audio16(self, sr):
773         return to_whisper(np.concatenate(self.native), sr)
774 
775     def _trim(self, cut_s, sr):
776         """Drop audio up to cut_s seconds, keeping block boundaries simple."""
777         drop = int(cut_s * sr)
778         merged = np.concatenate(self.native)
779         merged = merged[min(drop, len(merged)):]
780         self.native = [merged] if len(merged) else []
781         self.speech = max(0.0, self.speech - cut_s)
782 
783     # ---- decoding ----------------------------------------------------
784     def _hypothesis(self, sr):
785         a = self.args
786         audio = self._audio16(sr)
787         # Whisper invents text when handed a very short buffer, and in
788         # streaming that invention gets committed before real audio arrives.
789         if len(audio) < int(a.stream_min_audio * TARGET_SR):
790             return [], audio
791         segs, _ = self.tr.model.transcribe(
792             audio,
793             language=None if a.lang == "auto" else a.lang,
794             beam_size=1,
795             temperature=0.0,
796             condition_on_previous_text=False,
797             initial_prompt=(self.tr.context or None) if not a.no_context else None,
798             vad_filter=False,
799             word_timestamps=True,
800             no_speech_threshold=0.6,
801             log_prob_threshold=-1.0,
802         )
803         words = []
804         for s in segs:
805             words.extend(getattr(s, "words", None) or [])
806         return words, audio
807 
808     def step(self, sr):
809         words, _ = self._hypothesis(sr)
810         if not words:
811             return
812 
813         k = 0
814         while (k < len(words) and k < len(self.prev)
815                and norm_word(words[k].word) == norm_word(self.prev[k].word)
816                and norm_word(words[k].word)):
817             k += 1
818 
819         confirmed, rest = words[:k], words[k:]
820         if confirmed:
821             cut = confirmed[-1].end
822             self.sentence.extend(confirmed)
823             self._trim(cut, sr)
824             # Remaining words are now measured against a shorter buffer.
825             for w in rest:
826                 w.start = max(0.0, w.start - cut)
827                 w.end = max(0.0, w.end - cut)
828         self.prev = rest
829 
830         text = self._text(self.sentence)
831         tail = self._text(rest)
832         if confirmed and text and text.strip().endswith(SENT_END):
833             self.flush()
834         elif text or tail:
835             self.bus.publish({"type": "partial",
836                               "text": (text + " " + tail).strip(),
837                               "ts": time.time()})
838 
839     @staticmethod
840     def _text(words):
841         return re.sub(r"\s+", " ", "".join(w.word for w in words)).strip()
842 
843     def flush(self, drop_audio=False):
844         """Emit the sentence built so far as a final caption."""
845         text = self._text(self.sentence)
846         self.sentence = []
847         if drop_audio:
848             self.native, self.prev, self.speech = [], [], 0.0
849         if not text or looks_hallucinated(text):
850             if text:
851                 log("dropped: %r" % text)
852             return
853         if not self.args.no_context:
854             self.tr.context = (self.tr.context + " " + text)[-220:]
855         log("FINAL  %s" % text)
856         self.bus.publish({"type": "final", "text": text, "tr": "", "ts": time.time()})
857 
858 
859 def stream_segmenter(cap, tr, args, stop, bus):
860     """Low-latency path: continuous re-decode with LocalAgreement commits."""
861     dur = cap.blocksize / cap.sr
862     noise = 1e-4
863     dec = StreamDecoder(tr, bus, args)
864     silence = 0.0
865     last_step = 0.0
866     log("streaming mode: committing on agreement every %.1fs" % args.stream_interval)
867 
868     while not stop.is_set():
869         drained = 0
870         while True:
871             try:
872                 blk = cap.q.get_nowait()
873             except queue.Empty:
874                 break
875             drained += 1
876             rms = float(np.sqrt(np.mean(blk * blk)) + 1e-12)
877             if rms < noise:
878                 noise = 0.90 * noise + 0.10 * rms
879             else:
880                 noise = 0.995 * noise + 0.005 * rms
881             voiced = rms > max(noise * args.vad_ratio, args.vad_floor)
882             silence = 0.0 if voiced else silence + dur
883             if voiced or dec.native:
884                 dec.add(blk, voiced, dur)
885 
886         if not drained:
887             time.sleep(0.02)
888 
889         now = time.time()
890         buffered = dec.buffered_seconds(cap.sr)
891 
892         # A long pause ends the sentence: commit whatever is left.
893         if dec.native and silence >= args.pause:
894             if dec.speech >= args.min_speech:
895                 dec.step(cap.sr)
896                 if dec.prev:
897                     dec.sentence.extend(dec.prev)
898                     dec.prev = []
899             dec.flush(drop_audio=True)
900             silence = 0.0
901             last_step = now
902             continue
903 
904         if buffered >= args.max_seg:
905             dec.step(cap.sr)
906             if dec.prev:
907                 dec.sentence.extend(dec.prev)
908                 dec.prev = []
909             dec.flush(drop_audio=True)
910             last_step = now
911             continue
912 
913         if (dec.speech >= args.min_speech
914                 and now - last_step >= args.stream_interval):
915             last_step = now
916             try:
917                 dec.step(cap.sr)
918             except Exception as e:
919                 log("stream decode error:", repr(e))
920 
921 
922 def segmenter(cap, tr, jobs, args, stop):
923     dur = cap.blocksize / cap.sr
924     noise = 1e-4
925     preroll = deque(maxlen=max(1, int(0.35 / dur)))
926     seg = []
927     speech_blocks = 0
928     silence = 0.0
929     last_partial = 0.0
930     last_meter = 0.0
931 
932     while not stop.is_set():
933         try:
934             blk = cap.q.get(timeout=0.3)
935         except queue.Empty:
936             continue
937 
938         rms = float(np.sqrt(np.mean(blk * blk)) + 1e-12)
939         if rms < noise:
940             noise = 0.90 * noise + 0.10 * rms
941         else:
942             noise = 0.995 * noise + 0.005 * rms
943         gate = max(noise * args.vad_ratio, args.vad_floor)
944         voiced = rms > gate
945 
946         if args.meter and time.time() - last_meter > 0.25:
947             last_meter = time.time()
948             db = 20 * np.log10(max(rms, 1e-9))
949             bars = int(np.clip((db + 60) / 60 * 40, 0, 40))
950             sys.stdout.write("\r%-40s %6.1f dBFS   gate %6.1f   %s"
951                              % ("#" * bars, db, 20 * np.log10(gate),
952                                 "VOICE" if voiced else "     "))
953             sys.stdout.flush()
954 
955         if not seg:
956             if not voiced:
957                 preroll.append(blk)
958                 continue
959             seg = list(preroll)
960             preroll.clear()
961 
962         seg.append(blk)
963         if voiced:
964             speech_blocks += 1
965             silence = 0.0
966         else:
967             silence += dur
968 
969         seg_dur = len(seg) * dur
970         speech_dur = speech_blocks * dur
971 
972         if speech_dur >= args.min_speech and (silence >= args.pause or seg_dur >= args.max_seg):
973             if args.verbose:
974                 log("segment queued: %.1fs (%.1fs of it speech)" % (seg_dur, speech_dur))
975             try:
976                 jobs.put_nowait(("final", np.concatenate(seg), cap.sr))
977             except queue.Full:
978                 log("backlog full - dropping a segment (model too slow for live)")
979             seg, speech_blocks, silence = [], 0, 0.0
980         elif silence >= args.pause:
981             seg, speech_blocks, silence = [], 0, 0.0          # noise blip
982         elif (args.partials and speech_dur >= 0.7
983               and time.time() - last_partial >= args.partial_every
984               and not tr.busy.is_set() and jobs.empty()):
985             last_partial = time.time()
986             try:
987                 jobs.put_nowait(("partial", np.concatenate(seg), cap.sr))
988             except queue.Full:
989                 pass
990 
991 
992 # -------------------------------------------------------------- selftest ----
993 
994 def selftest(args, seconds):
995     dev = resolve_device(args.audio_device, args.loopback)
996     with Capture(dev, args.loopback) as cap:
997         log("recording %ds -- play / speak Finnish audio NOW..." % seconds)
998         blocks, t0, peak = [], time.time(), 0.0
999         while time.time() - t0 < seconds:
1000             try:
1001                 b = cap.q.get(timeout=0.5)
1002             except queue.Empty:
1003                 continue
1004             blocks.append(b)
1005             peak = max(peak, float(np.max(np.abs(b))))
1006         sr = cap.sr
1007 
1008     if not blocks:
1009         log("NO AUDIO CAPTURED -- wrong device? Run --list-devices.")
1010         return 1
1011     audio = np.concatenate(blocks)
1012     log("captured %.1fs, peak %.1f dBFS" % (len(audio) / sr, 20 * np.log10(max(peak, 1e-9))))
1013     if peak < 0.001:
1014         log("WARNING: that is silence. Pick another device with --audio-device.")
1015 
1016     a16 = to_whisper(audio, sr)
1017     with wave.open(str(HERE / "selftest.wav"), "wb") as w:
1018         w.setnchannels(1)
1019         w.setsampwidth(2)
1020         w.setframerate(TARGET_SR)
1021         w.writeframes((a16 * 32767).astype(np.int16).tobytes())
1022     log("wrote selftest.wav -- play it to confirm you grabbed the right audio")
1023 
1024     t = Transcriber(args, Bus(args))
1025     t0 = time.time()
1026     text, nsp, info = t.run(a16, final=True)
1027     took = time.time() - t0
1028     rtf = took / max(len(a16) / TARGET_SR, 0.01)
1029     log("detected language=%s  no_speech=%.2f" % (getattr(info, "language", "?"), nsp))
1030     verdict = "FAST ENOUGH for live" if rtf < 0.6 else "TOO SLOW -- use a smaller --model"
1031     log("transcribed in %.1fs  ->  RTF %.2f  (%s)" % (took, rtf, verdict))
1032     print("\n  " + (text or "(nothing recognised)") + "\n")
1033     return 0
1034 
1035 
1036 # ------------------------------------------------------------------ main ----
1037 
1038 DEFAULT_MODEL = "small"
1039 FI_MODEL_DIR = Path("build") / "models" / "fi-small-ct2"
1040 
1041 
1042 def resolve_model(requested, lang, here):
1043     """Pick the model, returning (name, message-to-log-or-None).
1044 
1045     With nothing requested, prefer the Finnish fine-tune when it has been built
1046     and Finnish is what we are transcribing: same size and speed as small, but
1047     better Finnish. An explicit --model always wins, which is why the flag
1048     defaults to None rather than to "small".
1049     """
1050     if requested is not None:
1051         return requested, None
1052     local = Path(here) / FI_MODEL_DIR
1053     if lang == "fi" and (local / "model.bin").is_file():
1054         return str(local), "using the Finnish fine-tune (override with --model small)"
1055     return DEFAULT_MODEL, None
1056 
1057 
1058 def build_parser():
1059     p = argparse.ArgumentParser(
1060         description="Live captions for OBS",
1061         formatter_class=argparse.ArgumentDefaultsHelpFormatter)
1062 
1063     g = p.add_argument_group("audio")
1064     g.add_argument("--list-devices", action="store_true", help="show devices and exit")
1065     g.add_argument("--audio-device", default=None, help="index or name substring")
1066     g.add_argument("--loopback", dest="loopback", action="store_true", default=True,
1067                    help="capture desktop output (default)")
1068     g.add_argument("--mic", dest="loopback", action="store_false",
1069                    help="capture an input device instead of desktop output")
1070     g.add_argument("--meter", action="store_true", help="print a live level meter")
1071     g.add_argument("--verbose", action="store_true", help="log VAD segment decisions")
1072 
1073     g = p.add_argument_group("model")
1074     # Default is resolved in main() so an explicit --model small is
1075     # distinguishable from not passing --model at all.
1076     g.add_argument("--model", default=None,
1077                    help="tiny|base|small|medium|large-v3|large-v3-turbo, or a local "
1078                         "CT2 dir (default: small, or the Finnish fine-tune if built)")
1079     g.add_argument("--lang", default="fi", help="fi, ru, ja, es, pt, ... or 'auto'")
1080     g.add_argument("--langs", default="fi,ru,ja,es,pt,en,auto",
1081                    help="languages offered as one-click buttons in the control panel")
1082     g.add_argument("--model-choices", dest="model_choices",
1083                    default="tiny,base,small,medium",
1084                    help="models offered in the control panel dropdown")
1085     g.add_argument("--compute-device", default="cpu", choices=["cpu", "cuda"])
1086     g.add_argument("--compute", default="int8", help="int8|int8_float32|float32|float16")
1087     g.add_argument("--threads", type=int, default=max(2, (os.cpu_count() or 8) - 2))
1088     g.add_argument("--beam", type=int, default=5)
1089     g.add_argument("--detect-every", type=float, default=6.0,
1090                    help="seconds between language-detection passes when --lang auto")
1091     g.add_argument("--detect-min-audio", type=float, default=1.6,
1092                    help="skip detection on segments shorter than this")
1093     g.add_argument("--detect-margin", type=float, default=1.3,
1094                    help="how far a new language must lead before switching")
1095     g.add_argument("--detect-hold", type=int, default=2,
1096                    help="consecutive detections needed before switching")
1097     g.add_argument("--repetition-penalty", type=float, default=1.15,
1098                    help="discourage Whisper from looping on a phrase; "
1099                         "Japanese and Chinese need this more than European "
1100                         "languages. 1.0 disables it")
1101     g.add_argument("--translate", action="store_true",
1102                    help="also emit an English translation line")
1103     g.add_argument("--no-context", action="store_true",
1104                    help="do not feed previous text back as a prompt")
1105 
1106     g = p.add_argument_group("segmentation")
1107     g.add_argument("--pause", type=float, default=0.65, help="silence (s) that closes a caption")
1108     g.add_argument("--min-speech", type=float, default=0.45, help="min speech (s) worth sending")
1109     g.add_argument("--max-seg", type=float, default=11.0, help="force a cut after this many s")
1110     g.add_argument("--vad-floor", type=float, default=0.004, help="absolute RMS gate")
1111     g.add_argument("--vad-ratio", type=float, default=3.0, help="gate = noise floor * this")
1112     g.add_argument("--partials", dest="partials", action="store_true", default=True)
1113     g.add_argument("--no-partials", dest="partials", action="store_false",
1114                    help="only show finished sentences (lower CPU)")
1115     g.add_argument("--partial-every", type=float, default=0.9)
1116     g.add_argument("--stream", action="store_true",
1117                    help="LocalAgreement streaming: words appear while someone is "
1118                         "still talking instead of after they stop. Costs a lot "
1119                         "more CPU - pair it with a smaller --model")
1120     g.add_argument("--stream-interval", type=float, default=0.8,
1121                    help="how often to re-decode the buffer in --stream mode")
1122     g.add_argument("--stream-min-audio", type=float, default=1.5,
1123                    help="do not decode until this much audio is buffered; "
1124                         "shorter buffers make Whisper hallucinate")
1125 
1126     g = p.add_argument_group("output")
1127     g.add_argument("--ws-port", type=int, default=8765)
1128     g.add_argument("--http-port", type=int, default=8777)
1129     g.add_argument("--txt", default="captions.txt", help="plain-text file for a GDI+ text source")
1130     g.add_argument("--txt-lines", type=int, default=2)
1131     g.add_argument("--lines", type=int, default=2, help="lines shown in the overlay")
1132     g.add_argument("--size", type=int, default=42, help="overlay font size in px")
1133     g.add_argument("--hide", type=float, default=8, help="auto-hide overlay after N idle seconds")
1134 
1135     p.add_argument("--selftest", nargs="?", type=int, const=12, default=None, metavar="SECONDS",
1136                    help="record N seconds, transcribe once, report speed, then exit")
1137     return p
1138 
1139 
1140 def main():
1141     args = build_parser().parse_args()
1142 
1143     chosen, why = resolve_model(args.model, args.lang, HERE)
1144     args.model = chosen
1145     if why:
1146         log(why)
1147 
1148     args.langs = [s.strip() for s in args.langs.split(",") if s.strip()]
1149     args.model_choices = [s.strip() for s in args.model_choices.split(",") if s.strip()]
1150     if args.model not in args.model_choices:
1151         args.model_choices.insert(0, args.model)
1152     if args.lang not in args.langs:
1153         args.langs.insert(0, args.lang)
1154 
1155     if args.list_devices:
1156         list_devices()
1157         return 0
1158     if args.selftest is not None:
1159         return selftest(args, args.selftest)
1160 
1161     bus = Bus(args)
1162     stop_ev = threading.Event()
1163     jobs = queue.Queue(maxsize=3)
1164 
1165     bus.mark_session()
1166     ctl = Controller(args, bus)
1167     tr = Transcriber(args, bus)
1168     ctl.tr = tr
1169     dev = resolve_device(args.audio_device, args.loopback)
1170 
1171     srv = http_server(args)
1172     loop = asyncio.new_event_loop()
1173     bus.attach(loop)
1174     ws_stop = asyncio.Event()
1175 
1176     def run_loop():
1177         asyncio.set_event_loop(loop)
1178         loop.run_until_complete(ws_server(bus, args, ctl, ws_stop))
1179 
1180     threading.Thread(target=run_loop, daemon=True).start()
1181     if not args.stream:
1182         threading.Thread(target=tr.worker, args=(jobs, stop_ev, ctl),
1183                          daemon=True).start()
1184 
1185     try:
1186         with Capture(dev, args.loopback) as cap:
1187             log("lang=%s  model=%s  -- Ctrl+C to stop" % (args.lang, args.model))
1188             if args.stream:
1189                 stream_segmenter(cap, tr, args, stop_ev, bus)
1190             else:
1191                 segmenter(cap, tr, jobs, args, stop_ev)
1192     except KeyboardInterrupt:
1193         print()
1194         log("stopping")
1195     finally:
1196         stop_ev.set()
1197         loop.call_soon_threadsafe(ws_stop.set)
1198         srv.shutdown()
1199     return 0
1200 
1201 
1202 if __name__ == "__main__":
1203     sys.exit(main())