From 0c67b805c9cb365431f10264e762cf9a3f3bb846 Mon Sep 17 00:00:00 2001 From: hannesreinsch Date: Wed, 9 Sep 2026 16:39:15 +0200 Subject: [PATCH] fix(hotkey): a microphone that closed itself does not cost the next double-tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "When the microphone closes automatically, the double press control doesn't reset." `recording` was a local flag in `listen_double_tap` and the only record of whether anything was running — true when nothing else could end a clip. The microphone can now close itself, after fifteen seconds of silence or the two-minute cap, so the clip was gone while the loop still believed it was running: the next tap was spent being a STOP for something already stopped, and the double-tap only worked on the try after that. The loop asks instead. `is_recording` is an optional callback polled once per poll — `lambda: bool(mine)` in the daemon, a list lookup and never work — and a clip that ended without a tap resets the gesture on the spot (`saw("ended")`, so the log says which). Given none, the loop behaves exactly as it always did, and a test pins that half too. It also fixes a case that was always wrong: an `on_start` that could not open the microphone left `recording` true, so the next tap was spent stopping a clip that never existed. `_tap_the_key` drives the real loop over a scripted key sequence with its clock and its key reader replaced, so a double-tap takes microseconds and no real key is touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P7oJz8M9QzsdJimoM318cj --- murmurflow/dictate.py | 5 +++ murmurflow/hotkey.py | 13 ++++++ tests/test_murmurflow.py | 93 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/murmurflow/dictate.py b/murmurflow/dictate.py index 3774b61..649e74d 100644 --- a/murmurflow/dictate.py +++ b/murmurflow/dictate.py @@ -2662,6 +2662,7 @@ def bind_trigger( on_abort: object | None = None, should_stop: object | None = None, on_tap: object | None = None, + is_recording: object | None = None, ) -> str: """Run the key listener in whichever mode ``doubleTap`` selects. Blocks. Returns a description. @@ -2678,6 +2679,7 @@ def bind_trigger( trigger=key, should_stop=should_stop if callable(should_stop) else None, on_tap=on_tap if callable(on_tap) else None, + is_recording=is_recording if callable(is_recording) else None, ) return f"double-tap {key} to start, tap once to stop" hotkey.listen( @@ -3119,4 +3121,7 @@ def on_abort() -> None: on_abort=on_abort, should_stop=should_stop, on_tap=on_tap, + # The microphone can close itself now, so the gesture has to be able to find that out + # without being told by a tap — see :func:`hotkey.listen_double_tap`. + is_recording=lambda: bool(mine), ) diff --git a/murmurflow/hotkey.py b/murmurflow/hotkey.py index 1f206c0..cb5fd7a 100644 --- a/murmurflow/hotkey.py +++ b/murmurflow/hotkey.py @@ -303,6 +303,7 @@ def listen_double_tap( tap_max: float = TAP_MAX, poll_hz: int = POLL_HZ, 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. @@ -320,6 +321,14 @@ def listen_double_tap( 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 @@ -342,6 +351,10 @@ def saw(what: str) -> None: 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: diff --git a/tests/test_murmurflow.py b/tests/test_murmurflow.py index 0064985..1b93d1c 100644 --- a/tests/test_murmurflow.py +++ b/tests/test_murmurflow.py @@ -1613,6 +1613,99 @@ def test_the_suite_can_never_type_on_the_real_keyboard(): assert platforms.type_text("anything at all") == "" +def _tap_the_key(monkeypatch, script, *, is_recording=None): + """Drive the real `listen_double_tap` loop over a scripted key sequence. + + ``script`` is a list of ``(held, seconds_since_the_last_step)``; the loop's clock and its key + reader are both replaced, so a double-tap takes microseconds and no real key is touched. + """ + from murmurflow import hotkey + + clock = [0.0] + steps = list(script) + seen: list[str] = [] + + def _down(_trigger): + if not steps: + raise SystemExit + held, gap = steps.pop(0) + clock[0] += gap + return held + + monkeypatch.setattr(hotkey.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(hotkey.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): + hotkey.listen_double_tap( + lambda: seen.append("START"), + lambda: seen.append("STOP"), + on_tap=seen.append, + is_recording=is_recording, + ) + return seen + + +def test_a_microphone_that_closed_itself_does_not_cost_the_next_double_tap(monkeypatch): + """Reported as "when the microphone closes automatically, the double press control doesn't reset". + + `recording` was the loop's only record of whether anything was running, and the microphone can + now close itself — after fifteen seconds of silence, or the two-minute cap. The clip was gone + and the loop 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. + """ + tap = [(True, 0.01), (False, 0.01)] + live = [False] # what `mine` would say: nothing is recording yet + + def is_recording(): + return live[0] + + # Two taps start it. Then the clip "closes itself", and two more taps must START again — + # not be spent as a stop for something that is already over. + def _started(): + seen.append("START") + live[0] = True + + from murmurflow import hotkey + + clock = [0.0] + steps = [*tap, *tap, *tap, *tap] + seen: list[str] = [] + + def _down(_trigger): + if not steps: + raise SystemExit + held, gap = steps.pop(0) + clock[0] += gap + # Between the two pairs, with the clip already started: the watchdog finishes it. + if "START" in seen and len(steps) == 3: + live[0] = False + return held + + monkeypatch.setattr(hotkey.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(hotkey.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): + hotkey.listen_double_tap( + _started, + lambda: seen.append("STOP"), + on_tap=seen.append, + is_recording=is_recording, + ) + assert seen.count("START") == 2, seen # both double-taps started a clip + assert "STOP" not in seen # and no tap was spent stopping one that had already ended + assert "ended" in seen # the loop noticed, without being told by a tap + + +def test_without_the_callback_the_gesture_is_exactly_what_it_was(monkeypatch): + """`is_recording` is optional, and a loop given none behaves as it always did: tap, tap, start.""" + tap = [(True, 0.01), (False, 0.01)] + seen = _tap_the_key(monkeypatch, [*tap, *tap, *tap]) + assert seen.count("START") == 1 + assert seen.count("STOP") == 1 # the third tap stops it, because nothing else can + + # --- streaming ---------------------------------------------------------------------------------