diff --git a/murmurflow/cli.py b/murmurflow/cli.py index a45bf3e..f302263 100644 --- a/murmurflow/cli.py +++ b/murmurflow/cli.py @@ -21,7 +21,7 @@ import urllib.request from pathlib import Path -from . import config, dictate, hotkey, platforms, service, whisper +from . import config, dictate, hotkey, platforms, service, speech, whisper def _out(line: str = "") -> None: @@ -131,7 +131,7 @@ def _update_command(receipt: Path) -> list[str] | None: A LOCAL directory is re-installed from that directory, because that is what a `git pull` just changed. Anything else (a git URL, PyPI) is an `upgrade`, which re-resolves it for itself. """ - uv = dictate.resolve_bin("uv") + uv = speech.resolve_bin("uv") if not uv: return None try: @@ -323,9 +323,9 @@ def _doctor(*, verbs: bool = False) -> int: Ordered the way it actually fails: no recorder, no transcriber, no model, no permission. """ rows: list[tuple[bool, str, str]] = [] - ffmpeg = dictate.resolve_bin("ffmpeg") + ffmpeg = speech.resolve_bin("ffmpeg") rows.append((bool(ffmpeg), f"recorder: {ffmpeg or 'ffmpeg NOT FOUND'}", "brew install ffmpeg")) - server = dictate.resolve_bin("whisper-server") + server = speech.resolve_bin("whisper-server") binary = whisper.found_binary() rows.append( ( @@ -662,7 +662,7 @@ def _reject(key: str, value: object) -> str: f"server takes the port one above this one." ) elif key == "language": - code = dictate.language_code(text) + code = speech.language_code(text) if code != "auto" and not (len(code) == 2 and code.isalpha()): return ( f"`{text}` is not a language. Use `auto`, or a two-letter code like `en` or `de` " @@ -673,7 +673,7 @@ def _reject(key: str, value: object) -> str: bad = [ str(v).strip() for v in entries - if not (len(code := dictate.language_code(str(v))) == 2 and code.isalpha()) + if not (len(code := speech.language_code(str(v))) == 2 and code.isalpha()) ] if bad: return ( @@ -725,16 +725,16 @@ def _warn(key: str, value: object) -> str: # Pin `en`, then have `["de"]` in `languages`, and EVERY clip is thrown away as a language # you do not speak — the trigger works, the microphone works, and nothing ever appears. spoken = dictate.spoken_languages() - if spoken and dictate.language_code(text) not in spoken: + if spoken and speech.language_code(text) not in spoken: return ( f"`languages` says you speak {', '.join(sorted(spoken))}, so every clip decoded as " - f"{dictate.language_code(text)} would be thrown away. Add it there, or unset it." + f"{speech.language_code(text)} would be thrown away. Add it there, or unset it." ) if key == "polishCommand" and text: program = "" with contextlib.suppress(ValueError, IndexError): program = shlex.split(text)[0] - if program and not dictate.resolve_bin(program): + if program and not speech.resolve_bin(program): return ( f"`{program}` is not on PATH, so polish would fail and degrade to the plain " "transcript on every sentence." diff --git a/murmurflow/dictate.py b/murmurflow/dictate.py index 649e74d..6beae58 100644 --- a/murmurflow/dictate.py +++ b/murmurflow/dictate.py @@ -45,15 +45,12 @@ from __future__ import annotations -import array import contextlib import difflib import json -import math import os import re import shutil -import signal import subprocess import sys import tempfile @@ -61,18 +58,53 @@ import time import urllib.error import urllib.request -import uuid import wave from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple -from . import config, platforms, whisper +from . import config, platforms, speech, whisper + +# ONE COPY, TWO TOOLS. Everything below is the same file in zyx's `core.speech` - byte for byte, +# checked by a digest in both repos and copied by one command (`make voice-sync`, run from zyx). +# It is the layer that is true of AUDIO and of a TRANSCRIPT rather than of either product: this +# module was extracted from zyx's `core.dictate`, the two drifted for three weeks, and the same two +# bugs had to be found twice. Re-exported rather than reached through the module, so every call +# site and every test that says `dictate.X` keeps saying it. +from .speech import ( # noqa: F401 + AUTO_STOP_SECONDS, + BYTES_PER_SECOND, + MAX_CLIP_SECONDS, + MIN_CLIP_SECONDS, + MIN_HEADER_BYTES, + NO_SPEECH, + QUIET_DBFS, + SAMPLE_RATE, + SILENCE_STOP_SECONDS, + SILENT_DBFS, + SPEECH_CONFIDENCE, + TOO_SHORT, + TRIM_BLOCK_SECONDS, + TRIM_KEEP_SECONDS, + Heard, + Recording, + Setup, + audio_seconds, + data_offset, + is_hallucination, + join_segments, + language_code, + multipart, + peak_dbfs, + repair_punctuation, + repair_wav, + resolve_bin, + strip_trailing_hallucination, + tail_dbfs, +) # Homebrew's bin dirs. launchd hands an agent a minimal PATH that excludes them, so a bare # shutil.which() finds nothing when the listener runs from a plist while working fine in a shell # (the TUNNEL-PATH-1 lesson, generalized here rather than re-learned). -_FALLBACK_BIN_DIRS: tuple[str, ...] = ("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin") # The mic to actually speak into. A default CoreAudio device is often an aggregate # ("Push3 + Z1 + Volt2" — a music interface), so ":default" would record the wrong input entirely. @@ -95,21 +127,6 @@ _PROC: subprocess.Popen[bytes] | None = None -def resolve_bin(name: str) -> str: - """Absolute path to ``name``, searching PATH then Homebrew's dirs; ``''`` if absent. - - Never raises. The fallback dirs matter only under launchd (see :data:`_FALLBACK_BIN_DIRS`). - """ - found = shutil.which(name) - if found: - return found - for directory in _FALLBACK_BIN_DIRS: - candidate = Path(directory) / name - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - return "" - - def available() -> tuple[bool, str]: """``(ready, hint)`` — whether dictation can run, and the ONE command that fixes it if not. @@ -241,18 +258,8 @@ def resolve_input() -> tuple[str, str]: global _INPUT_CACHE, _INPUT_CACHED_AT if _INPUT_CACHE is not None and time.monotonic() - _INPUT_CACHED_AT < INPUT_CACHE_SECONDS: return _INPUT_CACHE - want = input_name().lower() devices = list_inputs() - resolved = (platforms.default_input(), "system default") - for index, name in devices: - if want and want in name.lower(): - resolved = (str(index), name) - break - else: - for index, name in devices: # name miss: prefer a real mic over an aggregate interface - if "microphone" in name.lower() or "mikrofon" in name.lower(): - resolved = (str(index), name) - break + resolved = speech.pick_input(input_name(), devices, default=platforms.default_input()) if devices: # never cache a failed enumeration — ffmpeg may simply not have been ready _INPUT_CACHE, _INPUT_CACHED_AT = resolved, time.monotonic() return resolved @@ -261,366 +268,13 @@ def resolve_input() -> tuple[str, str]: # --- capture ---------------------------------------------------------------------------------- -@dataclass(frozen=True) -class Recording: - """An in-flight capture: the ffmpeg pid and the wav it is writing.""" - - pid: int - wav: Path - started_at: float - - @property - def seconds(self) -> float: - return max(0.0, time.time() - self.started_at) - - -def current() -> Recording | None: - """The in-flight recording, or ``None``. Stale markers (dead pid) are cleaned up and ignored.""" - path = state_path() - try: - raw = json.loads(path.read_text("utf-8")) - rec = Recording(int(raw["pid"]), Path(raw["wav"]), float(raw["started_at"])) - except (OSError, ValueError, KeyError, TypeError): - return None - try: - os.kill(rec.pid, 0) # signal 0 = liveness probe, kills nothing - except (OSError, ProcessLookupError): - path.unlink(missing_ok=True) - return None - return rec - - -def start() -> Recording | None: - """Begin capturing the mic to a fresh 16kHz mono wav; ``None`` if already recording or unable. - - Returns as soon as ffmpeg is spawned — CoreAudio needs ~300ms more before the first sample - actually lands (measured; it is a device-start floor, not an ffmpeg tax, so a compiled helper - would not beat it). Callers that cue the user should cue on :func:`ready`, not on this - return, or the first word is clipped. - """ - if current() is not None: - return None - ffmpeg = resolve_bin("ffmpeg") - if not ffmpeg: - return None - index, _ = resolve_input() - wav = _scratch_dir() / f"dictate-{int(time.time())}-{uuid.uuid4().hex[:8]}.wav" - cmd = [ - ffmpeg, - "-nostdin", - "-loglevel", - "error", - # A HARD CEILING on one clip, and it is a privacy control, not a convenience. ffmpeg is - # spawned with `start_new_session=True` so it survives its parent: kill the daemon (launchd - # restart, a crash, `kickstart -k`) while a clip is running and nothing ever stops it. Found - # live in development — THREE orphaned recorders, 4.5 hours each, 1.4 GB of recorded - # voice on disk and the microphone hot the whole time, which is the exact incident this - # product exists not to cause. `-t` makes the recorder bound its own life, with no - # supervisor needed and nothing to remember. - "-t", - str(MAX_CLIP_SECONDS), - *platforms.capture_args(index), - # ffmpeg's avfoundation input keeps exactly ONE pending audio buffer and releases the - # previous one whenever a new buffer arrives before its reader has taken it, so a little - # scheduling jitter silently costs samples. Measured here: ~11% of every capture, on the - # built-in mic, on an aggregate interface AND on a pure-software loopback with no hardware - # clock at all — so it is the input device implementation, not the microphone. It is also - # unreachable from the CLI: identical loss whether ffmpeg resamples and converts or copies - # raw bytes, and `-thread_queue_size` changes nothing. - # - # The damage is not the missing samples themselves but WHERE the hole goes. ffmpeg takes - # the timestamps from the buffers it did get, so the gap is spliced out and the whole - # sentence is handed to whisper ~11% too fast. `async=1` fills the gaps instead of closing - # them, which keeps the clip on real time (measured 87% -> 98% of the hold) and stops - # speech being sped up. It cannot bring the lost samples back; recovering those means - # leaving avfoundation for a ctypes CoreAudio recorder, which is not worth it while - # transcripts are this good. - "-af", - "aresample=async=1", - "-ar", - "16000", - "-ac", - "1", - # Write every packet straight through instead of buffering. Without this ffmpeg holds ~2s - # of audio in memory before the file grows, so `ready()` cannot tell that the mic went live - # until long after it did — so the "start talking" cue arrives two seconds late, by which - # point half the sentence has already been said into a microphone that was not listening. - "-flush_packets", - "1", - "-y", - str(wav), - ] - try: - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, # survive the caller; we stop it explicitly by pid - ) - except (OSError, subprocess.SubprocessError): - return None - global _PROC - _PROC = proc # so stop() can REAP it — see the zombie note there - rec = Recording(proc.pid, wav, time.time()) - with contextlib.suppress(OSError): - state_path().write_text( - json.dumps({"pid": rec.pid, "wav": str(rec.wav), "started_at": rec.started_at}), - "utf-8", - ) - return rec - - -def ready(rec: Recording, *, timeout: float = 2.0) -> bool: - """Block until the wav has actual audio frames in it (or ``timeout``). ``True`` if it does. - - The ~300ms CoreAudio start-up is invisible only if the cue fires when the mic is - genuinely live. A 44-byte wav is a header with no samples yet. - """ - deadline = time.time() + timeout - while time.time() < deadline: - try: - if rec.wav.stat().st_size > 1024: - return True - except OSError: - pass - time.sleep(0.02) - return False - - -def _exited(pid: int) -> bool: - """True once the recorder with ``pid`` is really gone — zombies included. - - ``os.kill(pid, 0)`` is NOT enough: a child that has exited but not been reaped is a zombie, and - signalling a zombie SUCCEEDS. Polling it therefore never observes the exit, which cost a flat - two seconds on every single dictation (measured: ffmpeg itself is gone in ~34ms) before the - loop gave up and SIGKILLed a process that had been dead the whole time. When the recorder is - our own child we reap it with ``waitpid``; when it is not (``toggle`` starts it in one CLI - invocation and stops it in another) no zombie can exist for us, so signal-0 is accurate. - """ - proc = _PROC - if proc is not None and proc.pid == pid: - return proc.poll() is not None - try: - os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - pass # not our child: signal-0 below is authoritative - except OSError: - return True - try: - os.kill(pid, 0) - except (OSError, ProcessLookupError): - return True - return False - - -def repair_wav(wav: Path) -> bool: - """Patch a RIFF header whose lengths were never written. ``True`` if it had to. Never raises. - - **ffmpeg writes the real lengths when it EXITS, and it does not always get to.** SIGKILL on the - fallback path in :func:`stop` is one way; Windows is the other, and there it is not a fallback - but the only path — ``os.kill`` there cannot deliver anything gentler than ``TerminateProcess`` - for a signal that is not CTRL_C/CTRL_BREAK, and a daemon started by ``pythonw`` has no console - to send those through. - - Measured rather than assumed, because the guess was wrong and the wrong guess would have been - a scary comment about a bug that does not exist. A killed ffmpeg leaves ``0xFFFFFFFF`` in both - size fields, not zero, so the audio still decodes — ``-flush_packets 1`` already wrote every - sample (see :func:`start`) and the readers stop at the end of the file. What breaks is - :func:`audio_seconds`, which believes the header and reports **37 hours** of captured audio: - that number is the entire capture-fault diagnostic (see the daemon loop, where a clip shorter - than the hold is how a throttled agent recording a quarter of every sentence was found), and it - goes silently blind on exactly the clips something already went wrong with. - - Cheaper than what it protects: a stat and twelve bytes on the ordinary path, where the header - is already right and this returns immediately. - """ - try: - size = wav.stat().st_size - if size <= 44: # a header and nothing else — there is nothing to rescue - return False - with wav.open("r+b") as handle: - if handle.read(4) != b"RIFF": - return False - handle.seek(8) - if handle.read(4) != b"WAVE": - return False - offset = 12 - while offset + 8 <= size: - handle.seek(offset) - name = handle.read(4) - declared = int.from_bytes(handle.read(4), "little") - if name == b"data": - actual = size - (offset + 8) - if declared and declared <= actual: - return False # the trailer was written; leave it exactly as it is - handle.seek(offset + 4) - handle.write(actual.to_bytes(4, "little")) - handle.seek(4) - handle.write((size - 8).to_bytes(4, "little")) - return True - if declared <= 0: - return False # a chunk with no length: the walk cannot go on honestly - offset += 8 + declared + (declared % 2) # chunks are padded to an even length - except (OSError, ValueError): - return False - return False - - -def stop(rec: Recording | None = None) -> Path | None: - """Stop the in-flight capture and return the finished wav (``None`` if nothing was recording). - - SIGINT (not SIGKILL) so ffmpeg writes the RIFF trailer — a killed ffmpeg leaves a wav whose - header claims zero length and whisper decodes it as silence. This sits directly on the felt - latency (it runs the instant the key is released), so exit is detected by REAPING - the child rather than polling it — see :func:`_exited`. - """ - global _PROC - rec = rec or current() - if rec is None: - return None - with contextlib.suppress(OSError, ProcessLookupError): - os.kill(rec.pid, signal.SIGINT) - deadline = time.time() + 2.0 - while time.time() < deadline: - if _exited(rec.pid): - break - time.sleep(0.01) - else: # never exited — force it, the trailer is lost but a truncated wav still decodes - with contextlib.suppress(OSError, ProcessLookupError): - os.kill(rec.pid, signal.SIGKILL) - if _PROC is not None and _PROC.pid == rec.pid: - _PROC = None - _clear_state_for(rec.pid) - if not rec.wav.is_file(): - return None - repair_wav(rec.wav) # a recorder that had to be killed still leaves a decodable clip - return rec.wav - - -def reap_orphans() -> int: - """Stop every recorder left behind by a previous run, and delete its audio. Returns how many. - - A recorder is spawned with ``start_new_session=True`` so it outlives the call that started it — - which also means it outlives the DAEMON. A launchd restart, a crash or a ``kickstart -k`` in the - middle of a clip leaves ffmpeg running forever with the microphone open: found live on the - machine as three recorders, 4.5 hours each, 1.4 GB of recorded voice on disk. `-t` - (:data:`MAX_CLIP_SECONDS`) bounds the damage; this ends it, because a daemon that is only now - starting cannot own a clip from before it existed. - - Matched on the exact scratch-path pattern this module writes, so no other ffmpeg on the machine - is ever a candidate. Best-effort and silent on any failure — a reaper must never keep the daemon - from starting. - """ - scratch = _scratch_dir() - reaped = 0 - try: - pattern = f"-y {scratch}/dictate-" - found = subprocess.run( - # `--` IS LOad-BEARING, and its absence is why this reaper never reaped anything. The - # pattern begins with `-y`, so without the guard pgrep parses it as its own option and - # exits with "illegal option -- y" before matching a single process. It failed silently - # in exactly the shape this function is written to tolerate — empty stdout, no raise — - # so every daemon start reported nothing to reap while an orphan held the microphone - # open. Found live: one recorder open since 4:03pm, writing - # to a wav that had already been deleted, with the mic indicator lit the whole time. - ["pgrep", "-f", "--", pattern], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - pids = [int(line) for line in found.stdout.split() if line.strip().isdigit()] - for pid in pids: - if pid == os.getpid(): - continue - with contextlib.suppress(OSError, ProcessLookupError): - os.kill(pid, signal.SIGINT) # SIGINT, so the wav still gets its RIFF trailer - reaped += 1 - except (OSError, subprocess.SubprocessError, ValueError): - return reaped - # The audio goes too: a clip nobody is waiting for has no transcription to outlive, and the - # contract is that your voice does not sit on disk (`keepAudio` keeps ONE file, - # deliberately, and it is not named like these). - with contextlib.suppress(OSError): - for wav in scratch.glob("dictate-*.wav"): - wav.unlink(missing_ok=True) - state_path().unlink(missing_ok=True) - return reaped - - -def _clear_state_for(pid: int) -> None: - """Drop the in-flight marker, but ONLY if it still describes ``pid``. - - A second surface may answer on a worker thread while a new clip starts, which means the NEXT - recording can already be running by the time the previous one is stopped. Unlinking - unconditionally orphaned it — ffmpeg still capturing, ``current()`` reporting nothing — so the - clip already in flight could never be finished. An unreadable marker is cleared, - since a marker nobody can parse is worse than none. - """ - try: - raw = json.loads(state_path().read_text("utf-8")) - if int(raw["pid"]) != pid: - return - except (OSError, ValueError, KeyError, TypeError): - pass - state_path().unlink(missing_ok=True) +#: What a wav header is when nothing else is in it: `RIFF....WAVE` + a 16-byte `fmt ` chunk. +#: A FLOOR, never the answer — see :func:`data_offset`. # --- warm transcription ----------------------------------------------------------------------- -def server_url() -> str: - return f"http://127.0.0.1:{port()}" - - -def server_up() -> bool: - """True if a warm whisper-server answers on the loopback port.""" - try: - with urllib.request.urlopen(f"{server_url()}/", timeout=0.5): - return True - except (urllib.error.URLError, OSError): - return False - - -#: How long an ownership answer is trusted for. See :func:`ours` — the question is "who holds this -#: port", which changes only when a process starts or dies, and asking it costs a `pgrep`. -OWNERSHIP_SECONDS = 30.0 - -_OWNERSHIP: dict[int, tuple[float, bool]] = {} - - -def ours() -> bool: - """Is the thing listening on our port a whisper-server, rather than whatever got there first. - - **Because the answer decides where recorded audio is sent.** The port is predictable, so any - local process can bind it first, receive every clip, and answer with text that gets typed at - the cursor. A socket that accepts a connection proves nothing about who is on the other end. - - So the port has to be held by a `whisper-server` process. Only one process can bind a port, so - finding one there IS the answer. Cached for :data:`OWNERSHIP_SECONDS` because this sits on the - partial path, which asks it about once a second, and `pgrep` is a process spawn. - """ - at = port() - now = time.monotonic() - cached = _OWNERSHIP.get(at) - if cached is not None and now - cached[0] < OWNERSHIP_SECONDS: - return cached[1] - try: - found = subprocess.run( - ["pgrep", "-f", f"whisper-server.*--port {at}"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - verdict = bool(found.stdout.split()) - except (OSError, subprocess.SubprocessError): - verdict = False # cannot tell: fail CLOSED, because the cost of being wrong is the audio - _OWNERSHIP[at] = (now, verdict) - return verdict - - def server_answers() -> tuple[bool, str]: """Does the warm server actually TRANSCRIBE — ``(ok, what went wrong)``. Never raises. @@ -645,7 +299,7 @@ def server_answers() -> tuple[bool, str]: handle.setsampwidth(2) handle.setframerate(16000) handle.writeframes(b"\x00" * 6400) # 0.2s of digital silence - body, content_type = _multipart(probe, {"response_format": "json", "temperature": "0"}) + body, content_type = multipart(probe, {"response_format": "json", "temperature": "0"}) request = urllib.request.Request( f"{server_url()}/inference", data=body, headers={"Content-Type": content_type} ) @@ -662,272 +316,98 @@ def server_answers() -> tuple[bool, str]: return False, str(error)[:120] -def serve_command(model: str = "") -> list[str] | None: - """The argv that starts a warm whisper-server, or ``None`` if it cannot be built. +# --- the recorder, in this install's vocabulary -------------------------------------------------- +# +# The engine below is byte-identical with zyx's copy and knows nothing about avfoundation or dshow: +# `capture` is this platform's own input arguments with the device already resolved, which is +# exactly the seam that made the Windows port four files instead of a fork. - ONE server, and it answers both the live passes and the final transcription. There used to be - a second one holding a small model for the partials; it was retired when the live pass began - typing punctuation, because the marks it chose were the marks the operator kept. - """ - binary = resolve_bin("whisper-server") - model = model or whisper.model() - if not binary or not model: - return None - return [ - binary, - "-m", - model, - "--host", - "127.0.0.1", - "--port", - str(port()), - "-t", - whisper.threads(), - "--convert", # let the server transcode anything ffmpeg reads, not just wav - # Stop whisper emitting "*sad*"/"[MUSIC]"-style sound annotations at the SOURCE rather than - # filtering them afterwards. `is_hallucination` stays as the backstop: -sns reduces these - # but does not eliminate them. - "-sns", - # EVERY CLIP IS ITS OWN CLIP. whisper.cpp keeps the text it decoded as context for what it - # decodes next, and a SERVER keeps it across REQUESTS — so yesterday's sentence primes - # today's, and under streaming, where one dictation is ~100 overlapping passes over the - # same growing audio, it primes itself with a hundred near-copies of what it just said. - # Measured on a 145s clip, same audio, same prompt, twice in a row: 543 characters one - # run and 1108 the next, one of them collapsing into "And. Your. Job as a founder." nine - # times over. That is the report — "a lot of points in between, it cuts the logic of the - # sentence" — and it is also why the same words came out well before the stream existed. - # `-mc 0` stores no text context, and the same two runs then came back CHARACTER FOR - # CHARACTER identical, in whole clauses, with no repetition: "...that fits your workflow, - # that you connect with that company, you like how they do things, and then go from there." - # - # What it costs is real and small: past 30s whisper decodes each window without the - # previous window's words to lean on. A dictation is one window, the vocabulary prompt is - # sent per request and still applies, and an unstable transcript is not worth a smoother - # seam at 0:30. - "-mc", - "0", - ] +def current() -> Recording | None: + """The in-flight recording, or ``None``. Stale markers (dead pid) are cleaned up and ignored.""" + return speech.current(state_path()) -def start_server(*, wait: float = 60.0) -> bool: - """Spawn the warm whisper-server if it is not already up; block until it answers. - - Loading large-v3-turbo takes a few seconds, which is exactly the cost we are paying ONCE here - so that every subsequent dictation does not. Returns True if a server is answering. - - **The ``cwd`` is the whole warm path**, and leaving it out is how MurmurFlow quietly lost it. - ``--convert`` (see :func:`serve_command`) makes whisper-server shell out to ffmpeg, and ffmpeg - writes its converted copy into the server's WORKING DIRECTORY. Started from a shell that - directory is the repo and everything works, which is why this survived every manual test. Under - the installed agent the daemon inherits ``/`` — not writable — so the conversion fails, the - server answers **every single request** with ``500 {"error":"FFmpeg conversion failed."}``, and - every clip silently falls through to the cold CLI. Measured live 2026-08-18 with one server on - ``/`` and one on a writable dir, same binary, same flags, same model, same request: 500 in 0.03s - against a clean transcript in 2.17s. - - Nothing on screen says so. The transcripts still arrive, just slower and — because the cold path - carries no server-side prompt and reports no confidence — measurably worse, with the two silence - gates weakened to boot. `watch_warm` in :func:`listen_loop` faithfully bounced the server after - every second cold clip, all day, into the same broken working directory. - - So the server is started in :func:`_scratch_dir`, which is ours, writable, and already the one - place recorded voice lives — whisper-server deletes its converted copy when it is done, so the - "empty between sentences" promise there still holds. - """ - if server_up(): - # Adopted only if a whisper-server is what is holding the port — see :func:`ours`. Anything - # else answering there would be handed recorded audio and believed about what was said. - return ours() - cmd = serve_command() - if cmd is None: - return False - try: - subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - cwd=str(_scratch_dir()), - ) - except (OSError, subprocess.SubprocessError): - return False - deadline = time.time() + wait - while time.time() < deadline: - if server_up(): - return True - time.sleep(0.1) - return False +def start() -> Recording | None: + """Capture the mic to a fresh 16kHz mono wav; ``None`` if already recording or unable.""" + device, _name = resolve_input() + return speech.start( + ffmpeg=resolve_bin("ffmpeg"), + capture=platforms.capture_args(device), + scratch=_scratch_dir(), + state=state_path(), + ) -def stop_server() -> int: - """Stop the warm whisper-server this install started. Returns how many were stopped. - The port ABOVE ours is swept too, and it is not a second server of ours: an older MurmurFlow - ran a small model there for the live pass, and a version that no longer starts one must still - stop the one it finds, or ~488 MB stays resident until the machine is next restarted. +def ready(rec: Recording, *, timeout: float = 2.0) -> bool: + """Block until the wav has actual audio frames in it (or ``timeout``). ``True`` if it does.""" + return speech.ready(rec, timeout=timeout) + + +def stop(rec: Recording | None = None) -> Path | None: + """Stop the capture and return the finished wav (``None`` if nothing was recording).""" + return speech.stop(rec, state=state_path()) + + +def reap_orphans() -> int: + """Stop every recorder left behind by a previous run, and delete its audio. Returns how many.""" + return speech.reap_orphans(scratch=_scratch_dir(), state=state_path()) - ``start_server`` detaches it with ``start_new_session=True`` so it outlives the listener, which - is the whole point while dictation is installed — and a leak the moment it is not: 1.8 GB - resident with nothing left to ask it anything, until the next reboot. BOTH of ours, matched on - OUR two ports, because a whisper-server on any other port belongs to somebody else. + +def _setup(model: str = "") -> speech.Setup: + """This install's answer to every question the shared engine asks. See :class:`speech.Setup`. + + ONE place where MurmurFlow's vocabulary meets the engine's, so nothing below reads a setting and + the engine can stay one file - byte-identical with zyx's copy of it. """ - stopped = 0 - for which in (port(), port() + 1): - try: - found = subprocess.run( - ["pgrep", "-f", f"whisper-server.*--port {which}"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - except (OSError, subprocess.SubprocessError): - continue - for token in found.stdout.split(): - with contextlib.suppress(ValueError, ProcessLookupError, PermissionError): - os.kill(int(token), signal.SIGTERM) - stopped += 1 - return stopped - - -def _multipart(wav: Path, fields: dict[str, str]) -> tuple[bytes, str]: - """Build a multipart/form-data body for whisper-server's ``/inference`` (stdlib only).""" - boundary = f"----murmurflow{uuid.uuid4().hex}" - parts: list[bytes] = [] - for key, value in fields.items(): - parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n' - f"{value}\r\n".encode() - ) - parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="file"; ' - f'filename="{wav.name}"\r\nContent-Type: audio/wav\r\n\r\n'.encode() + return speech.Setup( + whisper_server=resolve_bin("whisper-server"), + model=model or whisper.model(), + threads=whisper.threads(), + port=port(), + scratch=_scratch_dir(), + vocabulary=whisper.vocabulary(), + language=whisper.language(), ) - parts.append(wav.read_bytes()) - parts.append(f"\r\n--{boundary}--\r\n".encode()) - return b"".join(parts), f"multipart/form-data; boundary={boundary}" - - -# An older whisper-server answers with the language NAME and no `language_probabilities` to read a -# code off. Only the names anybody actually lists in `languages` need to be here; anything else -# falls through as itself, and an unrecognised language is compared as whisper spelled it. -_LANGUAGE_CODES: dict[str, str] = { - "english": "en", - "german": "de", - "french": "fr", - "spanish": "es", - "italian": "it", - "dutch": "nl", - "portuguese": "pt", - "polish": "pl", - "russian": "ru", - "japanese": "ja", - "chinese": "zh", - "korean": "ko", -} -def language_code(name: str) -> str: - """``"german"``, ``"German"`` and ``"de"`` are one answer; anything else passes through as itself. +def server_url() -> str: + return speech.server_url(port()) - One seam, so the gate that reads what whisper detected and the gate that reads what the user - said they speak can never disagree about what a language is called. - """ - key = str(name).strip().lower() - return _LANGUAGE_CODES.get(key, key) +def server_up() -> bool: + """True if anything answers on the loopback port - see :func:`ours` for WHO.""" + return speech.server_up(port()) -class Heard(NamedTuple): - """A transcript plus how sure whisper was that it was listening to speech at all. - ``confidence`` is whisper's own ``detected_language_probability``: how strongly the audio looks - like ANY one human language. Speech scores 0.97-0.999 (measured: a 1.2s English slice 0.969, a - single "Okay." 0.981, a German sentence 0.999); room tone, pink noise and digital silence score - 0.32-0.45, because there is no language in them to be sure about. It is ``1.0`` — deliberately - "certain" — whenever the signal is unavailable (cold path, forced language, older server), so a - missing score can never silently swallow something that was said. See :data:`SPEECH_CONFIDENCE`. - """ +def ours() -> bool: + """Is a whisper-server what holds our port. See :func:`speech.ours`.""" + return speech.ours(port()) - text: str - confidence: float = 1.0 - language: str = "" - #: Did the WARM server answer this one? A wedged server is invisible otherwise — it answers - #: every request with an error, every clip quietly takes the cold path, and the two gates that - #: read a confidence and a language are weaker there. That degradation has to be legible in the - #: log, or the next person to hit it is also debugging blind. ``None`` = nothing was decoded. - warm: bool | None = None +def forget_ownership() -> None: + """Drop the cached ownership verdict (a server was just started or stopped).""" + speech.forget_ownership() -def _confidence(payload: str) -> tuple[str, float, str]: - """Pull ``(text, detected_language_probability, language)`` out of verbose_json. Never raises. - ``strict=False`` is load-bearing, not defensive dressing: whisper-server puts the transcript's - trailing newline into the JSON string RAW, which is invalid JSON that ``json.loads`` rejects - outright. A strict parse fails on exactly the short utterances this gate exists to judge. - """ - try: - data = json.loads(payload, strict=False) - except (ValueError, TypeError): - # Not JSON at all — an older server answering a verbose_json request with plain text. Take - # it as the transcript and claim no opinion rather than discarding a real sentence. - return payload.strip(), 1.0, "" - if not isinstance(data, dict): - return payload.strip(), 1.0, "" - text = str(data.get("text", "")).strip() - raw = data.get("detected_language_probability") - # A CODE, NEVER THE NAME. whisper-server reports `"language": "english"` while a person writes - # `["de", "en"]` in their config, so the gate below compared "english" against {"de","en"} and - # would have rejected every sentence he ever spoke the moment the warm server came back up — - # a gate that is inert today and catastrophic tomorrow. `language_probabilities` is keyed by - # the codes themselves, so the top key IS the answer with no table to keep in sync. - probabilities = data.get("language_probabilities") - spoken = "" - if isinstance(probabilities, dict) and probabilities: - numeric = {k: v for k, v in probabilities.items() if isinstance(v, (int, float))} - if numeric: - spoken = str(max(numeric, key=lambda k: numeric[k])).strip().lower() - if not spoken: - spoken = language_code(str(data.get("language", "") or "")) - return text, float(raw) if isinstance(raw, (int, float)) else 1.0, spoken +def serve_command(model: str = "") -> list[str] | None: + """The argv that starts the warm whisper-server, or ``None`` if it cannot be built.""" + return speech.serve_command(_setup(model)) -def transcribe_warm(wav: Path, *, timeout: float = 60.0, language: str = "") -> Heard: - """Transcribe via the warm server; empty text if it is not up or errors. Never raises. +def start_server(*, wait: float = 60.0) -> bool: + """Spawn the warm whisper-server if it is not already up; block until it answers.""" + return speech.start_server(_setup(), wait=wait) - Reuses :mod:`whisper`'s language and vocabulary decisions, so every surface that transcribes - hears your own proper nouns the same way. - Asks for ``verbose_json`` rather than ``text`` purely to get the language score back; the - transcript is identical either way. +def stop_server() -> int: + """Stop the warm whisper-server this install started. Returns how many were stopped.""" + return speech.stop_server(port()) - ``language`` overrides the configured one for THIS request, and it exists for streaming. - Detecting the language is a whole extra encoder pass — measured at 0.75s of every 2.2s request - on an M4 Pro — and a clip does not change language halfway through, so the partials after the - first pin themselves to what the first one heard. See :func:`_stream_loop`. - """ - if not wav.is_file(): - return Heard("") - fields = { - "response_format": "verbose_json", - "language": language or whisper.language(), - "prompt": whisper.vocabulary(), - "temperature": "0", - } - try: - body, content_type = _multipart(wav, fields) - except OSError: - return Heard("") - request = urllib.request.Request( - f"{server_url()}/inference", data=body, headers={"Content-Type": content_type} - ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - payload = response.read().decode("utf-8", errors="ignore") - except (urllib.error.URLError, OSError, ValueError): - return Heard("") - return Heard(*_confidence(payload), warm=True) +def transcribe_warm(wav: Path, *, timeout: float = 60.0, language: str = "") -> Heard: + """Transcribe via the warm server; empty text if it is not up or errors. Never raises.""" + return speech.transcribe_warm(wav, _setup(), timeout=timeout, language=language) def transcribe(wav: Path, *, timeout: float = 60.0) -> Heard: @@ -989,46 +469,6 @@ def strip_fillers(transcript: str) -> str: # A clip this short cannot contain a word — it is a brushed key or an aborted chord. Transcribing # it wastes a second and, worse, invites the hallucination below. -MIN_CLIP_SECONDS = 0.4 - -# The one problem that is NOT worth a sound: see the daemon's release handler. -TOO_SHORT = "too short" - -# The clip was long enough and loud enough to be a sentence, but there was no speech in it. ONE -# string for every way we reach that conclusion (whisper's language score, the boilerplate word -# list, an empty transcript) because callers act on it rather than print it: the huddle counts -# consecutive occurrences to decide when to stop trusting the microphone. Kept in plain words — -# nobody cares which of the three traps fired. -NO_SPEECH = "I didn't hear anything" - -# The longest single clip the recorder will ever produce (see the `-t` flag in `start`). Ten minutes -# is far beyond any real hold — the longest measured real dictation is ~60s — and short -# enough that an orphaned recorder costs ~20 MB and ten minutes of open microphone instead of hours. -MAX_CLIP_SECONDS = 600 - -# How long the daemon lets a clip run before it closes the microphone ITSELF and types what was -# said. `MAX_CLIP_SECONDS` above is the recorder's own fuse and stays where it is: it bounds an -# ORPHAN, one nobody is waiting for, and it throws the audio away. This one is the opposite case — -# the operator is right there, he simply forgot the second tap ("what happens a lot of times is -# that I forget to close the microphone"), and the right answer is not to discard two minutes of -# his voice but to finish the clip exactly as the tap would have. Two minutes because the longest -# real dictation measured on this machine is ~68s; `maxHold` moves it. -AUTO_STOP_SECONDS = 120.0 - -# How long the microphone stays open with nothing being said before the daemon closes it and types -# what it has. The operator asked for it and left the number to me ("I don't know what is a good -# timing, you have to decide that yourself"), so: fifteen seconds. -# -# Dictation apps that stop on silence sit around two to three, and two to three is WRONG HERE. The -# gesture is a tap, not a held key, so nothing is telling the microphone you are still there; and -# this operator thinks mid-sentence — the pauses that produced every punctuation bug in this file -# were real, and a clip cut at three seconds would have ended half of them mid-thought, with the -# rest of the sentence spoken into a closed microphone. That failure is worse than the one being -# fixed, because a forgotten microphone loses nothing and a truncated sentence loses the sentence. -# Fifteen is longer than any pause measured here and still closes a forgotten one in a quarter of -# a minute rather than two. `silenceStop` moves it; 0 switches it off. -SILENCE_STOP_SECONDS = 15.0 - # What whisper emits when handed near-silence: it does not return "", it confidently returns one of # its training-set boilerplate lines. Untrapped, these get TYPED INTO YOUR DOCUMENT, which # is the worst failure this tool has — silence should produce nothing, never words you did not @@ -1044,166 +484,9 @@ def strip_fillers(transcript: str) -> str: # What stays is only what a person does NOT say: subtitle-rip credits and literal audio markers. # The bias is deliberate and one-directional — a hallucination that slips through is visible and # deletable, while a real sentence that is swallowed is invisible and looks like broken hardware. -_HALLUCINATIONS: frozenset[str] = frozenset( - { - "thanks for watching!", - "thanks for watching.", - "[blank_audio]", - "(silence)", - "untertitel von stephanie geiges", - "untertitel der amara.org-community", - "untertitelung aufgrund der amara.org-community", - "amara.org", - # THE SAME BOILERPLATE, IN THE LANGUAGES IT INVENTS. Whisper does not answer silence with - # nothing; it answers with the credit line of whatever it was trained on, and which - # language that lands in is a coin toss. Live: a 1.8s desk bump came back as - # "ご視聴ありがとうございました" (thank you for watching) and was typed into a terminal. - # The structural guards above it are the real fix; this is the list for the exact strings - # already seen, and it costs nothing to carry. - "ご視聴ありがとうございました", - "ご視聴ありがとうございます", - "おやすみなさい", - "字幕by索兰娅", - "字幕由amara.org社区提供", - "字幕志愿者 李宗盛", - "请不吝点赞 订阅 转发 打赏支持明镜与点点栏目", - "多谢您的观看", - "감사합니다", - "구독과 좋아요 부탁드립니다", - "sous-titres réalisés par la communauté d'amara.org", - "subtítulos realizados por la comunidad de amara.org", - "sottotitoli e revisione a cura di qtss", - "legendas pela comunidade amara.org", - } -) - - -# Whisper also annotates NON-SPEECH sound rather than returning nothing: "*sad*", "[MUSIC]", -# "(wind blowing)", "♪♪♪". Observed live in a quiet room (it produced "*sad*"). These -# are an open CLASS, not a word list — a blocklist would need a new entry forever — so the whole -# class is matched structurally: a transcript that is ENTIRELY one bracketed/asterisked annotation -# was a description of a sound, not something you said, and must never be typed. -_ANNOTATION_ONLY = re.compile(r"^[\s♪]*[\*\[\(]([^\]\)\*]*)[\*\]\)][\s♪.]*$") - -# An annotation is a LABEL ("sad", "wind blowing", "MUSIC"), never a sentence. Without this cap the -# trap also eats a real dictated line that happens to be fully parenthesised — "(That said, ship it -# anyway.)" — which is the worse bug of the two: a hallucination that slips through is visible and -# deletable, whereas silently swallowing what you actually said looks like the mic failed. -# Bias deliberately toward letting text through. -_MAX_ANNOTATION_WORDS = 3 - - -def is_hallucination(text: str) -> bool: - """True if ``text`` is whisper's output for silence rather than something that was said. - - Three shapes: the fixed boilerplate lines it emits for pure silence, a transcript of nothing - but music notes, and the open class of non-speech ANNOTATIONS it emits for room noise. All - three must be trapped — any of them typed into your document is a word you did not say. - """ - stripped = text.strip().lower().strip("♪ ") - # Bare "♪♪♪" with no brackets around it, which the annotation pattern below cannot match - # because that one requires a bracket or an asterisk. Once the notes and the whitespace are - # removed there is nothing left, so there was no speech in the clip. - if not stripped: - return bool(text.strip()) - if stripped in _HALLUCINATIONS: - return True - match = _ANNOTATION_ONLY.match(text.strip()) - if match is None: - return False - return len(match.group(1).split()) <= _MAX_ANNOTATION_WORDS - - -_SPACE_BEFORE_PUNCT = re.compile(r"\s+([,.!?;:])") -_DOUBLED_PUNCT = re.compile(r"([,;:])\s*([.!?])") -# A filler strip can leave the sentence dangling on the comma that preceded it ("...fixed, you -# know." -> "...fixed,"). Invisible on a Slack echo; sloppy when it is typed into a document. -_DANGLING_TAIL = re.compile(r"[,;:]+\s*$") - -# whisper puts a `\n` at every SEGMENT boundary, and a segment ends where the decoder's budget ran -# out — a TOKEN boundary, which is not a word boundary. When the seam lands inside a word the next -# segment starts with NO leading space ("...zyxworks.gith" + "ub.io"), and flattening every seam to -# a space is what typed "zyxworks.gith ub.io" and "z yxworks.com" into a real dictation. -# -# Whisper's own spacing is the signal, and reading it costs nothing: whisper carries a word's -# leading space inside the token, so a genuine word boundary always has whitespace on one side of -# the seam. None on either side means the word was cut in half — close that seam with nothing. -_SEGMENT_SEAM = re.compile(r"([^\S\n]*)\n+([^\S\n]*)") - -# Sentence end, for the trailing-boilerplate trap below. Deliberately crude: it only has to find -# the seam between "...make it public." and an appended "Thanks for watching!". -_SENTENCE_END = re.compile(r"(?<=[.!?])\s+") - - -def join_segments(transcript: str) -> str: - """Flatten whisper's per-segment newlines WITHOUT inventing a space in the middle of a word.""" - return _SEGMENT_SEAM.sub(lambda seam: " " if seam.group(1) or seam.group(2) else "", transcript) - - -#: Polite closings whisper invents for the pause between your last word and your hand. -#: -#: **Trailing only, and that is the whole reason this is a second list.** "Thank you." on its own is -#: a sentence people dictate, and swallowing it looks like the microphone failed — which is exactly -#: why it was taken OUT of :data:`_HALLUCINATIONS` once. Appended to a sentence that already ended, -#: it is whisper filling silence: reported live as "random thank-yous, I don't know where this -#: comes from". :func:`strip_trailing_hallucination` never removes the last sentence standing, so -#: both readings get what they should. -_TRAILING_BOILERPLATE: frozenset[str] = frozenset( - { - "thank you", - "thank you.", - "thank you!", - "thank you very much", - "thank you very much.", - "thanks", - "thanks.", - "thank you for watching", - "thank you for watching.", - "thanks for listening", - "thanks for listening.", - "bye", - "bye.", - "bye!", - "bye bye", - "bye-bye.", - "goodbye", - "goodbye.", - # ...and in the other language he actually speaks. - "danke", - "danke.", - "danke schön", - "danke schön.", - "vielen dank", - "vielen dank.", - "tschüss", - "tschüss.", - "auf wiedersehen", - "auf wiedersehen.", - "untertitel im auftrag des zdf", - } -) - - -def strip_trailing_hallucination(text: str) -> str: - """Drop whisper's boilerplate when it is APPENDED to a real sentence. - - :func:`is_hallucination` judges the WHOLE line, which is the right shape for a clip that was - nothing but silence. It is the wrong shape for the other half of the same failure: a real - sentence followed by trailing silence comes back as the sentence *plus* the credit line - ("...so only agent flow is public now. Thanks for watching!"), the whole thing scores as - confident speech in a language you speak, and every one of those words gets typed. - - Only whole trailing SENTENCES are removed, and never the last one standing — same - one-directional bias as everything else here: an invented line that slips through is visible and - deletable, a real one swallowed is invisible. That guard is also what lets this list carry the - polite closings (see :data:`_TRAILING_BOILERPLATE`) that the whole-line trap must not. - """ - parts = _SENTENCE_END.split(text) - while len(parts) > 1 and ( - is_hallucination(parts[-1]) or parts[-1].strip().lower() in _TRAILING_BOILERPLATE - ): - parts.pop() - return " ".join(parts) +def trim_trailing_quiet(wav: Path) -> bool: + """Cut the silence off the end of a clip, at this install's own floor. See `speech`.""" + return speech.trim_trailing_quiet(wav, quiet_floor()) def tidy(transcript: str) -> str: @@ -1236,18 +519,15 @@ def tidy(transcript: str) -> str: # right call) silently took the flattening with it, and the two have nothing to do with each # other. text = " ".join(text.split()) - text = _SPACE_BEFORE_PUNCT.sub(r"\1", text) - text = _DOUBLED_PUNCT.sub(r"\2", text) - # The credit line whisper appends to trailing silence. `finish` traps the whole-transcript - # case; this is the same hallucination riding along behind a real sentence. - text = strip_trailing_hallucination(text) - if stripping: - # Seam repair, and ONLY meaningful after a strip: it exists to close the ", ," a removed - # filler leaves behind. Run unconditionally it would quietly eat a trailing comma somebody - # dictated on purpose, which is the same class of bug as the strip itself. - ended = transcript.rstrip().endswith((".", "!", "?")) - text = _DANGLING_TAIL.sub("." if ended else "", text) - return text.strip() + # Seam repair is ONLY meaningful after a strip: it exists to close the ", ," a removed filler + # leaves behind. Run unconditionally it would quietly eat a trailing comma somebody dictated on + # purpose, which is the same class of bug as the strip itself - so `stripping` decides it, and + # zyx, which always strips, always closes it. + return repair_punctuation( + text, + close_dangling=stripping, + ended=transcript.rstrip().endswith((".", "!", "?")), + ) # The instruction prepended to the transcript when `polishCommand` is a bare model runner that @@ -2022,20 +1302,6 @@ def streamed(stream: Stream | None) -> str: # denied microphone — CoreAudio hands back digital silence — so a process without the grant records # a perfectly valid, perfectly empty wav, and whisper answers it with confident nonsense ("Nibble, # Nibble, Nibble"). Room tone from a real mic sits around -46 dBFS; true silence is -90 or below. -SILENT_DBFS = -70.0 - -# Below this language score, whisper was not listening to speech — see :class:`Heard`. Measured on -# large-v3-turbo: every real utterance scored >= 0.969, every silent or -# noisy clip <= 0.453. 0.75 sits in the empty middle with ~0.22 of margin on both sides. -# -# This is the trap that the word-list in `_HALLUCINATIONS` structurally cannot be: whisper answers -# silence in a DIFFERENT invented language each time. Hit live: the key was pressed, nothing was -# said, and back came two sentences of invented Icelandic ("Ennum, hvað -# er hann?") as though it were a question. No blocklist can grow fast enough to cover that; asking -# whisper how sure it was covers all of it at once. -SPEECH_CONFIDENCE = 0.75 - - def spoken_languages() -> frozenset[str]: """The languages you actually speak (``languages``), or empty = accept every one. @@ -2074,9 +1340,6 @@ def spoken_languages() -> frozenset[str]: # is not a constant. 8 dB over the loudest room seen, 15 dB under the quietest sentence seen. A # far-field microphone in a big room is a different machine from this one, so `quietFloor` # overrides it rather than leaving somebody with a tool that never hears them and no way to say so. -QUIET_DBFS = -30.0 - - def auto_stop_seconds() -> float: """How long a forgotten microphone stays open. ``maxHold`` overrides; ``0`` switches it off.""" raw = _cfg().get("maxHold") @@ -2107,155 +1370,6 @@ def quiet_floor() -> float: NOTHING_SAID = "nothing was said" -def peak_dbfs(wav: Path) -> float: - """Peak amplitude of a 16-bit PCM wav in dBFS; ``-inf`` for silence or an unreadable file. - - Pure stdlib (:mod:`wave` + :mod:`array`), cheap enough for the hot path: one pass over a few - seconds of 16 kHz mono. Worth it because "the transcript is wrong" and "we recorded nothing at - all" are indistinguishable in a log and have completely different fixes. - """ - try: - with wave.open(str(wav), "rb") as handle: - if handle.getsampwidth() != 2: - return 0.0 # not 16-bit: no opinion rather than a wrong one - frames = handle.readframes(handle.getnframes()) - except Exception: # noqa: BLE001 — a diagnostic must never break a dictation - return 0.0 - return _peak_dbfs(frames) - - -#: The recorder's own format — see the `-ar`/`-ac` flags in :func:`start`. 16 kHz mono 16-bit is -#: 32000 bytes of file per second of audio, which is what lets :func:`tail_dbfs` find "the last ten -#: seconds" by seeking from the END of a file whose header has not been written yet. -SAMPLE_RATE = 16000 -BYTES_PER_SECOND = SAMPLE_RATE * 2 - - -def _peak_dbfs(frames: bytes) -> float: - """Peak of 16-bit PCM ``frames`` in dBFS. ``-inf`` for silence or nothing.""" - if not frames: - return float("-inf") - samples = array.array("h") - samples.frombytes(frames[: len(frames) - (len(frames) % 2)]) - if not samples: - return float("-inf") - # `max(max(...), -min(...))` and not `max(abs(s) for s in samples)`: both find the same peak, - # but the generator runs one Python-level loop per SAMPLE — a minute of dictation is ~1M of - # them — where two array scans stay in C. This sits on the hot path between the key coming up - # and the transcribe starting, which is the one stretch of the product a person is waiting on. - peak = max(max(samples), -min(samples)) - if peak == 0: - return float("-inf") - return 20 * math.log10(min(peak, 32768) / 32768.0) - - -#: Silence left on the end of a clip, in blocks this long, is cut before anything transcribes it. -#: Small enough to find the end of the last word closely, large enough that one loud sample of -#: keyboard noise does not hold a whole minute of nothing in place. -TRIM_BLOCK_SECONDS = 0.2 - -#: ...and this much is kept after the last block that had sound in it. A word's decay is part of -#: the word, and whisper reads a hard cut at the end of a syllable as a different syllable. -TRIM_KEEP_SECONDS = 0.4 - - -def trim_trailing_quiet(wav: Path) -> bool: - """Cut silence off the end of a clip. True if anything was cut. Never raises. - - **Whisper invents words when it is handed audio with nothing in it**, and this is the fix for - it — measured, after two that were not. The same 12 seconds of speech, three ways: - - speech alone "...but just in this text box," - + 20s of digital silence "...but just in this text box, Thank you." - + 20s of faint room noise "...but just in this text box.." - - So the invention is not a property of the speech, the model or the prompt. It is the silence, - and the cure is not to hand it over. Reported as "a lot of gibberish in a different language", - which was romanised Japanese appended to a real English sentence, and it reached the cursor - because he had not tapped to stop — so the clip ended with fifteen seconds of nothing. - - What was tried first and REFUSED, both measured: no word list can catch it (this file already - said so — whisper answers silence in a different invented language each time), and whisper's - own per-segment `no_speech_prob`/`avg_logprob` do not either. The invented " Thank you." came - back at `no_speech_prob` **0.000** and `avg_logprob` -0.28, sitting among real speech at -0.05 - to -0.12: confidently wrong, with no threshold between them that does not also cut real quiet - speech. - - Nothing is cut when the clip is quiet all the way through — that is a clip with no speech in - it, and the level gates in :func:`finish` are what should judge it and say so. - """ - block = int(TRIM_BLOCK_SECONDS * BYTES_PER_SECOND) - try: - size = wav.stat().st_size - with wav.open("rb") as handle: - handle.seek(44) - audio = handle.read() - except OSError: - return False - floor = quiet_floor() - last = -1 - for index in range(len(audio) // block): - if _peak_dbfs(audio[index * block : (index + 1) * block]) >= floor: - last = index - if last < 0: - return False # nothing above the floor anywhere: not ours to judge - keep = 44 + (last + 1) * block + int(TRIM_KEEP_SECONDS * BYTES_PER_SECOND) - if keep >= size: - return False - try: - with wav.open("r+b") as handle: - handle.truncate(keep) - except OSError: - return False - repair_wav(wav) # the RIFF header still claims the length it had before the cut - return True - - -def tail_dbfs(wav: Path, seconds: float) -> float: - """Peak of the LAST ``seconds`` of a clip that is STILL BEING RECORDED. ``0.0`` = no opinion. - - Read by seeking from the end of the file rather than through :mod:`wave`, because while ffmpeg - is still appending, the RIFF header holds the lengths it was born with — zero — so every - header-respecting reader sees an empty file (this is what :func:`repair_wav` exists to undo, - and it may not be run against the file the recorder is writing). - - Every byte past the header is a sample, so "the last ten seconds" is the last - ``10 * BYTES_PER_SECOND`` bytes, and a seek is the whole implementation. A clip that has not - yet run that long has no opinion — ``0.0``, the same "no opinion" :func:`peak_dbfs` returns for - a format it will not judge, which is well above any real floor and so is never read as silence. - """ - want = int(seconds * BYTES_PER_SECOND) - if want <= 0: - return 0.0 - try: - size = wav.stat().st_size - if size < want + 44: # 44 = the standard PCM wav header ffmpeg writes - return 0.0 # not enough audio yet to have been quiet for that long - with wav.open("rb") as handle: - handle.seek(size - want) - return _peak_dbfs(handle.read(want)) - except OSError: - return 0.0 - - -def audio_seconds(wav: Path) -> float: - """How long the RECORDING is, from its own header. ``0.0`` if it cannot be read. - - Not the same number as :attr:`Recording.seconds`, and the gap between them is the point. - ``Recording.seconds`` is wall clock from spawning ffmpeg to stopping it; this is how much - audio actually reached the file. A daemon log that prints only the first cannot tell "whisper - mis-heard 21 seconds of speech" from "we captured 15 of the 21 seconds you spoke", which are - the two halves of every report that a dictation came back short, and they have completely - different fixes. Both are printed now, and only when they disagree — see the daemon loop. - """ - try: - with wave.open(str(wav), "rb") as handle: - rate = handle.getframerate() - return handle.getnframes() / rate if rate else 0.0 - except Exception: # noqa: BLE001 — a diagnostic must never break a dictation - return 0.0 - - @dataclass(frozen=True) class Result: """What one dictation produced, for the CLI and the huddle loop to report on.""" @@ -2803,7 +1917,7 @@ def listener_pid() -> int: pid = int(listener_lock_path().read_text("utf-8").strip()) except (OSError, ValueError): return 0 - if pid <= 0 or pid == os.getpid() or _exited(pid): + if pid <= 0 or pid == os.getpid() or speech._exited(pid): return 0 return pid diff --git a/murmurflow/gesture.py b/murmurflow/gesture.py new file mode 100644 index 0000000..b2f0d23 --- /dev/null +++ b/murmurflow/gesture.py @@ -0,0 +1,293 @@ +"""gesture — what a hand does with one key, and what it means. ONE COPY, TWO TOOLS. + +**This file is byte-identical in MurmurFlow and in zyx, and that is enforced** — same rule and same +ritual as :mod:`core.speech`, which holds the other half (`make voice-sync`, a digest in both +repos, a test that fails the moment either copy is edited alone). + +It is here rather than in `speech` because it is not about audio at all. It is the gesture: the +intent delay, the chord abort, the press-to-press double-tap window, the tap/hold distinction, and +the rule that a callback which raises never kills the loop. Every one of those is a decision made +against a person's hand, tuned by watching one, and every one of them was tuned twice — the +press-to-press window was measured in MurmurFlow and carried into zyx by hand three weeks later, +which is exactly the drift this file ends. + +**What is NOT here, and must not be:** which key, what it is called, how this platform reads it, +and what the gesture is FOR. The two primitives are passed in — ``held()`` answers "is the trigger +down right now" and ``since_keydown()`` "how long since any real key went down" — so this file +knows nothing about CoreGraphics, dshow, virtual keycodes or trigger names, and neither tool has to +explain its own vocabulary to it. + +Stdlib only, like everything it may be copied into — and it must satisfy the STRICTER of the two +repos' linters, which is MurmurFlow's (it runs the `S` and `SIM` rule sets that zyx does not). A +shared file that only passes in one repo is a file somebody edits in the other and cannot commit. + +Licence: MIT, as MurmurFlow is. The copy in zyx is vendored under it — see +docs/legal/THIRD-PARTY.md. +""" + +from __future__ import annotations + +import contextlib +import time +from collections.abc import Callable + +# DEAD TIME IS LOST WORDS. The recorder now starts on key-DOWN, immediately, and the chord guard +# DISCARDS the clip if a shortcut turns out to be what was happening. The reverse — wait, then start +# — is what shipped first, and it cost the operator the first half-second of every sentence: +# 180ms of waiting PLUS the ~300ms CoreAudio needs to open the mic, ~480ms before a single sample +# lands. His transcripts came back truncated mid-thought ("Hold it, go in, give me this test of the +# first motor") while the identical audio recorded with a countdown transcribed perfectly. +# +# Starting first makes the guard window OVERLAP the warm-up instead of preceding it, so the dead +# time is the ~300ms CoreAudio floor and nothing more. The cost is real and accepted: a ⌃C now +# spawns an ffmpeg that lives ~100ms before the chord guard kills it. Mic churn is cheap; the first +# words of a sentence are not. +# +# ponytail: the floor is CoreAudio's device-open, not our code — a resident always-on recorder would +# reach zero, and is deliberately NOT built: a permanently hot microphone is the exact shape of the +# privacy incident this product exists to avoid. +CHORD_GRACE = 0.18 + +# DOUBLE-TAP (hands-free) mode. Two taps of the trigger within this window start recording; one tap +# stops it. This is what macOS's own dictation does, and on a heavily-chorded key like Control it is +# easier on the hand than holding. Hold stays the default because it is faster for one short +# sentence — you are already holding the key you pressed. +# +# This used to claim a chord "can NEVER look like a double-tap". It can, and does: ⌃C then ⌃C in a +# terminal is two short Control presses inside the window. The guard is in `listen_double_tap` — a +# press with a real keystroke inside it is a shortcut, not a tap — and it is what the claim was +# standing in for. +# TAP-WINDOW-1 (operator, 2026-08-27): 0.35 -> 0.50. "The double command click just doesn't work +# sometimes, or works like after 10 seconds. You can't just open it and close it." +# +# 350ms is tighter than the gesture it is measuring. macOS's own double-CLICK default is 500ms and +# its accessibility slider goes far higher, so a hand tapping ⌘⌘ at an ordinary pace was landing +# OUTSIDE this window a good share of the time — and a missed pair is not a no-op: the second tap +# becomes the FIRST tap of a new pair, so the next single tap opens the panel. That is exactly the +# "it fires later, seemingly at random" he is describing, and it is a measurement error rather than +# a race. +# +# Widening it also widens the chord false-positive this comment is about — and that is fine here, +# because the chord GUARD is independent of this number: `listen_double_tap` discards any press +# with a real keystroke inside it, whatever the window says. This value only decides how patient +# the pairing is; `seconds_since_keydown` decides whether the pair was a gesture at all. +DOUBLE_TAP_WINDOW = 0.50 + +# A tap is a press SHORTER than this. Longer and it is a hold, not a tap — which is what lets both +# modes coexist on one key rather than needing two. Generous on purpose: a tap that lingers is the +# common human miss, and reading it as a hold silently breaks the pair. (0.35 until 2026-09-09, +# raised to MurmurFlow's own number with the press-to-press pairing below — the two were measured +# together on a real hand and neither works as well alone.) +TAP_MAX = 0.50 + +# Virtual keycode of the LAST key macOS saw go down, used only to notice that the operator pressed +# a real key while holding the trigger — i.e. he is typing ⌘S, not dictating. Any such chord aborts +# the recording, which is what makes a bare modifier safe to bind at all. +_ABORT_ON_CHORD = True + +# Slack in the "was a real key pressed DURING this trigger press?" comparison. The poll runs at +# 60Hz and the two clocks are read a tick apart, so an exact comparison would miss a keystroke that +# landed in the same frame as the release. Small enough that a genuine tap a moment after typing is +# still a tap. +_CHORD_EPSILON = 0.02 + +# 60 Hz. Fast enough that press/release feels instant (16ms granularity is below the ~100ms a human +# perceives as lag) and cheap enough to be invisible: two C calls per tick is well under 1% of one +# core. Polling faster buys nothing a person can feel. +POLL_HZ = 60 + + +def listen( + on_press: Callable[[], None], + on_release: Callable[[], None], + *, + held: Callable[[], bool], + since_keydown: Callable[[], float], + min_hold: float = 0.15, + should_stop: Callable[[], bool] | None = None, + on_abort: Callable[[], None] | None = None, + chord_grace: float = CHORD_GRACE, + poll_hz: int = POLL_HZ, +) -> None: + """Block, calling ``on_press`` when the trigger goes down and ``on_release`` when it comes up. + + ``on_press`` fires IMMEDIATELY on key-down, so the microphone starts opening while the operator + is still deciding to talk — see :data:`CHORD_GRACE` for why waiting first cost him the opening + words of every sentence. + + **Chord abort.** Holding ⌃ and pressing C is "interrupt", not "dictate". If a real key goes down + while the trigger is held, the recording is abandoned via ``on_abort`` (which must DISCARD, not + transcribe) and no release fires for that press. Without this guard, binding a bare modifier + would dictate on every keyboard shortcut the operator uses. ``chord_grace`` bounds how long + after the press a key-down still counts as a chord; past it, a keystroke is someone typing in + another window, not this gesture. + + ``min_hold`` swallows accidental brushes: a press shorter than this still fires the pair, and + the caller decides what a too-short clip means — suppressing it here would strand the recording + that ``on_press`` already started. + + ``should_stop`` is polled each tick so a daemon can shut down cleanly. A callback that raises is + never allowed to kill the loop: the listener is the one process standing between a person + and their microphone, so it keeps going rather than dying silently at 3am. + """ + interval = 1.0 / max(1, poll_hz) + down = False # the trigger is physically down and on_press has fired + aborted = False + pressed_at = 0.0 + while True: + if should_stop is not None and should_stop(): + if down and not aborted: # never leave a recording running on shutdown + _safe(on_release) + return + now_held = held() + elapsed = time.monotonic() - pressed_at + # A key-down MORE RECENT than the trigger press is a shortcut, not speech. Bounded by + # chord_grace so that typing in another window a minute into a long dictation cannot + # retroactively cancel it. + chord = ( + _ABORT_ON_CHORD + and down + and not aborted + and elapsed <= chord_grace + and since_keydown() < elapsed + ) + if now_held and not down: + down, aborted = True, False + pressed_at = time.monotonic() + _safe(on_press) # start the mic NOW; the guard below discards if this was a chord + elif chord: + aborted = True + _safe(on_abort or on_release) + elif not now_held and down: + down = False + if aborted: + aborted = False + continue # a shortcut: already discarded, there is nothing to finish + if elapsed < min_hold: + # The REMAINDER, not the floor again: a press of `min_hold - 1ms` used to wait + # a further full floor, so the shortest holds took nearly twice as long to answer. + # Measured in MurmurFlow; zyx carried the doubled wait until the loop became one. + time.sleep(min_hold - elapsed) # let the mic collect something before we cut it + _safe(on_release) + time.sleep(interval) + + +def listen_double_tap( + on_start: Callable[[], None], + on_stop: Callable[[], None], + *, + held: Callable[[], bool], + since_keydown: Callable[[], float], + should_stop: Callable[[], bool] | None = None, + window: float = DOUBLE_TAP_WINDOW, + tap_max: float = TAP_MAX, + poll_hz: int = POLL_HZ, + on_tap: Callable[[str], None] | None = None, + pairs_only: bool = False, + is_recording: Callable[[], bool] | None = None, +) -> None: + """Hands-free mode: double-tap the trigger to start talking, tap once to stop. + + Why this exists beside :func:`listen`: on Control — the key the operator actually wants, because + it is where macOS puts dictation — every ``⌃C``/``⌃D``/``⌃R`` looks like the beginning of a + hold. Hold mode handles that correctly (the chord guard discards the clip) but the operator + still SEES the machine react to a keystroke that was never meant for it. A double-tap is a + deliberate gesture, so the interaction stays silent until he genuinely asks for it. + + ``is_recording`` (optional) is how the loop learns that a clip ended WITHOUT a tap. The + ``recording`` flag below used to be the only record of whether anything was running, and the + microphone can now close itself (:data:`core.dictate.SILENCE_STOP_SECONDS`). The clip was gone + and this still believed it was running, so the next tap was spent being a STOP for a clip that + had already stopped, and the double-tap only worked on the try after that. Asked once per poll, + so the answer must be a cheap lookup and never work. + + ``on_tap`` (optional) is told ``"press"`` the instant the key goes down — before anything is + known about what the gesture will turn out to be, which is what lets a caller open the + microphone early — and then about every press this loop DECIDES something about — ``"tap"``, + ``"hold"``, ``"chord"``, ``"start"``, ``"stop"``. It exists because the alternative failure is + unanswerable: a listener that reacts to nothing looks identical whether the key is never read, + the taps are too far apart, or the chord guard is eating them. The operator hit exactly that on + a newly-bound key ("I can't hear any sound when I double click the right command"), and there + was nothing anywhere to tell him which of the three it was. + + **One chord is not a start — but two in a row were.** This originally reasoned that "a shortcut + is one press, and one press is never a start", and applied no chord guard at all. ⌃C then ⌃C in + a terminal is two short Control presses inside :data:`DOUBLE_TAP_WINDOW`, so recording began + behind the operator's back and the next stray tap ended it as a too-short clip — failure cues + arriving "out of nowhere" while he worked. A press with a real keystroke inside it is now + discarded as the shortcut it was. + + **``pairs_only`` — for a trigger that TOGGLES something rather than recording.** The floating + chat pane is one gesture in and the same gesture out, and it was bound to this loop as if it + were a microphone: the double-tap opened it (``on_start``), and the next single tap was read as + the "stop" of a recording — a callback that does nothing for a pane, while it still CONSUMED + the tap and left ``recording`` true with nothing recording. The count the operator measured is + exactly that arithmetic: two taps open it, one dead tap, two more to fire again (PANE-TAP-1, + operator, 2026-08-28: "it only opens after clicking four times the command and only closes + after clicking four times the command"). + + With it set there is no recording state at all: every completed pair fires ``on_start`` and a + lone tap is only ever the first half of the next pair. ``on_stop`` is never called. + """ + interval = 1.0 / max(1, poll_hz) + down = False + pressed_at = 0.0 + last_tap = -999.0 + recording = False + + def saw(what: str) -> None: + if on_tap is not None: + _safe(lambda: on_tap(what)) + + while True: + if should_stop is not None and should_stop(): + if recording: + _safe(on_stop) + return + if recording and is_recording is not None and not is_recording(): + # It closed itself. Forget the clip rather than charging the next tap for it. + recording, last_tap = False, -999.0 + saw("ended") + now = time.monotonic() + now_held = held() + if now_held and not down: + down, pressed_at = True, now + saw("press") + elif not now_held and down: + down = False + if now - pressed_at > tap_max: + last_tap = -999.0 # a long hold is not a tap; it cannot open a double-tap pair + saw("hold") + continue + # A CHORD IS NOT A TAP. This module used to claim a chord "can NEVER look like a + # double-tap" — false, and the operator found it: ⌃C then ⌃C in a terminal is two short + # Control presses inside the window, which is exactly the gesture. Recording started + # behind his back and the next stray tap ended it as a too-short clip, so he got failure + # cues "out of nowhere, maybe every 30 seconds". If a real key went down while the + # trigger was held, this press belonged to a shortcut. + if since_keydown() <= (now - pressed_at) + _CHORD_EPSILON: + last_tap = -999.0 + saw("chord") + continue + if recording and not pairs_only: + recording, last_tap = False, -999.0 + saw("stop") + _safe(on_stop) + elif pressed_at - last_tap <= window: + # PRESS TO PRESS, never release to release. Comparing releases charges the second + # tap's own duration to the window, so a 200ms press blew a 500ms window and an + # ordinary double-tap read as two unrelated taps — nothing happened at all. It is + # also how macOS measures its own double-click. + recording, last_tap = not pairs_only, -999.0 + saw("start") + _safe(on_start) + else: + last_tap = pressed_at + saw("tap") + time.sleep(interval) + + +def _safe(fn: Callable[[], None]) -> None: + """Run a listener callback, swallowing anything it throws.""" + with contextlib.suppress(Exception): + fn() diff --git a/murmurflow/hotkey.py b/murmurflow/hotkey.py index cb5fd7a..6e23978 100644 --- a/murmurflow/hotkey.py +++ b/murmurflow/hotkey.py @@ -26,11 +26,9 @@ from __future__ import annotations -import contextlib -import time from collections.abc import Callable -from . import platforms +from . import gesture, platforms #: COMBINATION triggers: every one of these flags must be held at once, and nothing types. #: @@ -122,64 +120,6 @@ def canonical_trigger(name: str) -> str: return "_".join(TRIGGER_ALIASES.get(word, word) for word in key.split("_")) -# DEAD TIME IS LOST WORDS. The recorder now starts on key-DOWN, immediately, and the chord guard -# DISCARDS the clip if a shortcut turns out to be what was happening. The reverse — wait, then start -# — is what shipped first, and it cost the user the first half-second of every sentence: -# 180ms of waiting PLUS the ~300ms CoreAudio needs to open the mic, ~480ms before a single sample -# lands. Transcripts came back truncated mid-thought ("Hold it, go in, give me this test of the -# first motor") while the identical audio recorded with a countdown transcribed perfectly. -# -# Starting first makes the guard window OVERLAP the warm-up instead of preceding it, so the dead -# time is the ~300ms CoreAudio floor and nothing more. The cost is real and accepted: a ⌃C now -# spawns an ffmpeg that lives ~100ms before the chord guard kills it. Mic churn is cheap; the first -# words of a sentence are not. -# -# ponytail: the floor is CoreAudio's device-open, not our code — a resident always-on recorder would -# reach zero, and is deliberately NOT built: a permanently hot microphone is the exact shape of the -# privacy incident this product exists to avoid. -CHORD_GRACE = 0.18 - -# DOUBLE-TAP (hands-free) mode. Two taps of the trigger within this window start recording; one tap -# stops it. This is what macOS's own dictation does, and on a heavily-chorded key like Control it is -# easier on the hand than holding — which is why this, and not hold, is the default. Hold is the -# opt-out (`config set doubleTap false`), and it is faster for one short sentence: you are already -# holding the key you pressed. -# -# This used to claim a chord "can NEVER look like a double-tap". It can, and does: ⌃C then ⌃C in a -# terminal is two short Control presses inside the window. The guard is in `listen_double_tap` — a -# press with a real keystroke inside it is a shortcut, not a tap — and it is what the claim was -# standing in for. -# -# Measured PRESS TO PRESS, and that is the whole reliability of the gesture. Release-to-release — -# what shipped first — makes the second tap's own duration count against the budget: a 200ms gap -# plus a 200ms press blows a 350ms window, so an ordinary double-tap read as two unrelated taps and -# nothing happened at all. Press-to-press is also how macOS measures a double-click, and 500ms is -# its default there. -DOUBLE_TAP_WINDOW = 0.50 - -# A tap is a press SHORTER than this. Longer and it is a hold, not a tap — which is what lets both -# modes coexist on one key rather than needing two. Generous on purpose: a tap that lingers is the -# common human miss, and reading it as a hold silently breaks the pair. -TAP_MAX = 0.50 - -# Virtual keycode of the LAST key macOS saw go down, used only to notice that the user pressed -# a real key while holding the trigger — i.e. typing ⌘S, not dictating. Any such chord aborts -# the recording, which is what makes a bare modifier safe to bind at all. -_ABORT_ON_CHORD = True - -# Slack in the "was a real key pressed DURING this trigger press?" comparison. The poll runs at -# 60Hz and the two clocks are read a tick apart, so an exact comparison would miss a keystroke that -# landed in the same frame as the release. Small enough that a genuine tap a moment after typing is -# still a tap. -_CHORD_EPSILON = 0.02 - -# 60 Hz. Fast enough that press/release feels instant (16ms granularity is below the ~100ms a human -# perceives as lag) and cheap enough to be invisible: two C calls per tick is well under 1% of one -# core. Polling faster buys nothing a person can feel. (Windows scans a few dozen keys per tick -# for the chord guard instead of one call; still well under a percent.) -POLL_HZ = 60 - - def accessibility_trusted() -> bool: """Has this binary been granted whatever permission typing into another app needs. @@ -220,77 +160,39 @@ def unavailable_reason() -> str: return platforms.keys_unavailable() +# THE GESTURE ITSELF IS SHARED — `murmurflow.gesture`, byte-identical with zyx's copy of it (see +# that module's own note). What stays HERE is the vocabulary: which triggers exist, what they are +# called on each keyboard, and which platform module answers for them. The loop is handed the two +# questions it needs as callables and never learns what a keycode is. +CHORD_GRACE = gesture.CHORD_GRACE +DOUBLE_TAP_WINDOW = gesture.DOUBLE_TAP_WINDOW +TAP_MAX = gesture.TAP_MAX +POLL_HZ = gesture.POLL_HZ + + def listen( on_press: Callable[[], None], on_release: Callable[[], None], *, trigger: str = DEFAULT_TRIGGER, - min_hold: float = 0.15, - should_stop: Callable[[], bool] | None = None, on_abort: Callable[[], None] | None = None, + should_stop: Callable[[], bool] | None = None, + min_hold: float = 0.15, chord_grace: float = CHORD_GRACE, poll_hz: int = POLL_HZ, ) -> None: - """Block, calling ``on_press`` when the trigger goes down and ``on_release`` when it comes up. - - ``on_press`` fires IMMEDIATELY on key-down, so the microphone starts opening while the user - is still deciding to talk — see :data:`CHORD_GRACE` for why waiting first cost him the opening - words of every sentence. - - **Chord abort.** Holding ⌃ and pressing C is "interrupt", not "dictate". If a real key goes down - while the trigger is held, the recording is abandoned via ``on_abort`` (which must DISCARD, not - transcribe) and no release fires for that press. Without this guard, binding a bare modifier - would dictate on every keyboard shortcut the user uses. ``chord_grace`` bounds how long - after the press a key-down still counts as a chord; past it, a keystroke is someone typing in - another window, not this gesture. - - ``min_hold`` swallows accidental brushes: a press shorter than this still fires the pair, and - the caller decides what a too-short clip means — suppressing it here would strand the recording - that ``on_press`` already started. - - ``should_stop`` is polled each tick so a daemon can shut down cleanly. A callback that raises is - never allowed to kill the loop: the listener is the one process standing between the user - and the dictation, so it keeps going rather than dying silently at 3am. - """ - interval = 1.0 / max(1, poll_hz) - held = False # the trigger is physically down and on_press has fired - aborted = False - pressed_at = 0.0 - while True: - if should_stop is not None and should_stop(): - if held and not aborted: # never leave a recording running on shutdown - _safe(on_release) - return - now_held = is_trigger_down(trigger) - elapsed = time.monotonic() - pressed_at - # A key-down MORE RECENT than the trigger press is a shortcut, not speech. Bounded by - # chord_grace so that typing in another window a minute into a long dictation cannot - # retroactively cancel it. - chord = ( - _ABORT_ON_CHORD - and held - and not aborted - and elapsed <= chord_grace - and seconds_since_keydown() < elapsed - ) - if now_held and not held: - held, aborted = True, False - pressed_at = time.monotonic() - _safe(on_press) # start the mic NOW; the guard below discards if this was a chord - elif chord: - aborted = True - _safe(on_abort or on_release) - elif not now_held and held: - held = False - if aborted: - aborted = False - continue # a shortcut: already discarded, there is nothing to finish - if elapsed < min_hold: - # The REMAINDER, not the floor again: a press of `min_hold - 1ms` used to wait a - # further full floor, so the shortest holds took nearly twice as long to answer. - time.sleep(min_hold - elapsed) # let the mic collect something before we cut it - _safe(on_release) - time.sleep(interval) + """Hold-to-talk on ``trigger``. Blocks. See :func:`murmurflow.gesture.listen`.""" + gesture.listen( + on_press, + on_release, + held=lambda: is_trigger_down(trigger), + since_keydown=seconds_since_keydown, + on_abort=on_abort, + should_stop=should_stop, + min_hold=min_hold, + chord_grace=chord_grace, + poll_hz=poll_hz, + ) def listen_double_tap( @@ -305,101 +207,16 @@ def listen_double_tap( on_tap: Callable[[str], None] | None = None, is_recording: Callable[[], bool] | None = None, ) -> None: - """Hands-free mode: double-tap the trigger to start talking, tap once to stop. - - Why this exists beside :func:`listen`: on Control — the key the user actually wants, because - it is where macOS puts dictation — every ``⌃C``/``⌃D``/``⌃R`` looks like the beginning of a - hold. Hold mode handles that correctly (the chord guard discards the clip) but the user - still SEES the machine react to a keystroke that was never meant for it. A double-tap is a - deliberate gesture, so the interaction stays silent until it is genuinely asked for. - - ``on_tap`` (optional) is told ``"press"`` the instant the key goes down, and then about every - press this loop DECIDES something about — ``"tap"``, ``"hold"``, ``"chord"``, ``"start"``, - ``"stop"``. It exists because the alternative failure is - unanswerable: a listener that reacts to nothing looks identical whether the key is never read, - the taps are too far apart, or the chord guard is eating them. The user hit exactly that on - a newly-bound key ("I can't hear any sound when I double click the right command"), and there - was nothing anywhere to tell him which of the three it was. - - ``is_recording`` (optional) is how the loop learns that a clip ended WITHOUT a tap. This flag - used to be the only record of whether anything was recording, and the microphone can now close - itself — after fifteen seconds of silence, or the two-minute cap. The clip was gone and this - still believed it was running, so the next tap was spent being a STOP for a clip that had - already stopped, and the double-tap only worked on the try after that. Reported as "when the - microphone closes automatically, the double press control doesn't reset". Asked once per poll, - so the answer is a list lookup, never work. - - **One chord is not a start — but two in a row were.** This originally reasoned that "a shortcut - is one press, and one press is never a start", and applied no chord guard at all. ⌃C then ⌃C in - a terminal is two short Control presses inside :data:`DOUBLE_TAP_WINDOW`, so recording began - behind the user's back and the next stray tap ended it as a too-short clip — failure cues - arriving "out of nowhere" mid-work. A press with a real keystroke inside it is now - discarded as the shortcut it was. - """ - interval = 1.0 / max(1, poll_hz) - held = False - pressed_at = 0.0 - last_tap = -999.0 - recording = False - - def saw(what: str) -> None: - if on_tap is not None: - _safe(lambda: on_tap(what)) - - while True: - if should_stop is not None and should_stop(): - if recording: - _safe(on_stop) - return - if recording and is_recording is not None and not is_recording(): - # It closed itself. Forget the clip rather than charging the next tap for it. - recording, last_tap = False, -999.0 - saw("ended") - now = time.monotonic() - now_held = is_trigger_down(trigger) - if now_held and not held: - held, pressed_at = True, now - # On the PRESS, before anything is known about what this gesture will turn out to be. - # It is what lets the daemon open the microphone early — see `dictate.preroll`, where - # the whole point is that the device needs longer to wake than the gesture takes. - saw("press") - elif not now_held and held: - held = False - if now - pressed_at > tap_max: - last_tap = -999.0 # a long hold is not a tap; it cannot open a double-tap pair - saw("hold") - continue - # A CHORD IS NOT A TAP. This module used to claim a chord "can NEVER look like a - # double-tap" — false, and the user found it: ⌃C then ⌃C in a terminal is two short - # Control presses inside the window, which is exactly the gesture. Recording started - # unnoticed and the next stray tap ended it as a too-short clip, so what arrived was failure - # cues "out of nowhere, maybe every 30 seconds". If a real key went down while the - # trigger was held, this press belonged to a shortcut. - if seconds_since_keydown() <= (now - pressed_at) + _CHORD_EPSILON: - last_tap = -999.0 - saw("chord") - continue - if recording: - recording, last_tap = False, -999.0 - saw("stop") - _safe(on_stop) - elif pressed_at - last_tap <= window: - # PRESS to PRESS. Comparing releases charged the second tap's own duration to the - # window and made an ordinary double-tap miss. - recording, last_tap = True, -999.0 - saw("start") - _safe(on_start) - else: - last_tap = pressed_at - saw("tap") - time.sleep(interval) - - -def _safe(fn: Callable[[], None]) -> None: - """Run a listener callback, swallowing anything it throws. - - A failed dictation must never take down the listener — the daemon is the one process standing - between someone and their microphone, so it outlives any single clip. - """ - with contextlib.suppress(Exception): - fn() + """Double-tap to start, tap once to stop. See :func:`murmurflow.gesture.listen_double_tap`.""" + gesture.listen_double_tap( + on_start, + on_stop, + held=lambda: is_trigger_down(trigger), + since_keydown=seconds_since_keydown, + should_stop=should_stop, + window=window, + tap_max=tap_max, + poll_hz=poll_hz, + on_tap=on_tap, + is_recording=is_recording, + ) diff --git a/murmurflow/speech.py b/murmurflow/speech.py new file mode 100644 index 0000000..5ab9353 --- /dev/null +++ b/murmurflow/speech.py @@ -0,0 +1,1226 @@ +"""speech — the physics of a microphone and the hygiene of a transcript. ONE COPY, TWO TOOLS. + +**This file is byte-identical in MurmurFlow and in zyx, and that is enforced.** MurmurFlow was +extracted from zyx's ``core.dictate`` and the two ship separately on purpose — they do different +jobs, one types at your cursor and one hands what you said to a runtime — but the layer underneath +both is not a product decision at all. It is what a wav header looks like, what silence measures, +and what whisper invents when it is handed nothing. That layer drifted for three weeks and cost +two of the same bugs found twice, which is what this file exists to stop. + +**What belongs here:** anything that is true of the AUDIO or of the TRANSCRIPT, needs no config, +opens no device, and has no opinion about what happens to the words afterwards. + +**What does NOT:** every reader of a setting (the floors are passed in as arguments, never read), +the recorder, the key, the server, the cues, and anything that types, pastes, speaks or answers. +A function here that needs to ask a question about THIS install is in the wrong file. + +Stdlib only, and it must stay that way: it is copied verbatim into a runtime whose first invariant +is a stdlib core. + +Licence: MIT, as MurmurFlow is. The copy in zyx is vendored under it — see +docs/legal/THIRD-PARTY.md. +""" + +from __future__ import annotations + +import array +import contextlib +import json +import math +import os +import re +import shutil +import signal +import subprocess +import time +import urllib.error +import urllib.request +import uuid +import wave +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import NamedTuple + +# --- the wav on disk ------------------------------------------------------------------------ + +MIN_HEADER_BYTES = 44 + + +def data_offset(wav: Path) -> int: + """Where the samples actually start. :data:`MIN_HEADER_BYTES` when the header cannot be read. + + **44 is a guess and on this machine it is the wrong one.** Measured 2026-09-09 with the + recorder's own argv: ffmpeg writes a 26-byte ``LIST``/``INFO`` chunk between ``fmt `` and + ``data``, so the first sample is at byte **78**. Everything that seeks past "the header" by a + constant — the trailing-quiet trim, the tail level read — is then reading 34 bytes of encoder + metadata as audio and cutting the clip 34 bytes early. (MurmurFlow assumes 44 in both places + and has the same off-by-a-chunk; it is small enough to have gone unnoticed there, which is + exactly why it was measured here rather than copied.) + + Works on a file STILL BEING RECORDED: the chunk headers are written when ffmpeg opens the file, + and only the two SIZE fields are left for the exit that may never come. + """ + try: + with wav.open("rb") as handle: + if handle.read(4) != b"RIFF": + return MIN_HEADER_BYTES + handle.seek(8) + if handle.read(4) != b"WAVE": + return MIN_HEADER_BYTES + size = wav.stat().st_size + offset = 12 + while offset + 8 <= size: + handle.seek(offset) + name = handle.read(4) + declared = int.from_bytes(handle.read(4), "little") + if name == b"data": + return offset + 8 + if declared <= 0: + break # a chunk with no length: the walk cannot go on honestly + offset += 8 + declared + (declared % 2) # chunks are padded to an even length + except (OSError, ValueError): + pass + return MIN_HEADER_BYTES + + +def repair_wav(wav: Path) -> bool: + """Patch a RIFF header whose lengths were never written. ``True`` if it had to. Never raises. + + **ffmpeg writes the real lengths when it EXITS, and it does not always get to.** The SIGKILL + fallback in :func:`stop` is one way; :func:`trim_trailing_quiet` is the other, and there it is + not a failure at all — cutting the tail off a clip leaves a header still claiming the length it + had before the cut. + + A killed ffmpeg leaves ``0xFFFFFFFF`` in both size fields rather than zero, so the audio still + DECODES (``-flush_packets 1`` already wrote every sample and readers stop at the end of file). + What breaks is every reader that believes the header — :func:`peak_dbfs` reads a frame count + that is not there, and a clip something already went wrong with is exactly the clip whose + diagnostics go blind. + + Cheaper than what it protects: a stat and twelve bytes on the ordinary path, where the header + is already right and this returns immediately. (Ported from MurmurFlow 2026-09-09.) + """ + try: + size = wav.stat().st_size + if size <= 44: # a header and nothing else — there is nothing to rescue + return False + with wav.open("r+b") as handle: + if handle.read(4) != b"RIFF": + return False + handle.seek(8) + if handle.read(4) != b"WAVE": + return False + offset = 12 + while offset + 8 <= size: + handle.seek(offset) + name = handle.read(4) + declared = int.from_bytes(handle.read(4), "little") + if name == b"data": + actual = size - (offset + 8) + if declared and declared <= actual: + return False # the trailer was written; leave it exactly as it is + handle.seek(offset + 4) + handle.write(actual.to_bytes(4, "little")) + handle.seek(4) + handle.write((size - 8).to_bytes(4, "little")) + return True + if declared <= 0: + return False # a chunk with no length: the walk cannot go on honestly + offset += 8 + declared + (declared % 2) # chunks are padded to an even length + except (OSError, ValueError): + return False + return False + + +# --- what a sample measures ---------------------------------------------------------------- + +SAMPLE_RATE = 16000 +BYTES_PER_SECOND = SAMPLE_RATE * 2 + + +def _peak_dbfs(frames: bytes) -> float: + """Peak of 16-bit PCM ``frames`` in dBFS. ``-inf`` for silence or nothing.""" + if not frames: + return float("-inf") + samples = array.array("h") + samples.frombytes(frames[: len(frames) - (len(frames) % 2)]) + if not samples: + return float("-inf") + # `max(max(...), -min(...))` and not `max(abs(s) for s in samples)`: both find the same peak, + # but the generator runs one Python-level loop per SAMPLE — a minute of speech is ~1M of them — + # where two array scans stay in C. This sits between the key coming up and the transcribe + # starting, which is the one stretch of this a person is waiting on. + peak = max(max(samples), -min(samples)) + if peak == 0: + return float("-inf") + return 20 * math.log10(min(peak, 32768) / 32768.0) + + +def peak_dbfs(wav: Path) -> float: + """Peak amplitude of a 16-bit PCM wav in dBFS; ``-inf`` for silence or an unreadable file. + + Pure stdlib (:mod:`wave` + :mod:`array`), cheap enough for the hot path: one pass over a few + seconds of 16 kHz mono. Worth it because "the transcript is wrong" and "we recorded nothing at + all" are indistinguishable in a log and have completely different fixes. + """ + try: + with wave.open(str(wav), "rb") as handle: + if handle.getsampwidth() != 2: + return 0.0 # not 16-bit: no opinion rather than a wrong one + frames = handle.readframes(handle.getnframes()) + except Exception: # noqa: BLE001 — a diagnostic must never break a dictation + return 0.0 + return _peak_dbfs(frames) + + +#: Silence left on the end of a clip, in blocks this long, is cut before anything transcribes it. +#: Small enough to find the end of the last word closely, large enough that one loud sample of + +TRIM_BLOCK_SECONDS = 0.2 + +#: ...and this much is kept after the last block that had sound in it. A word's decay is part of +#: the word, and whisper reads a hard cut at the end of a syllable as a different syllable. +TRIM_KEEP_SECONDS = 0.4 + + +def trim_trailing_quiet(wav: Path, floor: float) -> bool: + """Cut silence off the end of a clip. True if anything was cut. Never raises. + + **Whisper invents words when it is handed audio with nothing in it**, and this is the fix for + it — measured in MurmurFlow, after two that were not. The same 12 seconds of speech, three ways: + + speech alone "...but just in this text box," + + 20s of digital silence "...but just in this text box, Thank you." + + 20s of faint room noise "...but just in this text box.." + + So the invention is not a property of the speech, the model or the prompt. It is the silence, + and the cure is not to hand it over. It matters more here than it did there: with + :data:`SILENCE_STOP_SECONDS` a forgotten microphone now ALWAYS ends in fifteen seconds of + nothing, so without this every auto-closed turn would arrive with an invented tail on it. + + What was tried first and refused, both measured: no word list can catch it (whisper answers + silence in a different invented language each time — see :data:`SPEECH_CONFIDENCE`), and + whisper's own ``no_speech_prob``/``avg_logprob`` do not either; the invented " Thank you." came + back at 0.000 and -0.28, sitting among real speech at -0.05 to -0.12. + + Nothing is cut when the clip is quiet all the way through — that is a clip with no speech in it, + and the level gates in :func:`finish` are what should judge it and say so. + (Ported from MurmurFlow 2026-09-09.) + """ + block = int(TRIM_BLOCK_SECONDS * BYTES_PER_SECOND) + head = data_offset(wav) # NOT 44: this ffmpeg writes a LIST chunk too + try: + size = wav.stat().st_size + with wav.open("rb") as handle: + handle.seek(head) + audio = handle.read() + except OSError: + return False + last = -1 + for index in range(len(audio) // block): + if _peak_dbfs(audio[index * block : (index + 1) * block]) >= floor: + last = index + if last < 0: + return False # nothing above the floor anywhere: not ours to judge + keep = head + (last + 1) * block + int(TRIM_KEEP_SECONDS * BYTES_PER_SECOND) + if keep >= size: + return False + try: + with wav.open("r+b") as handle: + handle.truncate(keep) + except OSError: + return False + repair_wav(wav) # the RIFF header still claims the length it had before the cut + return True + + +def tail_dbfs(wav: Path, seconds: float) -> float: + """Peak of the LAST ``seconds`` of a clip that is STILL BEING RECORDED. ``0.0`` = no opinion. + + Read by seeking from the end of the file rather than through :mod:`wave`, because while ffmpeg + is still appending, the RIFF header holds the lengths it was born with — zero — so every + header-respecting reader sees an empty file. (:func:`repair_wav` is what undoes that, and it + must not be run against a file the recorder is still writing.) + + Every byte past the header is a sample, so "the last fifteen seconds" is the last + ``15 * BYTES_PER_SECOND`` bytes and a seek is the whole implementation. A clip that has not yet + run that long has no opinion — ``0.0``, which is well above any real floor and so is never read + as silence. (Ported from MurmurFlow 2026-09-09.) + """ + want = int(seconds * BYTES_PER_SECOND) + if want <= 0: + return 0.0 + try: + size = wav.stat().st_size + if size < want + data_offset(wav): + return 0.0 # not enough audio yet to have been quiet for that long + with wav.open("rb") as handle: + handle.seek(size - want) + return _peak_dbfs(handle.read(want)) + except OSError: + return 0.0 + + +def audio_seconds(wav: Path) -> float: + """How long the RECORDING is, from its own header. ``0.0`` if it cannot be read. + + Not the same number as :attr:`Recording.seconds`, and the gap between them is the point. + ``Recording.seconds`` is wall clock from spawning ffmpeg to stopping it; this is how much + audio actually reached the file. A daemon log that prints only the first cannot tell "whisper + mis-heard 21 seconds of speech" from "we captured 15 of the 21 seconds you spoke", which are + the two halves of every report that a dictation came back short, and they have completely + different fixes. Both are printed now, and only when they disagree — see the daemon loop. + """ + try: + with wave.open(str(wav), "rb") as handle: + rate = handle.getframerate() + return handle.getnframes() / rate if rate else 0.0 + except Exception: # noqa: BLE001 — a diagnostic must never break a dictation + return 0.0 + + +# --- the thresholds, each with the measurement behind it ----------------------------------- + +MIN_CLIP_SECONDS = 0.4 + +# The one problem that is NOT worth a sound: see the daemon's release handler. +TOO_SHORT = "too short" + +# The clip was long enough and loud enough to be a sentence, but there was no speech in it. ONE +# string for every way we reach that conclusion (whisper's language score, the boilerplate word +# list, an empty transcript) because callers act on it rather than print it: the huddle counts +# consecutive occurrences to decide when to stop trusting the microphone. Kept in the operator's +# own words — he does not care which of the three traps fired. +NO_SPEECH = "I didn't hear anything" + +# The longest single clip the recorder will ever produce (see the `-t` flag in `start`). Ten minutes +# is far beyond any real hold — the longest sentence the operator has dictated is ~60s — and short +# enough that an orphaned recorder costs ~20 MB and ten minutes of open microphone instead of hours. +MAX_CLIP_SECONDS = 600 + +# How long a turn may run before the huddle closes the microphone ITSELF and answers what was said. +# `MAX_CLIP_SECONDS` above is the recorder's own fuse and stays where it is: it bounds an ORPHAN — +# a clip nobody is waiting for — and it throws the audio away. This is the opposite case. The +# operator is right there and simply forgot the second tap, and the right answer is not to discard +# two minutes of his voice but to finish the turn exactly as the tap would have. + +SILENT_DBFS = -70.0 + +# Below this language score, whisper was not listening to speech — see :class:`Heard`. Measured on +# this operator's own model (large-v3-turbo): every real utterance scored >= 0.969, every silent or +# noisy clip <= 0.453. 0.75 sits in the empty middle with ~0.22 of margin on both sides. +# +# This is the trap that the word-list in `_HALLUCINATIONS` structurally cannot be: whisper answers +# silence in a DIFFERENT invented language each time. The operator hit it live on 2026-08-09 — +# he pressed the key, said nothing, and Zyx answered two turns of invented Icelandic ("Ennum, hvað +# er hann?") as though it were a question. No blocklist can grow fast enough to cover that; asking +# whisper how sure it was covers all of it at once. +SPEECH_CONFIDENCE = 0.75 + + +# The level below which a stretch of audio is a ROOM and not a sentence. Measured: a loud silent +# room reads -38 dBFS and the quietest real speech -15, so -30 sits between them. Do NOT lower it +# to -40 — that is inside the room. +# +# **It is not a gate on a clip, and that distinction is the reason zyx may have this number at all** +# (the voice contract, `deliberately_divergent.quiet_floor`). MurmurFlow DROPS a clip +# under its floor, because it types into a document where transcribing a room is worse than losing +# a sentence; zyx hands text to a model that can decline to answer, so it drops nothing. Here the +# floor answers two different questions: "has he stopped talking" (:data:`SILENCE_STOP_SECONDS`) +# and "is this tail worth transcribing" (:func:`trim_trailing_quiet`). +# Each tool names the setting that moves it in its own vocabulary; this is the default. +QUIET_DBFS = -30.0 + + +# Each tool names the setting that moves it in its own vocabulary; 0 switches it off. +AUTO_STOP_SECONDS = 120.0 + +# How long the microphone stays open with nothing being said before it closes itself and answers. +# +# Fifteen seconds, and the dictation apps that stop at two or three are WRONG HERE: the gesture is +# a tap, not a held key, so nothing is telling the microphone the operator is still there — and he +# thinks mid-sentence. A clip cut at three seconds would end half his turns mid-thought, with the +# rest spoken into a closed microphone. That failure is worse than the one this fixes, because a +# forgotten microphone loses nothing and a truncated sentence loses the sentence. +# Each tool names the setting that moves it in its own vocabulary; 0 switches it off. +SILENCE_STOP_SECONDS = 15.0 + + +# --- the transcript ------------------------------------------------------------------------- + +_SEGMENT_SEAM = re.compile(r"([^\S\n]*)\n+([^\S\n]*)") + + +def join_segments(transcript: str) -> str: + """Flatten whisper's per-segment newlines WITHOUT inventing a space in the middle of a word.""" + return _SEGMENT_SEAM.sub(lambda seam: " " if seam.group(1) or seam.group(2) else "", transcript) + + +HALLUCINATIONS: frozenset[str] = frozenset( + { + "thanks for watching!", + "thanks for watching.", + "[blank_audio]", + "(silence)", + "untertitel von stephanie geiges", + "untertitel der amara.org-community", + "untertitelung aufgrund der amara.org-community", + "amara.org", + # THE SAME BOILERPLATE, IN THE LANGUAGES IT INVENTS. Whisper does not answer silence with + # nothing; it answers with the credit line of whatever it was trained on, and which + # language that lands in is a coin toss. Live: a 1.8s desk bump came back as + # "ご視聴ありがとうございました" (thank you for watching) and was typed into a terminal. + # The structural guards elsewhere are the real fix; this is the list of exact strings + # already seen, and it costs nothing to carry. + "ご視聴ありがとうございました", + "ご視聴ありがとうございます", + "おやすみなさい", + "字幕by索兰娅", + "字幕由amara.org社区提供", + "字幕志愿者 李宗盛", + "请不吝点赞 订阅 转发 打赏支持明镜与点点栏目", + "多谢您的观看", + "감사합니다", + "구독과 좋아요 부탁드립니다", + "sous-titres réalisés par la communauté d'amara.org", + "subtítulos realizados por la comunidad de amara.org", + "sottotitoli e revisione a cura di qtss", + "legendas pela comunidade amara.org", + } +) + + +# Whisper also annotates NON-SPEECH sound rather than returning nothing: "*sad*", "[MUSIC]", +# "(wind blowing)", a run of music notes (U+266A). Observed live on this operator's quiet +# room (it produced "*sad*"). These +# are an open CLASS, not a word list — a blocklist would need a new entry forever — so the whole +# class is matched structurally: a transcript that is ENTIRELY one bracketed/asterisked annotation +# was a description of a sound, not something the operator said, and must never be typed. +_ANNOTATION_ONLY = re.compile(r"^[\s\u266a]*[\*\[\(]([^\]\)\*]*)[\*\]\)][\s\u266a.]*$") + +# An annotation is a LABEL ("sad", "wind blowing", "MUSIC"), never a sentence. Without this cap the +# trap also eats a real dictated line that happens to be fully parenthesised — "(That said, ship it +# anyway.)" — which is the worse bug of the two: a hallucination that slips through is visible and +# deletable, whereas silently swallowing what the operator actually said looks like the mic failed. +# Bias deliberately toward letting text through. +_MAX_ANNOTATION_WORDS = 3 + + +def is_hallucination(text: str, extra: frozenset[str] = frozenset()) -> bool: + """True if ``text`` is whisper's output for silence rather than something that was said. + + **``extra`` is the one thing here that is NOT shared, and it must not become shared.** + :data:`HALLUCINATIONS` holds only what a PERSON NEVER SAYS - subtitle credits, `[BLANK_AUDIO]`, + the credit line in each language whisper invents it in. The polite one-word sentences + ("thank you", "so", "bye") are a different bet in each tool: MurmurFlow types into a document, + where swallowing a sentence somebody did say reads as broken hardware, so it carries none of + them; zyx hands text to a model that can decline to answer, so it carries them all. That is + `deliberately_divergent.hallucination_list` in the voice contract, and a test in MurmurFlow + fails the moment one of those words reaches this table. + + Three shapes: the fixed boilerplate lines it emits for pure silence, a transcript of nothing + but MUSIC NOTES, and the open class of non-speech ANNOTATIONS it emits for room noise. All + three must be trapped — any of them typed into the operator's document is a word he did not say. + """ + stripped = text.strip().lower().strip("\u266a ") + # A bare run of music notes (U+266A) with no brackets around it. The annotation pattern + # below cannot see it because that + # one requires a bracket or an asterisk, and the boilerplate set above cannot either because + # stripping the notes leaves "", which is not a member. So it fell through both traps and was + # PASTED. Found 2026-08-14 while extracting this module into a standalone tool; whisper emits + # bare note runs for music and for room tone, so this was live. + if not stripped: + return bool(text.strip()) + if stripped in HALLUCINATIONS or stripped in extra: + return True + match = _ANNOTATION_ONLY.match(text.strip()) + if match is None: + return False + return len(match.group(1).split()) <= _MAX_ANNOTATION_WORDS + + +_SPACE_BEFORE_PUNCT = re.compile(r"\s+([,.!?;:])") +_DOUBLED_PUNCT = re.compile(r"([,;:])\s*([.!?])") +# A filler strip can leave the sentence dangling on the comma that preceded it ("...fixed, you +# know." -> "...fixed,"). Invisible on a Slack echo; sloppy when it is typed into a document. +_DANGLING_TAIL = re.compile(r"[,;:]+\s*$") + +# Sentence end, for the trailing-boilerplate trap below. Deliberately crude: it only has to find +# the seam between "...make it public." and an appended "Thanks for watching!". +_SENTENCE_END = re.compile(r"(?<=[.!?])\s+") + +# Polite closings whisper invents for the silence at the END of a clip, in both languages the +# operator speaks. These may only ever be matched as a TRAILING sentence with a real one in front +# of them — never on a whole transcript, because every one of them is also a complete thing a +# person says on purpose, and swallowing it looks like broken hardware. That is why they live here +# and not in :data:`_HALLUCINATIONS`. (Ported from MurmurFlow 2026-09-09.) +TRAILING_BOILERPLATE: frozenset[str] = frozenset( + { + "thank you", + "thank you.", + "thank you!", + "thank you very much", + "thank you very much.", + "thanks", + "thanks.", + "thank you for watching", + "thank you for watching.", + "thanks for listening", + "thanks for listening.", + "bye", + "bye.", + "bye!", + "bye bye", + "bye-bye.", + "goodbye", + "goodbye.", + "danke", + "danke.", + "danke schön", + "danke schön.", + "vielen dank", + "vielen dank.", + "tschüss", + "tschüss.", + "auf wiedersehen", + "auf wiedersehen.", + "untertitel im auftrag des zdf", + } +) + + +def strip_trailing_hallucination(text: str, extra: frozenset[str] = frozenset()) -> str: + """Drop whisper's boilerplate when it is APPENDED to a real sentence. + + :func:`is_hallucination` judges the WHOLE line, which is the right shape for a clip that was + nothing but silence. It is the wrong shape for the other half of the same failure: a real + sentence followed by trailing silence comes back as the sentence *plus* the credit line + ("...so only agent flow is public now. Thanks for watching!"). Every gate before this one reads + the transcript as a whole — the confidence score, the language score and the blocklist all see + a confident, real, in-language sentence — so the invented tail passes all three. + + Only whole trailing SENTENCES that :func:`is_hallucination` already recognises are removed, and + never the last one standing — same one-directional bias as everything else here. + """ + parts = _SENTENCE_END.split(text) + while len(parts) > 1 and ( + is_hallucination(parts[-1], extra) or parts[-1].strip().lower() in TRAILING_BOILERPLATE + ): + parts.pop() + return " ".join(parts) + + +def repair_punctuation( + text: str, *, close_dangling: bool, ended: bool, extra: frozenset[str] = frozenset() +) -> str: + """The seam repair every transcript wants, in the one order both tools ran it in. + + ``close_dangling`` is the one genuine difference and it belongs to the caller: a filler strip + can leave the sentence hanging on the comma that preceded it ("...fixed, you know." -> + "...fixed,"), so a tool that strips fillers must close that seam and a tool that does not must + NOT — run unconditionally it quietly eats a trailing comma somebody dictated on purpose, which + is the same class of bug as the strip itself. ``ended`` says whether the raw transcript ended on + a full stop, so the seam is closed with one rather than with nothing. + """ + text = _SPACE_BEFORE_PUNCT.sub(r"\1", text) + text = _DOUBLED_PUNCT.sub(r"\2", text) + # The credit line whisper appends to trailing silence. The whole-transcript case is trapped by + # `is_hallucination`; this is the same invention riding along behind a real sentence. + text = strip_trailing_hallucination(text, extra) + if close_dangling: + text = _DANGLING_TAIL.sub("." if ended else "", text) + return text.strip() + + +# --- the engine: one microphone, one server, one transcript -------------------------------------- + + +@dataclass(frozen=True) +class Setup: + """What the engine needs to know about THIS install, asked ONCE by the caller. + + **This is the seam that lets the engine be one file.** The two tools name their settings + differently (`voicePort` against `port`, `whisperModel` against `model`), read them from + different homes, and answer to different vocabularies - so a single `config.get` in here is a + line that has to differ, and one line that differs is a file that is no longer shared. Each tool + builds one of these from its own configuration and hands it over. + + Every field has a default that is safe rather than clever: an empty binary path or model makes + the function that needs it decline, exactly as a missing binary always did. + """ + + #: Absolute path to the `whisper-server` binary, or "" when it is not installed. + whisper_server: str = "" + #: Absolute path to the model weights, or "" when none was found. + model: str = "" + #: How many decode threads, as the string the CLI wants. + threads: str = "4" + #: The loopback port the warm server listens on. + port: int = 8477 + #: A writable directory. THE SERVER IS STARTED IN IT - see :func:`start_server`. + scratch: Path = Path(".") + #: The proper-noun glossary sent with every request; "" sends none. + vocabulary: str = "" + #: The language to decode as, or "auto". + language: str = "auto" + + +def server_url(port: int) -> str: + return f"http://127.0.0.1:{port}" + + +def server_up(port: int) -> bool: + """True if a warm whisper-server answers on the loopback port.""" + try: + with urllib.request.urlopen(f"{server_url(port)}/", timeout=0.5): + return True + except (urllib.error.URLError, OSError): + return False + + +#: How long an ownership answer is trusted for. See :func:`ours` — the question is "who holds this +#: port", which changes only when a process starts or dies, and asking it costs a `pgrep`. + + +#: How long an ownership answer is trusted for. See :func:`ours` — the question is "who holds this +#: port", which changes only when a process starts or dies, and asking it costs a `pgrep`. +OWNERSHIP_SECONDS = 30.0 + +_OWNERSHIP: dict[int, tuple[float, bool]] = {} + + +def ours(port: int) -> bool: + """Is the thing listening on our port a whisper-server, rather than whatever got there first. + + **Because the answer decides where recorded audio is sent.** The port is predictable, so any + local process can bind it first, receive every clip, and answer with text that gets typed at + the cursor. A socket that accepts a connection proves nothing about who is on the other end. + + So the port has to be held by a `whisper-server` process. Only one process can bind a port, so + finding one there IS the answer. Cached for :data:`OWNERSHIP_SECONDS` because this sits on the + partial path, which asks it about once a second, and `pgrep` is a process spawn. + """ + at = port + now = time.monotonic() + cached = _OWNERSHIP.get(at) + if cached is not None and now - cached[0] < OWNERSHIP_SECONDS: + return cached[1] + try: + found = subprocess.run( + ["pgrep", "-f", f"whisper-server.*--port {at}"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + verdict = bool(found.stdout.split()) + except (OSError, subprocess.SubprocessError): + verdict = False # cannot tell: fail CLOSED, because the cost of being wrong is the audio + _OWNERSHIP[at] = (now, verdict) + return verdict + + +def forget_ownership() -> None: + """Drop every cached ownership verdict — a server was just started, stopped, or faked in a test. + + A module-level cache is per PROCESS, and a test process runs hundreds of tests: without this, a + test that stubs `pgrep` leaves its answer standing for everything that follows it in the same + worker, in file order, which is not an order anybody reads. + """ + _OWNERSHIP.clear() + + +def serve_command(setup: Setup) -> list[str] | None: + """The argv that starts a warm whisper-server, or ``None`` if it cannot be built. + + ONE server, and it answers both the live passes and the final transcription. There used to be + a second one holding a small model for the partials; it was retired when the live pass began + typing punctuation, because the marks it chose were the marks the operator kept. + """ + binary, model = setup.whisper_server, setup.model + if not binary or not model: + return None + return [ + binary, + "-m", + model, + "--host", + "127.0.0.1", + "--port", + str(setup.port), + "-t", + setup.threads, + "--convert", # let the server transcode anything ffmpeg reads, not just wav + # Stop whisper emitting "*sad*"/"[MUSIC]"-style sound annotations at the SOURCE rather than + # filtering them afterwards. `is_hallucination` stays as the backstop: -sns reduces these + # but does not eliminate them. + "-sns", + # EVERY CLIP IS ITS OWN CLIP. whisper.cpp keeps the text it decoded as context for what it + # decodes next, and a SERVER keeps it across REQUESTS — so yesterday's sentence primes + # today's, and under streaming, where one dictation is ~100 overlapping passes over the + # same growing audio, it primes itself with a hundred near-copies of what it just said. + # Measured on a 145s clip, same audio, same prompt, twice in a row: 543 characters one + # run and 1108 the next, one of them collapsing into "And. Your. Job as a founder." nine + # times over. That is the report — "a lot of points in between, it cuts the logic of the + # sentence" — and it is also why the same words came out well before the stream existed. + # `-mc 0` stores no text context, and the same two runs then came back CHARACTER FOR + # CHARACTER identical, in whole clauses, with no repetition: "...that fits your workflow, + # that you connect with that company, you like how they do things, and then go from there." + # + # What it costs is real and small: past 30s whisper decodes each window without the + # previous window's words to lean on. A dictation is one window, the vocabulary prompt is + # sent per request and still applies, and an unstable transcript is not worth a smoother + # seam at 0:30. + "-mc", + "0", + ] + + +def start_server(setup: Setup, *, wait: float = 60.0) -> bool: + """Spawn the warm whisper-server if it is not already up; block until it answers. + + Loading large-v3-turbo takes a few seconds, which is exactly the cost we are paying ONCE here + so that every subsequent dictation does not. Returns True if a server is answering. + + **The ``cwd`` is the whole warm path**, and leaving it out is how MurmurFlow quietly lost it. + ``--convert`` (see :func:`serve_command`) makes whisper-server shell out to ffmpeg, and ffmpeg + writes its converted copy into the server's WORKING DIRECTORY. Started from a shell that + directory is the repo and everything works, which is why this survived every manual test. Under + the installed agent the daemon inherits ``/`` — not writable — so the conversion fails, the + server answers **every single request** with ``500 {"error":"FFmpeg conversion failed."}``, and + every clip silently falls through to the cold CLI. Measured live 2026-08-18 with one server on + ``/`` and one on a writable dir, same binary, same flags, same model, same request: 500 in 0.03s + against a clean transcript in 2.17s. + + Nothing on screen says so. The transcripts still arrive, just slower and — because the cold path + carries no server-side prompt and reports no confidence — measurably worse, with the two silence + gates weakened to boot. `watch_warm` in :func:`listen_loop` faithfully bounced the server after + every second cold clip, all day, into the same broken working directory. + + So the server is started in the caller's own scratch dir, which is writable, and already the one + place recorded voice lives — whisper-server deletes its converted copy when it is done, so the + "empty between sentences" promise there still holds. + """ + if server_up(setup.port): + # Adopted only if a whisper-server holds the port — see :func:`ours`. Anything + # else answering there would be handed recorded audio and believed about what was said. + return ours(setup.port) + cmd = serve_command(setup) + if cmd is None: + return False + try: + subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + cwd=str(setup.scratch), + ) + except (OSError, subprocess.SubprocessError): + return False + deadline = time.time() + wait + while time.time() < deadline: + if server_up(setup.port): + return True + time.sleep(0.1) + return False + + +def stop_server(port: int) -> int: + """Stop the warm whisper-server this install started. Returns how many were stopped. + + The port ABOVE ours is swept too, and it is not a second server of ours: an older MurmurFlow + ran a small model there for the live pass, and a version that no longer starts one must still + stop the one it finds, or ~488 MB stays resident until the machine is next restarted. + + ``start_server`` detaches it with ``start_new_session=True`` so it outlives the listener, which + is the whole point while dictation is installed — and a leak the moment it is not: 1.8 GB + resident with nothing left to ask it anything, until the next reboot. BOTH of ours, matched on + OUR two ports, because a whisper-server on any other port belongs to somebody else. + """ + stopped = 0 + for which in (port, port + 1): + try: + found = subprocess.run( + ["pgrep", "-f", f"whisper-server.*--port {which}"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError): + continue + for token in found.stdout.split(): + with contextlib.suppress(ValueError, ProcessLookupError, PermissionError): + os.kill(int(token), signal.SIGTERM) + stopped += 1 + return stopped + + +def multipart(wav: Path, fields: dict[str, str]) -> tuple[bytes, str]: + """Build a multipart/form-data body for whisper-server's ``/inference`` (stdlib only).""" + boundary = f"----speech{uuid.uuid4().hex}" + parts: list[bytes] = [] + for key, value in fields.items(): + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n' + f"{value}\r\n".encode() + ) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="file"; ' + f'filename="{wav.name}"\r\nContent-Type: audio/wav\r\n\r\n'.encode() + ) + parts.append(wav.read_bytes()) + parts.append(f"\r\n--{boundary}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +# An older whisper-server answers with the language NAME and no `language_probabilities` to read a +# code off. Only the names anybody actually lists in `languages` need to be here; anything else +# falls through as itself, and an unrecognised language is compared as whisper spelled it. + + +_LANGUAGE_CODES: dict[str, str] = { + "english": "en", + "german": "de", + "french": "fr", + "spanish": "es", + "italian": "it", + "dutch": "nl", + "portuguese": "pt", + "polish": "pl", + "russian": "ru", + "japanese": "ja", + "chinese": "zh", + "korean": "ko", +} + + +def language_code(name: str) -> str: + """``"german"``, ``"German"`` and ``"de"`` are one answer; anything else passes through. + + One seam, so the gate that reads what whisper detected and the gate that reads what the user + said they speak can never disagree about what a language is called. + """ + key = str(name).strip().lower() + return _LANGUAGE_CODES.get(key, key) + + +class Heard(NamedTuple): + """A transcript plus how sure whisper was that it was listening to speech at all. + + ``confidence`` is whisper's own ``detected_language_probability``: how strongly the audio looks + like ANY one human language. Speech scores 0.97-0.999 (measured: a 1.2s English slice 0.969, a + single "Okay." 0.981, a German sentence 0.999); room tone, pink noise and digital silence score + 0.32-0.45, because there is no language in them to be sure about. It is ``1.0`` — deliberately + "certain" — whenever the signal is unavailable (cold path, forced language, older server), so a + missing score can never silently swallow something that was said. See :data:`SPEECH_CONFIDENCE`. + """ + + text: str + confidence: float = 1.0 + language: str = "" + #: Did the WARM server answer this one? A wedged server is invisible otherwise — it answers + #: every request with an error, every clip quietly takes the cold path, and the two gates that + #: read a confidence and a language are weaker there. That degradation has to be legible in the + #: log, or the next person to hit it is also debugging blind. ``None`` = nothing was decoded. + warm: bool | None = None + + +def confidence(payload: str) -> tuple[str, float, str]: + """Pull ``(text, detected_language_probability, language)`` out of verbose_json. Never raises. + + ``strict=False`` is load-bearing, not defensive dressing: whisper-server puts the transcript's + trailing newline into the JSON string RAW, which is invalid JSON that ``json.loads`` rejects + outright. A strict parse fails on exactly the short utterances this gate exists to judge. + """ + try: + data = json.loads(payload, strict=False) + except (ValueError, TypeError): + # Not JSON at all — an older server answering a verbose_json request with plain text. Take + # it as the transcript and claim no opinion rather than discarding a real sentence. + return payload.strip(), 1.0, "" + if not isinstance(data, dict): + return payload.strip(), 1.0, "" + text = str(data.get("text", "")).strip() + raw = data.get("detected_language_probability") + # A CODE, NEVER THE NAME. whisper-server reports `"language": "english"` while a person writes + # `["de", "en"]` in their config, so the gate below compared "english" against {"de","en"} and + # would have rejected every sentence he ever spoke the moment the warm server came back up — + # a gate that is inert today and catastrophic tomorrow. `language_probabilities` is keyed by + # the codes themselves, so the top key IS the answer with no table to keep in sync. + probabilities = data.get("language_probabilities") + spoken = "" + if isinstance(probabilities, dict) and probabilities: + numeric = {k: v for k, v in probabilities.items() if isinstance(v, (int, float))} + if numeric: + spoken = str(max(numeric, key=lambda k: numeric[k])).strip().lower() + if not spoken: + spoken = language_code(str(data.get("language", "") or "")) + return text, float(raw) if isinstance(raw, (int, float)) else 1.0, spoken + + +def transcribe_warm(wav: Path, setup: Setup, *, timeout: float = 60.0, language: str = "") -> Heard: + """Transcribe via the warm server; empty text if it is not up or errors. Never raises. + + Reuses :mod:`whisper`'s language and vocabulary decisions, so every surface that transcribes + hears your own proper nouns the same way. + + Asks for ``verbose_json`` rather than ``text`` purely to get the language score back; the + transcript is identical either way. + + ``language`` overrides the configured one for THIS request, and it exists for streaming. + Detecting the language is a whole extra encoder pass — measured at 0.75s of every 2.2s request + on an M4 Pro — and a clip does not change language halfway through, so the partials after the + first pin themselves to what the first one heard. See :func:`_stream_loop`. + + **It asks who holds the port before it sends anything** (:func:`ours`). Adopting a server was + guarded and SENDING was not, which is the wrong half: a process that binds :func:`port` first is + handed every clip you record and believed about what was in it — and what comes back is TYPED + AT YOUR CURSOR. Cached, so this is a dict read on the partial path and a `pgrep` twice a minute. + """ + if not wav.is_file() or not ours(setup.port): + return Heard("") + fields = { + "response_format": "verbose_json", + "language": language or setup.language, + "prompt": setup.vocabulary, + "temperature": "0", + } + try: + body, content_type = multipart(wav, fields) + except OSError: + return Heard("") + request = urllib.request.Request( + f"{server_url(setup.port)}/inference", + data=body, + headers={"Content-Type": content_type}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = response.read().decode("utf-8", errors="ignore") + except (urllib.error.URLError, OSError, ValueError): + return Heard("") + return Heard(*confidence(payload), warm=True) + + +# --- finding the pieces this install has ------------------------------------------------------- + +#: Homebrew's bin dirs. launchd hands an agent a minimal PATH that excludes them, so a bare +#: `shutil.which()` finds nothing when the listener runs from a plist while working fine in a +#: shell. Both tools are installed as launchd/Task Scheduler agents, and both learned this the +#: same way. +FALLBACK_BIN_DIRS: tuple[str, ...] = ("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin") + + +def resolve_bin(name: str) -> str: + """Absolute path to ``name``, searching PATH then :data:`FALLBACK_BIN_DIRS`; ``''`` if absent. + + Never raises. The fallback dirs matter only under an agent (see the constant). + """ + found = shutil.which(name) + if found: + return found + for directory in FALLBACK_BIN_DIRS: + candidate = Path(directory) / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return "" + + +def pick_input( + want: str, devices: Sequence[tuple[str, str]], *, default: str = "default" +) -> tuple[str, str]: + """Choose the microphone from an enumerated list. ``(id, name)``, never raises. + + **Matched by NAME, never by position.** Device ids shift the moment a USB interface is plugged + in — pinning one would silently start recording the wrong device, which is the failure nobody + notices until they read the transcript. A name miss prefers anything calling itself a + microphone over an aggregate interface, and failing that falls back to the system default, so + the capture degrades to "wrong-ish mic" rather than to "broken". + + The ENUMERATION is the caller's, and so is ``default`` — an avfoundation index on macOS, a + dshow name on Windows. Only the choosing is here, because the rule is about how people name + microphones, not about an API. + """ + wanted = want.strip().lower() + for device, name in devices: + if wanted and wanted in name.lower(): + return str(device), name + for device, name in devices: # name miss: prefer a real mic over an aggregate interface + # ...in both spellings, because a German Windows calls it "Mikrofon" and the operator's + # clients run German machines. It costs one `or` and it is the difference between picking + # the built-in microphone and picking whatever aggregate device sorted first. + lowered = name.lower() + if "microphone" in lowered or "mikrofon" in lowered: + return str(device), name + return default, "system default" + + +# --- the recorder ------------------------------------------------------------------------------ +# +# WHAT IS NOT HERE, and why: which microphone, where the scratch dir is, what the marker file is +# called, and how this platform names an audio input. Every one of those is a question about an +# INSTALL, so the caller answers them and passes the answer in — `capture` is the platform's own +# input arguments with the device already resolved, which is the seam a Windows port needs and the +# reason this file has no idea what avfoundation is. + +#: The recorder this process spawned, when it spawned one. Held so :func:`_exited` can REAP it: a +#: child that has exited but not been waited on is a zombie, and signalling a zombie succeeds. +_PROC: subprocess.Popen[bytes] | None = None + + +@dataclass(frozen=True) +class Recording: + """An in-flight capture: the ffmpeg pid and the wav it is writing.""" + + pid: int + wav: Path + started_at: float + + @property + def seconds(self) -> float: + return max(0.0, time.time() - self.started_at) + + +def current(state: Path) -> Recording | None: + """The in-flight recording, or ``None``. Stale markers (dead pid) are cleaned up and ignored.""" + path = state + try: + raw = json.loads(path.read_text("utf-8")) + rec = Recording(int(raw["pid"]), Path(raw["wav"]), float(raw["started_at"])) + except (OSError, ValueError, KeyError, TypeError): + return None + try: + os.kill(rec.pid, 0) # signal 0 = liveness probe, kills nothing + except (OSError, ProcessLookupError): + path.unlink(missing_ok=True) + return None + return rec + + +def start( + *, + ffmpeg: str, + capture: Sequence[str], + scratch: Path, + state: Path, + max_seconds: int = MAX_CLIP_SECONDS, +) -> Recording | None: + """Begin capturing the mic to a fresh 16kHz mono wav; ``None`` if already recording or unable. + + Returns as soon as ffmpeg is spawned — CoreAudio needs ~300ms more before the first sample + actually lands (measured; it is a device-start floor, not an ffmpeg tax, so a compiled helper + would not beat it). Callers that cue the operator should cue on :func:`ready`, not on this + return, or the first word is clipped. + """ + if current(state) is not None: + return None + if not ffmpeg: + return None + wav = scratch / f"dictate-{int(time.time())}-{uuid.uuid4().hex[:8]}.wav" + cmd = [ + ffmpeg, + "-nostdin", + "-loglevel", + "error", + # A HARD CEILING on one clip, and it is a privacy control, not a convenience. ffmpeg is + # spawned with `start_new_session=True` so it survives its parent: kill the daemon (launchd + # restart, a crash, `kickstart -k`) while a clip is running and nothing ever stops it. Found + # live on the operator's machine — THREE orphaned recorders, 4.5 hours each, 1.4 GB of his + # voice on disk and the microphone hot the whole time, which is the exact incident this + # product exists not to cause. `-t` makes the recorder bound its own life, with no + # supervisor needed and nothing to remember. + "-t", + str(max_seconds), + *capture, + # ffmpeg's avfoundation input keeps exactly ONE pending audio buffer and releases the + # previous one whenever a new buffer arrives before its reader has taken it, so a little + # scheduling jitter silently costs samples. Measured here: ~11% of every capture, on the + # built-in mic, on an aggregate interface AND on a pure-software loopback with no hardware + # clock at all — so it is the input device implementation, not the microphone. It is also + # unreachable from the CLI: identical loss whether ffmpeg resamples and converts or copies + # raw bytes, and `-thread_queue_size` changes nothing. + # + # The damage is not the missing samples themselves but WHERE the hole goes. ffmpeg takes + # the timestamps from the buffers it did get, so the gap is spliced out and the whole + # sentence is handed to whisper ~11% too fast. `async=1` fills the gaps instead of closing + # them, which keeps the clip on real time (measured 87% -> 98% of the hold) and stops + # speech being sped up. It cannot bring the lost samples back; recovering those means + # leaving avfoundation for a ctypes CoreAudio recorder, which is not worth it while + # transcripts are this good. + "-af", + "aresample=async=1", + "-ar", + "16000", + "-ac", + "1", + # Write every packet straight through instead of buffering. Without this ffmpeg holds ~2s + # of audio in memory before the file grows, so `ready()` cannot tell that the mic went live + # until long after it did — and the operator gets his "start talking" cue two seconds late, + # by which point he has already said the first half of his sentence. + "-flush_packets", + "1", + "-y", + str(wav), + ] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # survive the caller; we stop it explicitly by pid + ) + except (OSError, subprocess.SubprocessError): + return None + global _PROC + _PROC = proc # so stop() can REAP it — see the zombie note there + rec = Recording(proc.pid, wav, time.time()) + with contextlib.suppress(OSError): + state.write_text( + json.dumps({"pid": rec.pid, "wav": str(rec.wav), "started_at": rec.started_at}), + "utf-8", + ) + return rec + + +def ready(rec: Recording, *, timeout: float = 2.0) -> bool: + """Block until the wav has actual audio frames in it (or ``timeout``). ``True`` if it does. + + The ~300ms CoreAudio start-up is invisible to the operator only if he is cued when the mic is + genuinely live. A 44-byte wav is a header with no samples yet. + """ + deadline = time.time() + timeout + while time.time() < deadline: + try: + if rec.wav.stat().st_size > 1024: + return True + except OSError: + pass + time.sleep(0.02) + return False + + +def _exited(pid: int) -> bool: + """True once the recorder with ``pid`` is really gone — zombies included. + + ``os.kill(pid, 0)`` is NOT enough: a child that has exited but not been reaped is a zombie, and + signalling a zombie SUCCEEDS. Polling it therefore never observes the exit, which cost a flat + two seconds on every single dictation (measured: ffmpeg itself is gone in ~34ms) before the + loop gave up and SIGKILLed a process that had been dead the whole time. When the recorder is + our own child we reap it with ``waitpid``; when it is not (one CLI invocation starts it and + invocation and stops it in another) no zombie can exist for us, so signal-0 is accurate. + """ + proc = _PROC + if proc is not None and proc.pid == pid: + return proc.poll() is not None + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass # not our child: signal-0 below is authoritative + except OSError: + return True + try: + os.kill(pid, 0) + except (OSError, ProcessLookupError): + return True + return False + + +def stop(rec: Recording | None, *, state: Path) -> Path | None: + """Stop the in-flight capture and return the finished wav (``None`` if nothing was recording). + + SIGINT (not SIGKILL) so ffmpeg writes the RIFF trailer — a killed ffmpeg leaves a wav whose + header claims zero length and whisper decodes it as silence. This sits directly on the felt + latency (it runs the instant the operator lets go of the key), so exit is detected by REAPING + the child rather than polling it — see :func:`_exited`. + """ + global _PROC + rec = rec or current(state) + if rec is None: + return None + with contextlib.suppress(OSError, ProcessLookupError): + os.kill(rec.pid, signal.SIGINT) + deadline = time.time() + 2.0 + while time.time() < deadline: + if _exited(rec.pid): + break + time.sleep(0.01) + else: # never exited — force it, the trailer is lost but a truncated wav still decodes + with contextlib.suppress(OSError, ProcessLookupError): + os.kill(rec.pid, signal.SIGKILL) + if _PROC is not None and _PROC.pid == rec.pid: + _PROC = None + _clear_state_for(rec.pid, state) + return rec.wav if rec.wav.is_file() else None + + +def reap_orphans(*, scratch: Path, state: Path) -> int: + """Stop every recorder left behind by a previous run, and delete its audio. Returns how many. + + A recorder is spawned with ``start_new_session=True`` so it outlives the call that started it — + which also means it outlives the DAEMON. A launchd restart, a crash or a ``kickstart -k`` in the + middle of a clip leaves ffmpeg running forever with the microphone open: found live on the + operator's machine as three recorders, 4.5 hours each, 1.4 GB of his voice on disk. `-t` + (:data:`MAX_CLIP_SECONDS`) bounds the damage; this ends it, because a daemon that is only now + starting cannot own a clip from before it existed. + + Matched on the exact scratch-path pattern this module writes, so no other ffmpeg on the machine + is ever a candidate. Best-effort and silent on any failure — a reaper must never keep the daemon + from starting. + """ + reaped = 0 + try: + pattern = f"-y {scratch}/dictate-" + found = subprocess.run( + # `--` IS LOad-BEARING, and its absence is why this reaper never reaped anything. The + # pattern begins with `-y`, so without the guard pgrep parses it as its own option and + # exits with "illegal option -- y" before matching a single process. It failed silently + # in exactly the shape this function is written to tolerate — empty stdout, no raise — + # so every daemon start reported nothing to reap while an orphan held the microphone + # open. Found live on the operator's machine: one recorder open since 4:03pm, writing + # to a wav that had already been deleted, with the mic indicator lit the whole time. + ["pgrep", "-f", "--", pattern], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + pids = [int(line) for line in found.stdout.split() if line.strip().isdigit()] + for pid in pids: + if pid == os.getpid(): + continue + with contextlib.suppress(OSError, ProcessLookupError): + os.kill(pid, signal.SIGINT) # SIGINT, so the wav still gets its RIFF trailer + reaped += 1 + except (OSError, subprocess.SubprocessError, ValueError): + return reaped + # The audio goes too: a clip nobody is waiting for has no transcription to outlive, and the + # contract is that the operator's voice does not sit on disk (`voiceKeepAudio` keeps ONE file, + # deliberately, and it is not named like these). + with contextlib.suppress(OSError): + for wav in scratch.glob("dictate-*.wav"): + wav.unlink(missing_ok=True) + state.unlink(missing_ok=True) + return reaped + + +def _clear_state_for(pid: int, state: Path) -> None: + """Drop the in-flight marker, but ONLY if it still describes ``pid``. + + A huddle answers on a worker thread so the operator can interrupt, which means his NEXT + recording can already be running by the time the previous one is stopped. Unlinking + unconditionally orphaned it — ffmpeg still capturing, ``current()`` reporting nothing — so the + clip he was in the middle of speaking could never be finished. An unreadable marker is cleared, + since a marker nobody can parse is worse than none. + """ + try: + raw = json.loads(state.read_text("utf-8")) + if int(raw["pid"]) != pid: + return + except (OSError, ValueError, KeyError, TypeError): + pass + state.unlink(missing_ok=True) diff --git a/tests/test_murmurflow.py b/tests/test_murmurflow.py index 1b93d1c..be781e6 100644 --- a/tests/test_murmurflow.py +++ b/tests/test_murmurflow.py @@ -22,12 +22,13 @@ import types import wave from pathlib import Path +from types import SimpleNamespace import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from murmurflow import cli, config, dictate, platforms, service, whisper +from murmurflow import cli, config, dictate, gesture, platforms, service, speech, whisper @pytest.fixture(autouse=True) @@ -38,7 +39,7 @@ def _isolated_home(tmp_path, monkeypatch): # Who holds a port is cached across calls, and the cache is module state. On a DEVELOPER's Mac # a real whisper-server is on that port, so a stale True leaked into other tests and hid a # failure that only CI — where nothing is listening — could see. - dictate._OWNERSHIP.clear() + dictate.forget_ownership() # And an unclaimed pre-roll is module state too: one left behind makes the NEXT test's # `preroll` a no-op and its `preroll_claim` wait out the full claim timeout. dictate._PREROLL = None @@ -311,7 +312,7 @@ def test_a_second_listener_is_refused_and_told_who_has_the_key(monkeypatch): # The doubled-sound bug: the login agent is live and you run `murmurflow listen` to watch it. dictate.listener_lock_path().parent.mkdir(parents=True, exist_ok=True) dictate.listener_lock_path().write_text("4242", "utf-8") - monkeypatch.setattr(dictate, "_exited", lambda pid: False) # 4242 is alive + monkeypatch.setattr(speech, "_exited", lambda pid: False) # 4242 is alive assert dictate.listener_pid() == 4242 assert dictate.claim_listener() == 4242 @@ -320,7 +321,7 @@ def test_a_lock_left_by_a_crash_never_blocks_the_next_start(monkeypatch): # A hard reboot must not leave dictation needing a file deleted by hand. dictate.listener_lock_path().parent.mkdir(parents=True, exist_ok=True) dictate.listener_lock_path().write_text("4242", "utf-8") - monkeypatch.setattr(dictate, "_exited", lambda pid: True) # 4242 is gone + monkeypatch.setattr(speech, "_exited", lambda pid: True) # 4242 is gone assert dictate.listener_pid() == 0 assert dictate.claim_listener() == 0 assert dictate.listener_lock_path().read_text("utf-8") == str(os.getpid()) @@ -447,7 +448,7 @@ def fake_sleep(seconds): clock[0] += seconds monkeypatch.setattr( - hotkey, "time", types.SimpleNamespace(monotonic=lambda: clock[0], sleep=fake_sleep) + gesture, "time", types.SimpleNamespace(monotonic=lambda: clock[0], sleep=fake_sleep) ) monkeypatch.setattr(hotkey, "seconds_since_keydown", lambda: 99.0) # never a chord down = iter([True, False, False]) @@ -727,9 +728,9 @@ def test_a_language_you_do_not_speak_is_a_hallucination(): def test_the_server_language_is_read_off_the_body_beside_the_score(): body = json.dumps({"text": "hallo", "detected_language_probability": 0.99, "language": "de"}) - assert dictate._confidence(body) == ("hallo", 0.99, "de") + assert speech.confidence(body) == ("hallo", 0.99, "de") # An older server, or the cold path: no opinion, and no opinion is never a refusal. - assert dictate._confidence("just text") == ("just text", 1.0, "") + assert speech.confidence("just text") == ("just text", 1.0, "") # --- the platform seam ------------------------------------------------------------------------ @@ -955,11 +956,11 @@ def test_the_language_gate_reads_a_code_even_when_the_server_says_a_name(monkeyp "language_probabilities": {"en": 0.99, "de": 0.004}, } ) - text, confidence, spoken = dictate._confidence(payload) + text, confidence, spoken = speech.confidence(payload) assert (text, spoken) == ("hello there", "en") assert confidence == 0.99 # An older server with no probabilities still resolves through the name table. - _, _, older = dictate._confidence(json.dumps({"text": "hallo", "language": "german"})) + _, _, older = speech.confidence(json.dumps({"text": "hallo", "language": "german"})) assert older == "de" @@ -1237,11 +1238,11 @@ def _popen(cmd, **kwargs): seen["cmd"], seen["kwargs"] = cmd, kwargs raise OSError("not really spawning anything in a test") - monkeypatch.setattr(dictate, "server_up", lambda _at=0: False) + monkeypatch.setattr(speech, "server_up", lambda _port=0: False) monkeypatch.setattr( - dictate, "serve_command", lambda _m="", _at=0: ["whisper-server", "--convert"] + speech, "serve_command", lambda _setup: ["whisper-server", "--convert"] ) - monkeypatch.setattr(dictate.subprocess, "Popen", _popen) + monkeypatch.setattr(speech.subprocess, "Popen", _popen) assert dictate.start_server() is False cwd = Path(str(seen["kwargs"]["cwd"])) assert cwd.is_dir(), "the server must not be spawned into a directory that does not exist" @@ -1632,8 +1633,8 @@ def _down(_trigger): clock[0] += gap return held - monkeypatch.setattr(hotkey.time, "monotonic", lambda: clock[0]) - monkeypatch.setattr(hotkey.time, "sleep", lambda _s: None) + monkeypatch.setattr(gesture.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(gesture.time, "sleep", lambda _s: None) monkeypatch.setattr(hotkey, "is_trigger_down", _down) monkeypatch.setattr(hotkey, "seconds_since_keydown", lambda: 99.0) # never a chord with contextlib.suppress(SystemExit): @@ -1682,8 +1683,8 @@ def _down(_trigger): live[0] = False return held - monkeypatch.setattr(hotkey.time, "monotonic", lambda: clock[0]) - monkeypatch.setattr(hotkey.time, "sleep", lambda _s: None) + monkeypatch.setattr(gesture.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(gesture.time, "sleep", lambda _s: None) monkeypatch.setattr(hotkey, "is_trigger_down", _down) monkeypatch.setattr(hotkey, "seconds_since_keydown", lambda: 99.0) with contextlib.suppress(SystemExit): @@ -2317,7 +2318,7 @@ class _Nobody: assert dictate.ours() is False assert dictate.start_server() is False # and it is never adopted - dictate._OWNERSHIP.clear() + dictate.forget_ownership() class _Whisper: stdout = "4242\n" @@ -2374,7 +2375,7 @@ def _receipt_at(tmp_path, body): def test_a_local_checkout_is_reinstalled_from_that_checkout(tmp_path, monkeypatch): """`git pull` changes the checkout; the daemon runs the COPY uv made. So install re-copies.""" - monkeypatch.setattr(cli.dictate, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") + monkeypatch.setattr(cli.speech, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") checkout = tmp_path / "murmurflow" checkout.mkdir() receipt = _receipt_at( @@ -2393,7 +2394,7 @@ def test_a_local_checkout_is_reinstalled_from_that_checkout(tmp_path, monkeypatc def test_an_install_from_git_or_pypi_is_upgraded_instead(tmp_path, monkeypatch): - monkeypatch.setattr(cli.dictate, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") + monkeypatch.setattr(cli.speech, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") receipt = _receipt_at(tmp_path, '[tool]\nrequirements = [{ name = "murmurflow" }]\n') assert cli._update_command(receipt) == ["/opt/homebrew/bin/uv", "tool", "upgrade", "murmurflow"] @@ -2401,9 +2402,9 @@ def test_an_install_from_git_or_pypi_is_upgraded_instead(tmp_path, monkeypatch): def test_no_uv_and_a_corrupt_receipt_both_mean_do_not_update(tmp_path, monkeypatch): """An update that cannot run is an inconvenience. An `install` that refuses is a dead tool.""" receipt = _receipt_at(tmp_path, '[tool]\nrequirements = [{ name = "murmurflow" }]\n') - monkeypatch.setattr(cli.dictate, "resolve_bin", lambda _n: "") + monkeypatch.setattr(cli.speech, "resolve_bin", lambda _n: "") assert cli._update_command(receipt) is None - monkeypatch.setattr(cli.dictate, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") + monkeypatch.setattr(cli.speech, "resolve_bin", lambda _n: "/opt/homebrew/bin/uv") assert cli._update_command(_receipt_at(tmp_path, "not toml at all {{{")) is None @@ -2570,3 +2571,70 @@ def _record(text, settle): def test_whitespace_alone_is_still_nothing_to_type(): assert dictate.inject(" ")[1] == "nothing to type" + + +def _ffmpeg_shaped_wav(path: Path, seconds_loud: float, seconds_quiet: float) -> None: + """A wav shaped like the one `start()` actually writes — LIST chunk and all. + + Measured 2026-09-09 with the recorder's own argv: ffmpeg puts a 26-byte `LIST`/`INFO` chunk + between `fmt ` and `data`, so the samples begin at byte 78 and not at the 44 every "skip the + header" constant assumed. + """ + audio = (b"\x00\x40" * int(dictate.SAMPLE_RATE * seconds_loud)) + ( + b"\x00\x00" * int(dictate.SAMPLE_RATE * seconds_quiet) + ) + fmt = ( + b"fmt " + + (16).to_bytes(4, "little") + + (1).to_bytes(2, "little") + + (1).to_bytes(2, "little") + + dictate.SAMPLE_RATE.to_bytes(4, "little") + + dictate.BYTES_PER_SECOND.to_bytes(4, "little") + + (2).to_bytes(2, "little") + + (16).to_bytes(2, "little") + ) + info = ( + b"LIST" + + (26).to_bytes(4, "little") + + b"INFOISFT" + + (14).to_bytes(4, "little") + + b"Lavf61.7.100\x00\x00" + ) + data = b"data" + len(audio).to_bytes(4, "little") + audio + body = b"WAVE" + fmt + info + data + path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body) + + +def test_the_samples_are_found_where_they_are_and_not_at_a_guessed_44(tmp_path: Path) -> None: + """The trim and the tail read seek past "the header", so the header has to be measured.""" + wav = tmp_path / "ffmpeg-shaped.wav" + _ffmpeg_shaped_wav(wav, seconds_loud=1.0, seconds_quiet=4.0) + assert dictate.data_offset(wav) == 78 + + assert dictate.trim_trailing_quiet(wav) is True + kept = (wav.stat().st_size - 78) / dictate.BYTES_PER_SECOND + assert 1.0 <= kept <= 1.0 + dictate.TRIM_KEEP_SECONDS + dictate.TRIM_BLOCK_SECONDS + with wave.open(str(wav), "rb") as handle: + assert handle.getnframes() == (wav.stat().st_size - 78) // 2 + + broken = tmp_path / "broken.wav" + broken.write_bytes(b"not a wav at all") + assert dictate.data_offset(broken) == dictate.MIN_HEADER_BYTES + + +def test_recorded_audio_is_never_sent_to_a_port_a_whisper_server_does_not_hold( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The port is predictable, so whoever binds it first receives the clip AND types the answer.""" + wav = tmp_path / "clip.wav" + _ffmpeg_shaped_wav(wav, seconds_loud=0.5, seconds_quiet=0.0) + dictate.forget_ownership() + monkeypatch.setattr( + dictate.subprocess, "run", lambda *_a, **_k: SimpleNamespace(stdout="", returncode=1) + ) + + def _never(*_a: object, **_k: object) -> None: # pragma: no cover — the point is it is not hit + raise AssertionError("audio was sent to a port nothing of ours holds") + + monkeypatch.setattr(dictate.urllib.request, "urlopen", _never) + assert dictate.transcribe_warm(wav).text == "" diff --git a/tests/test_voice_contract.py b/tests/test_voice_contract.py index 96ae25e..24af486 100644 --- a/tests/test_voice_contract.py +++ b/tests/test_voice_contract.py @@ -13,11 +13,14 @@ from __future__ import annotations +import hashlib import inspect import json from pathlib import Path -from murmurflow import dictate +import pytest + +from murmurflow import dictate, speech CONTRACT = json.loads( (Path(__file__).resolve().parents[1] / "voice-contract.json").read_text("utf-8") @@ -40,7 +43,7 @@ def test_every_threshold_still_holds_its_measured_value() -> None: def test_the_warm_request_still_asks_whisper_the_same_question() -> None: """Read off the source, because the alternative is a live whisper-server in the suite.""" spec = CONTRACT["whisper_warm_request"] - src = inspect.getsource(dictate.transcribe_warm) + src = inspect.getsource(speech.transcribe_warm) for field in spec["required_fields"]: assert f'"{field}"' in src, f"the warm request dropped `{field}`: {spec['why']}" assert f'"{spec["response_format"]}"' in src @@ -57,16 +60,16 @@ def test_the_warm_server_is_started_where_it_can_write() -> None: spec = CONTRACT["whisper_server_flags"] # Read the SOURCE, not a call: `serve_command()` returns None without a resolvable binary # and model, so calling it would pass vacuously on any machine that has neither. - src = inspect.getsource(dictate.serve_command) + src = inspect.getsource(speech.serve_command) for flag in spec["must_contain"]: assert f'"{flag}"' in src, f"{flag} missing: {spec['why']}" if spec["must_run_with_cwd"]: - assert "cwd=" in inspect.getsource(dictate.start_server), spec["why"] + assert "cwd=" in inspect.getsource(speech.start_server), spec["why"] def test_capture_still_stays_on_real_time() -> None: spec = CONTRACT["ffmpeg_capture"] - src = inspect.getsource(dictate.start) + src = inspect.getsource(speech.start) for token in spec["must_contain"]: assert f'"{token}"' in src, f"capture dropped `{token}`: {spec['why']}" @@ -98,3 +101,75 @@ def test_boilerplate_appended_to_a_real_sentence_never_survives_tidy() -> None: spec = CONTRACT["trailing_hallucination"] for raw, expected in spec["must_hold"]: assert dictate.tidy(raw) == expected, spec["why"] + + +# --- the core itself, not just the measurements ------------------------------------------------- + +#: Every file that is byte-identical in zyx, and the one command that keeps them so. +SHARED = ("speech.py", "gesture.py") +_HERE = Path(__file__).resolve().parents[1] + + +def _core(name: str) -> Path: + return _HERE / "murmurflow" / name + + +def _recorded(name: str) -> str: + for line in (_HERE / "voice-core.sha256").read_text("utf-8").splitlines(): + if line.strip().endswith(name): + return line.split()[0] + raise AssertionError(f"{name} has no digest in voice-core.sha256") + + +@pytest.mark.parametrize("name", SHARED) +def test_the_shared_files_are_the_copies_both_tools_carry(name: str) -> None: + """ONE COPY, TWO TOOLS — and a copy nothing checks is two copies again in three weeks. + + `voice-contract.json` pins the MEASUREMENTS and it did its job: the thresholds never drifted. + What drifted was everything around them — a wav header offset, a hallucination table, a + trailing-silence trim, a level scan, and a hold floor that waited its remainder here and the + whole floor again there — because "the same code in both repos" was a habit, and habits lose to + three weeks and 24 commits. + + So the shared layer is TWO FILES (`speech.py`, the audio and the transcript; `gesture.py`, what + a hand does with one key) and both are byte-identical in zyx. This test cannot see zyx and does + not try: it checks that neither file has been edited since the two were last made equal. The + ritual is `make voice-sync`, run from the zyx checkout. + """ + digest = hashlib.sha256(_core(name).read_bytes()).hexdigest() + assert digest == _recorded(name), ( + f"murmurflow/{name} changed. It is SHARED: zyx carries the same file byte for byte. Run " + "`make voice-sync` in the zyx checkout (it copies the file and rewrites the digest in both " + "repos), then commit both." + ) + + +def test_nothing_in_the_shared_core_asks_a_question_about_this_install() -> None: + """The floors are ARGUMENTS in there, never settings, or the file cannot be the same file. + + The two tools name their settings differently (`quietFloor` against `voiceQuietFloor`) and read + them from different places, so one line of config in `speech` is a line that has to differ — and + one line that differs is a file that is no longer shared. + """ + for name in SHARED: + src = _core(name).read_text("utf-8") + for forbidden in ("import config", "config.flag", "_cfg(", "quiet_floor()", "os.environ"): + assert forbidden not in src, ( + f"`{forbidden}` in murmurflow/{name}: a shared file reads no configuration. " + "Take the value as an argument and let each tool answer for its own install." + ) + + +def test_the_polite_one_word_sentences_are_not_in_the_shared_table() -> None: + """`deliberately_divergent.hallucination_list`, enforced where it can actually be enforced. + + zyx's blocklist holds "thank you", "you", "so" and "bye", and that is right THERE: what reads + the transcript is a model that can decline to answer. Here the transcript is TYPED, so a real + sentence swallowed reads as broken hardware — and the shared table is the one place those two + bets could quietly be merged into one. This is the test that noticed when they were. + """ + for word in ("thank you", "thank you.", "you", "so", "bye", "vielen dank", "."): + assert word not in speech.HALLUCINATIONS, ( + f"{word!r} reached the SHARED hallucination table. It is a thing a person says, and " + "MurmurFlow types what a person says." + ) diff --git a/voice-contract.json b/voice-contract.json index b8011ae..1c9f317 100644 --- a/voice-contract.json +++ b/voice-contract.json @@ -71,6 +71,6 @@ "deliberately_divergent": { "tidy": "zyx strips spoken fillers unconditionally; MurmurFlow made it `stripFillers`, default OFF, after it deleted a leading 'hey' from a real sentence, and has since cut every lexical entry from the list - it strips the sounds um/uh/erm/hmm and nothing that is also a word. DIFFERENT ON PURPOSE: zyx's reader is a language model that wants the signal, MurmurFlow's is a human who said those words. Whitespace collapse is NOT in this category - both must flatten whisper's per-segment newlines, because nobody can speak a newline.", "hallucination_list": "MurmurFlow deliberately narrowed the blocklist. A false positive deletes a sentence somebody did say, which reads as broken hardware; the confidence gate above subsumes most of it.", - "quiet_floor": "MurmurFlow has QUIET_DBFS (-30.0) and zyx has no equivalent. Correct for both: MurmurFlow pastes into a document, where transcribing a room is worse than dropping a quiet clip; zyx hands text to a model that can simply decline to answer. Measured -38 dBFS for a loud silent room and -15 for the quietest real speech, so -30 sits between them. Do NOT lower it to -40." + "quiet_floor": "BOTH have QUIET_DBFS (-30.0) now, and they answer DIFFERENT questions with it. MurmurFlow also DROPS a clip whose peak is under the floor, because it types into a document where transcribing a room is worse than losing a sentence; zyx drops nothing, because it hands text to a model that can simply decline to answer, and that half stays divergent on purpose. What zyx uses the number for is WHERE THE SILENCE IS: has the speaker stopped (the microphone closes itself after SILENCE_STOP_SECONDS) and is this tail worth transcribing (the trailing-quiet trim, without which every self-closed clip ends in silence and comes back with an invented sentence on it). Measured -38 dBFS for a loud silent room and -15 dBFS for the quietest real speech, so -30 sits between them. Do NOT lower it to -40." } } diff --git a/voice-core.sha256 b/voice-core.sha256 new file mode 100644 index 0000000..567bed7 --- /dev/null +++ b/voice-core.sha256 @@ -0,0 +1,2 @@ +a9b2a21eddb01613e1deb13c4e1d7b3f7f986264dd7a8b616fbb791a1721738f speech.py +7482506a31462a63f015e24808ef05651ee40583b4c9768d5d6312a90be10abf gesture.py