feat: add automatic stop after a configurable amount of silence.

This commit is contained in:
syntaxbullet
2026-02-18 16:12:54 +01:00
parent 85230a14a8
commit 134200d345
2 changed files with 56 additions and 1 deletions

View File

@@ -6,6 +6,8 @@ import threading
import time import time
from typing import Any from typing import Any
import numpy as np
import subprocess import subprocess
import rumps import rumps
@@ -54,6 +56,12 @@ class CalliopeApp(rumps.App):
self._transcribe_done = threading.Event() self._transcribe_done = threading.Event()
self._transcribe_done.set() # not transcribing initially 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 = rumps.MenuItem("Status: Loading model...")
self.status_item.set_callback(None) self.status_item.set_callback(None)
self.toggle_item = rumps.MenuItem("Start Recording", callback=self._on_toggle_click) 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._pp_menu = rumps.MenuItem("Post-Processing")
self._build_pp_menu() 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 # Typing mode submenu
self._typing_menu = rumps.MenuItem("Typing Mode") self._typing_menu = rumps.MenuItem("Typing Mode")
current_mode = cfg.get("typing_mode", "char") current_mode = cfg.get("typing_mode", "char")
@@ -101,6 +116,7 @@ class CalliopeApp(rumps.App):
self.status_item, self.status_item,
None, None,
self.toggle_item, self.toggle_item,
self._auto_stop_item,
self.context_item, self.context_item,
self._lang_menu, self._lang_menu,
self._model_menu, self._model_menu,
@@ -302,7 +318,10 @@ class CalliopeApp(rumps.App):
self.title = "\U0001f534 0:00" # 🔴 self.title = "\U0001f534 0:00" # 🔴
self.toggle_item.title = "Stop Recording" self.toggle_item.title = "Stop Recording"
self.status_item.title = "Status: 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: try:
self.recorder.start() self.recorder.start()
except Exception: except Exception:
@@ -317,6 +336,8 @@ class CalliopeApp(rumps.App):
self.overlay.show() self.overlay.show()
self._rec_timer = rumps.Timer(self._update_rec_duration, 1) self._rec_timer = rumps.Timer(self._update_rec_duration, 1)
self._rec_timer.start() 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") self._notify("Calliope", "", "Recording started")
log.info("Recording started") log.info("Recording started")
@@ -325,6 +346,7 @@ class CalliopeApp(rumps.App):
if not self._recording: if not self._recording:
return return
self._recording = False self._recording = False
self._silence_stop_evt.set()
if self._rec_timer: if self._rec_timer:
self._rec_timer.stop() self._rec_timer.stop()
self._rec_timer = None self._rec_timer = None
@@ -341,6 +363,37 @@ class CalliopeApp(rumps.App):
self._transcribe_done.clear() self._transcribe_done.clear()
threading.Thread(target=self._transcribe_and_type, args=(audio,), daemon=True).start() 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: def _update_rec_duration(self, timer) -> None:
if self._rec_start_time is None: if self._rec_start_time is None:
return return

View File

@@ -26,6 +26,8 @@ DEFAULTS: dict[str, Any] = {
"silence_threshold": 0.005, # RMS energy below which audio is considered silence "silence_threshold": 0.005, # RMS energy below which audio is considered silence
"notifications": True, # show macOS notifications "notifications": True, # show macOS notifications
"typing_delay": 0.005, # seconds between keystrokes in char mode "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": { "postprocessing": {
"enabled": False, "enabled": False,
"model": None, # active model HF repo id "model": None, # active model HF repo id