From 134200d345a3034b28ac0547e1c68ba95121ed40 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 18 Feb 2026 16:12:54 +0100 Subject: [PATCH] feat: add automatic stop after a configurable amount of silence. --- calliope/app.py | 55 +++++++++++++++++++++++++++++++++++++++++++++- calliope/config.py | 2 ++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/calliope/app.py b/calliope/app.py index 39ee036..3670102 100644 --- a/calliope/app.py +++ b/calliope/app.py @@ -6,6 +6,8 @@ import threading import time from typing import Any +import numpy as np + import subprocess import rumps @@ -54,6 +56,12 @@ class CalliopeApp(rumps.App): self._transcribe_done = threading.Event() self._transcribe_done.set() # not transcribing initially + # Silence-based auto-stop + self._silence_since: float | None = None + self._rec_has_speech: bool = False + self._silence_stop_evt: threading.Event = threading.Event() + self._silence_stop_evt.set() # not monitoring initially + self.status_item = rumps.MenuItem("Status: Loading model...") self.status_item.set_callback(None) self.toggle_item = rumps.MenuItem("Start Recording", callback=self._on_toggle_click) @@ -86,6 +94,13 @@ class CalliopeApp(rumps.App): self._pp_menu = rumps.MenuItem("Post-Processing") self._build_pp_menu() + # Auto-stop on silence toggle + auto_stop = cfg.get("auto_stop_silence", True) + prefix = "\u2713 " if auto_stop else " " + self._auto_stop_item = rumps.MenuItem( + f"{prefix}Auto-stop on Silence", callback=self._on_auto_stop_toggle + ) + # Typing mode submenu self._typing_menu = rumps.MenuItem("Typing Mode") current_mode = cfg.get("typing_mode", "char") @@ -101,6 +116,7 @@ class CalliopeApp(rumps.App): self.status_item, None, self.toggle_item, + self._auto_stop_item, self.context_item, self._lang_menu, self._model_menu, @@ -302,7 +318,10 @@ class CalliopeApp(rumps.App): self.title = "\U0001f534 0:00" # 🔴 self.toggle_item.title = "Stop Recording" self.status_item.title = "Status: Recording..." - self.recorder.on_audio = self.overlay.push_samples + self._silence_since = None + self._rec_has_speech = False + self._silence_stop_evt = threading.Event() + self.recorder.on_audio = self._on_audio_chunk try: self.recorder.start() except Exception: @@ -317,6 +336,8 @@ class CalliopeApp(rumps.App): self.overlay.show() self._rec_timer = rumps.Timer(self._update_rec_duration, 1) self._rec_timer.start() + if self.cfg.get("auto_stop_silence", True): + threading.Thread(target=self._silence_monitor, daemon=True).start() self._notify("Calliope", "", "Recording started") log.info("Recording started") @@ -325,6 +346,7 @@ class CalliopeApp(rumps.App): if not self._recording: return self._recording = False + self._silence_stop_evt.set() if self._rec_timer: self._rec_timer.stop() self._rec_timer = None @@ -341,6 +363,37 @@ class CalliopeApp(rumps.App): self._transcribe_done.clear() threading.Thread(target=self._transcribe_and_type, args=(audio,), daemon=True).start() + def _on_audio_chunk(self, chunk: np.ndarray) -> None: + """Called from the audio thread on every recorder chunk.""" + self.overlay.push_samples(chunk) + rms = float(np.sqrt(np.mean(chunk ** 2))) + threshold = self.cfg.get("silence_threshold", 0.005) + if rms >= threshold: + self._rec_has_speech = True + self._silence_since = None + elif self._rec_has_speech and self._silence_since is None: + self._silence_since = time.monotonic() + + def _silence_monitor(self) -> None: + """Background thread: trigger auto-stop after sustained silence.""" + timeout = self.cfg.get("silence_timeout_seconds", 1.5) + stop_evt = self._silence_stop_evt + while not stop_evt.is_set(): + since = self._silence_since + if since is not None and (time.monotonic() - since) >= timeout: + log.info("Auto-stop: %.1fs of silence detected", timeout) + self._stop_and_transcribe() + break + stop_evt.wait(0.1) + + def _on_auto_stop_toggle(self, sender) -> None: + enabled = not self.cfg.get("auto_stop_silence", True) + self.cfg["auto_stop_silence"] = enabled + config_mod.save(self.cfg) + prefix = "\u2713 " if enabled else " " + self._auto_stop_item.title = f"{prefix}Auto-stop on Silence" + log.info("Auto-stop on silence %s", "enabled" if enabled else "disabled") + def _update_rec_duration(self, timer) -> None: if self._rec_start_time is None: return diff --git a/calliope/config.py b/calliope/config.py index b5ca10c..e1d7844 100644 --- a/calliope/config.py +++ b/calliope/config.py @@ -26,6 +26,8 @@ DEFAULTS: dict[str, Any] = { "silence_threshold": 0.005, # RMS energy below which audio is considered silence "notifications": True, # show macOS notifications "typing_delay": 0.005, # seconds between keystrokes in char mode + "auto_stop_silence": True, # stop recording automatically after sustained silence + "silence_timeout_seconds": 1.5, # seconds of silence before auto-stop "postprocessing": { "enabled": False, "model": None, # active model HF repo id