feat: add automatic stop after a configurable amount of silence.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user