From 9930214208531d1a6b153153e5d0ad01bd437e1e Mon Sep 17 00:00:00 2001 From: hannesreinsch Date: Wed, 9 Sep 2026 19:14:25 +0200 Subject: [PATCH 1/3] fix(dictate): the header is measured, and the port is checked before audio goes to it Two defects, both found while porting this month's work into zyx, and both live in both copies. THE SAMPLES DO NOT START AT BYTE 44. Measured with `start()`'s own argv on macOS: ffmpeg writes a 26-byte LIST/INFO chunk between `fmt ` and `data`, so the first sample is at 78. `trim_trailing_quiet` was reading 34 bytes of encoder metadata as audio and cutting every clip 34 bytes early, and `tail_dbfs` was measuring its "have you stopped talking" window from the wrong floor. `data_offset` walks the chunks instead, and works on a file ffmpeg is still appending to, because only the two SIZE fields are left for an exit that may never come. `ours()` GUARDED ADOPTING A SERVER AND NOT SENDING TO ONE, which is the wrong half. The port is predictable, so a process that binds it first is handed every clip you record and believed about what was in it - and what comes back is typed at your cursor. `transcribe_warm` asks first now; the answer is cached, so the partial path pays a dict read and a `pgrep` twice a minute. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J3aFkYVLa3FpjYmpiD4g5V --- murmurflow/dictate.py | 55 +++++++++++++++++++++++++++++--- tests/test_murmurflow.py | 68 ++++++++++++++++++++++++++++++++++++++++ voice-contract.json | 2 +- 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/murmurflow/dictate.py b/murmurflow/dictate.py index 649e74d..a1d6bc1 100644 --- a/murmurflow/dictate.py +++ b/murmurflow/dictate.py @@ -414,6 +414,48 @@ def _exited(pid: int) -> bool: return False +#: 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`. +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 a real recording it is the wrong one.** Measured 2026-09-09 with + :func:`start`'s own argv on macOS: ffmpeg writes a 26-byte ``LIST``/``INFO`` chunk (its own + encoder name) between ``fmt `` and ``data``, so the first sample is at byte **78**. Everything + that seeks past "the header" by a constant — :func:`trim_trailing_quiet`, :func:`tail_dbfs` — + was reading 34 bytes of that metadata as audio and cutting the clip 34 bytes early. Small + enough to have gone unnoticed, wrong on every clip. + + 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 — which is what + :func:`repair_wav` is for. + """ + 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. @@ -906,8 +948,12 @@ def transcribe_warm(wav: Path, *, timeout: float = 60.0, language: str = "") -> 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(): + if not wav.is_file() or not ours(): return Heard("") fields = { "response_format": "verbose_json", @@ -2185,10 +2231,11 @@ def trim_trailing_quiet(wav: Path) -> bool: it, and the level gates in :func:`finish` are what should judge it and say so. """ block = int(TRIM_BLOCK_SECONDS * BYTES_PER_SECOND) + head = data_offset(wav) # NOT 44: ffmpeg writes a LIST chunk in there too try: size = wav.stat().st_size with wav.open("rb") as handle: - handle.seek(44) + handle.seek(head) audio = handle.read() except OSError: return False @@ -2199,7 +2246,7 @@ def trim_trailing_quiet(wav: Path) -> bool: 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) + keep = head + (last + 1) * block + int(TRIM_KEEP_SECONDS * BYTES_PER_SECOND) if keep >= size: return False try: @@ -2229,7 +2276,7 @@ def tail_dbfs(wav: Path, seconds: float) -> float: return 0.0 try: size = wav.stat().st_size - if size < want + 44: # 44 = the standard PCM wav header ffmpeg writes + 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) diff --git a/tests/test_murmurflow.py b/tests/test_murmurflow.py index 1b93d1c..4e11826 100644 --- a/tests/test_murmurflow.py +++ b/tests/test_murmurflow.py @@ -22,6 +22,7 @@ import types import wave from pathlib import Path +from types import SimpleNamespace import pytest @@ -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._OWNERSHIP.clear() + 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/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." } } From 223904410d8e63061b5e872da9f1eb4b441daefd Mon Sep 17 00:00:00 2001 From: hannesreinsch Date: Wed, 9 Sep 2026 19:34:11 +0200 Subject: [PATCH 2/3] refactor(speech): one copy of the speech core, and it is the same file zyx carries The extraction that produced this repo left ~450 lines living in two places: the wav on disk, what a sample measures, every threshold with its measurement, and the transcript rules. `voice-contract.json` pinned the MEASUREMENTS and did its job - no threshold drifted. Everything around them did, and the same two bugs had to be found twice. `murmurflow/speech.py` is that layer now, and it is byte-identical in zyx's `core/speech.py`. It reads NO configuration - the floors are arguments, because the two tools name their settings differently (`quietFloor` against `voiceQuietFloor`) and one line that has to differ is a file that is no longer shared. Enforced: a digest in `voice-core.sha256` that both repos carry, a test that fails the moment the file is edited, and `make voice-sync` (run from zyx) that copies it and rewrites both digests. WHAT IS NOT SHARED, and this suite caught it during the refactor: the whole-line hallucination blocklist. The shared table holds only what a PERSON NEVER SAYS - subtitle credits and audio markers, in every language whisper invents them in. "thank you", "you", "so", "bye" stay out of it and stay out of MurmurFlow: this tool types what a person says, so a swallowed sentence reads as broken hardware. zyx passes them in as its own extras, which is right there for the opposite reason. `deliberately_divergent.hallucination_list` is a parameter now, not a paragraph, and a new test fails if one of those words ever reaches the table. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J3aFkYVLa3FpjYmpiD4g5V --- murmurflow/dictate.py | 520 +++-------------------------------- murmurflow/speech.py | 520 +++++++++++++++++++++++++++++++++++ tests/test_voice_contract.py | 60 +++- voice-core.sha256 | 1 + 4 files changed, 625 insertions(+), 476 deletions(-) create mode 100644 murmurflow/speech.py create mode 100644 voice-core.sha256 diff --git a/murmurflow/dictate.py b/murmurflow/dictate.py index a1d6bc1..b139f99 100644 --- a/murmurflow/dictate.py +++ b/murmurflow/dictate.py @@ -45,11 +45,9 @@ from __future__ import annotations -import array import contextlib import difflib import json -import math import os import re import shutil @@ -67,7 +65,39 @@ 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, + audio_seconds, + data_offset, + is_hallucination, + join_segments, + peak_dbfs, + repair_punctuation, + repair_wav, + 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 @@ -416,99 +446,6 @@ def _exited(pid: int) -> bool: #: 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`. -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 a real recording it is the wrong one.** Measured 2026-09-09 with - :func:`start`'s own argv on macOS: ffmpeg writes a 26-byte ``LIST``/``INFO`` chunk (its own - encoder name) between ``fmt `` and ``data``, so the first sample is at byte **78**. Everything - that seeks past "the header" by a constant — :func:`trim_trailing_quiet`, :func:`tail_dbfs` — - was reading 34 bytes of that metadata as audio and cutting the clip 34 bytes early. Small - enough to have gone unnoticed, wrong on every clip. - - 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 — which is what - :func:`repair_wav` is for. - """ - 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.** 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). @@ -1035,46 +972,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 @@ -1090,166 +987,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: @@ -1282,18 +1022,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 @@ -2068,20 +1805,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. @@ -2120,9 +1843,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") @@ -2153,156 +1873,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) - head = data_offset(wav) # NOT 44: ffmpeg writes a LIST chunk in there too - try: - size = wav.stat().st_size - with wav.open("rb") as handle: - handle.seek(head) - 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 = 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 (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 + 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 - - @dataclass(frozen=True) class Result: """What one dictation produced, for the CLI and the huddle loop to report on.""" diff --git a/murmurflow/speech.py b/murmurflow/speech.py new file mode 100644 index 0000000..3942de6 --- /dev/null +++ b/murmurflow/speech.py @@ -0,0 +1,520 @@ +"""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 math +import re +import wave +from pathlib import Path + +# --- 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() diff --git a/tests/test_voice_contract.py b/tests/test_voice_contract.py index 96ae25e..ee63553 100644 --- a/tests/test_voice_contract.py +++ b/tests/test_voice_contract.py @@ -13,11 +13,12 @@ from __future__ import annotations +import hashlib import inspect import json from pathlib import Path -from murmurflow import dictate +from murmurflow import dictate, speech CONTRACT = json.loads( (Path(__file__).resolve().parents[1] / "voice-contract.json").read_text("utf-8") @@ -98,3 +99,60 @@ 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 ------------------------------------------------- + +_CORE = Path(__file__).resolve().parents[1] / "murmurflow" / "speech.py" +_DIGEST = Path(__file__).resolve().parents[1] / "voice-core.sha256" + + +def test_the_shared_speech_core_is_the_copy_both_tools_carry() -> 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 — because "the same code in both repos" was a habit, and + habits lose to three weeks and 24 commits. + + So the shared layer is ONE FILE (`murmurflow/speech.py`) and it is byte-identical in zyx. This + test cannot see zyx, and does not try to: it checks that the file has not been edited since the + two were last made equal. Editing it is fine and expected; editing it and leaving the other copy + behind is what this names. The ritual is `make voice-sync`, run from the zyx checkout. + """ + digest = hashlib.sha256(_CORE.read_bytes()).hexdigest() + assert digest == _DIGEST.read_text("utf-8").split()[0], ( + "murmurflow/speech.py changed. It is the SHARED speech core: 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. + """ + src = _CORE.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/speech.py: the shared core 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-core.sha256 b/voice-core.sha256 new file mode 100644 index 0000000..d55598a --- /dev/null +++ b/voice-core.sha256 @@ -0,0 +1 @@ +beae5ba401868b1e7b4f5fad4367050fb6bb7ebacc5f1ddef534b41d3bdb4ec9 speech.py From 17b9b2e07d56ef6ea822a6ee78db28647b0082f7 Mon Sep 17 00:00:00 2001 From: hannesreinsch Date: Wed, 9 Sep 2026 20:54:27 +0200 Subject: [PATCH 3/3] refactor(speech): one engine, two skins - the whole microphone-to-text path is shared Everything from the key to the transcript is one implementation now, byte-identical with zyx's copy of it: `speech.py` (the wav on disk, the recorder, the warm server, the transcribe path, the thresholds, the transcript rules, `resolve_bin`, `pick_input`) and `gesture.py` (the intent delay, the chord abort, the press-to-press pairing, the tap/hold line). 1,519 lines that were living twice. The seam is arguments and never configuration: `speech.Setup` for the engine, `capture=` for this platform's own ffmpeg input args, `held()`/`since_keydown()` for the key. So the shared files know nothing about dshow, avfoundation, `quietFloor` or `voiceQuietFloor` - which is exactly what lets them be the same bytes - and the platform package keeps doing the job it was extracted for. WHAT THE MOVE FOUND, in each direction: - `ours()` guarded adopting a server and not sending to one (fixed in the commit before this one, in both); - the samples start at byte 78, not 44 (same); - MurmurFlow's hold floor slept its REMAINDER and zyx's slept the whole floor again, so zyx's shortest holds took nearly twice as long to answer. This repo's own test pinned the better one and now pins it for both; - "Mikrofon" was matched here and not there. Deliberately still two: `bind_trigger` (the hint it returns is the product's own words), `transcribe`'s cold fallback, `Result`, `_cfg`. Enforcement: `voice-core.sha256` carries a digest per shared file, a parametrised test fails the moment either is edited alone, and a second test forbids configuration inside them. `make voice-sync` is run from the zyx checkout and writes both repos. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J3aFkYVLa3FpjYmpiD4g5V --- murmurflow/cli.py | 18 +- murmurflow/dictate.py | 655 ++++---------------------------- murmurflow/gesture.py | 293 +++++++++++++++ murmurflow/hotkey.py | 259 ++----------- murmurflow/speech.py | 706 +++++++++++++++++++++++++++++++++++ tests/test_murmurflow.py | 44 +-- tests/test_voice_contract.py | 67 ++-- voice-core.sha256 | 3 +- 8 files changed, 1188 insertions(+), 857 deletions(-) create mode 100644 murmurflow/gesture.py 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 b139f99..6beae58 100644 --- a/murmurflow/dictate.py +++ b/murmurflow/dictate.py @@ -51,7 +51,6 @@ import os import re import shutil -import signal import subprocess import sys import tempfile @@ -59,11 +58,9 @@ 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, speech, whisper @@ -88,13 +85,19 @@ 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, ) @@ -102,7 +105,6 @@ # 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. @@ -125,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. @@ -271,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 @@ -291,315 +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 - - #: 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`. -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) # --- 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. @@ -624,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} ) @@ -641,276 +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. +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()) - 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. +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`. - **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(): - 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: @@ -2420,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 index 3942de6..5ab9353 100644 --- a/murmurflow/speech.py +++ b/murmurflow/speech.py @@ -24,10 +24,23 @@ 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 ------------------------------------------------------------------------ @@ -518,3 +531,696 @@ def repair_punctuation( 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 4e11826..be781e6 100644 --- a/tests/test_murmurflow.py +++ b/tests/test_murmurflow.py @@ -28,7 +28,7 @@ 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) @@ -39,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 @@ -312,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 @@ -321,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()) @@ -448,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]) @@ -728,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 ------------------------------------------------------------------------ @@ -956,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" @@ -1238,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" @@ -1633,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): @@ -1683,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): @@ -2318,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" @@ -2375,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( @@ -2394,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"] @@ -2402,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 @@ -2628,7 +2628,7 @@ def test_recorded_audio_is_never_sent_to_a_port_a_whisper_server_does_not_hold( """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._OWNERSHIP.clear() + dictate.forget_ownership() monkeypatch.setattr( dictate.subprocess, "run", lambda *_a, **_k: SimpleNamespace(stdout="", returncode=1) ) diff --git a/tests/test_voice_contract.py b/tests/test_voice_contract.py index ee63553..24af486 100644 --- a/tests/test_voice_contract.py +++ b/tests/test_voice_contract.py @@ -18,6 +18,8 @@ import json from pathlib import Path +import pytest + from murmurflow import dictate, speech CONTRACT = json.loads( @@ -41,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 @@ -58,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']}" @@ -103,28 +105,42 @@ def test_boilerplate_appended_to_a_real_sentence_never_survives_tidy() -> None: # --- the core itself, not just the measurements ------------------------------------------------- -_CORE = Path(__file__).resolve().parents[1] / "murmurflow" / "speech.py" -_DIGEST = Path(__file__).resolve().parents[1] / "voice-core.sha256" +#: 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") -def test_the_shared_speech_core_is_the_copy_both_tools_carry() -> None: + +@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 — because "the same code in both repos" was a habit, and - habits lose to three weeks and 24 commits. - - So the shared layer is ONE FILE (`murmurflow/speech.py`) and it is byte-identical in zyx. This - test cannot see zyx, and does not try to: it checks that the file has not been edited since the - two were last made equal. Editing it is fine and expected; editing it and leaving the other copy - behind is what this names. The ritual is `make voice-sync`, run from the zyx checkout. + 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.read_bytes()).hexdigest() - assert digest == _DIGEST.read_text("utf-8").split()[0], ( - "murmurflow/speech.py changed. It is the SHARED speech core: 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." + 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." ) @@ -135,12 +151,13 @@ def test_nothing_in_the_shared_core_asks_a_question_about_this_install() -> None 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. """ - src = _CORE.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/speech.py: the shared core reads no configuration. " - "Take the value as an argument and let each tool answer for its own install." - ) + 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: diff --git a/voice-core.sha256 b/voice-core.sha256 index d55598a..567bed7 100644 --- a/voice-core.sha256 +++ b/voice-core.sha256 @@ -1 +1,2 @@ -beae5ba401868b1e7b4f5fad4367050fb6bb7ebacc5f1ddef534b41d3bdb4ec9 speech.py +a9b2a21eddb01613e1deb13c4e1d7b3f7f986264dd7a8b616fbb791a1721738f speech.py +7482506a31462a63f015e24808ef05651ee40583b4c9768d5d6312a90be10abf gesture.py