From a86f44c3422d83c40a6bd37f442c6785cb554e8d Mon Sep 17 00:00:00 2001 From: Christian Kauer Date: Thu, 26 Mar 2026 20:33:18 +0100 Subject: [PATCH] upd --- .claude/settings.local.json | 3 +- main.py | 219 +++++++++++++++++---------------- whisper_app/app.py | 1 + whisper_app/audio.py | 142 ++++++++++++++++++--- whisper_app/settings_window.py | 6 +- 5 files changed, 245 insertions(+), 126 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 36c5ab1..7c5f7f2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -100,7 +100,8 @@ "Bash(pip3 install:*)", "Bash(python3 -m pip install Pillow)", "Bash(python3 -m pip install pyinstaller)", - "Bash(python3 build.py)" + "Bash(python3 build.py)", + "Bash(source .venv-linux/bin/activate)" ] } } diff --git a/main.py b/main.py index 1d991e9..cfeea0c 100644 --- a/main.py +++ b/main.py @@ -1,108 +1,111 @@ -# main.py -import os -import queue -import sys -import threading - -def _setup_error_log(): - """Last-resort error log for crashes before tray appears (frozen mode).""" - if not getattr(sys, "frozen", False): - return - import traceback - if os.name == "nt": - log_dir = os.path.join(os.environ.get("LOCALAPPDATA", ""), "WhisperDictation") - else: - log_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "WhisperDictation") - os.makedirs(log_dir, exist_ok=True) - log_path = os.path.join(log_dir, "error.log") - sys.excepthook = lambda *args: open(log_path, "a").write( - "".join(traceback.format_exception(*args)) + "\n" - ) - -def main(): - _setup_error_log() - - from whisper_app import app, config, audio, transcriber, hotkey - from whisper_app import overlay, tray, log_window, settings_window, vocab_window - import tkinter as tk - - config.load_config() - config.load_vocab() - - # Tkinter root (hidden) - root = tk.Tk() - root.withdraw() - app.overlay_tk = root - - # Log queue — connect before model load so early messages appear - log_q: queue.Queue = queue.Queue() - app.set_log_queue(log_q) - - # Windows - overlay.create(root) - log_win = log_window.create( - root, log_q, - on_settings=lambda: settings_window.open(root, _reload), - on_vocab=lambda: vocab_window.open(root), - ) - - # Load model in background so the tray appears immediately - threading.Thread(target=transcriber.load_model, daemon=True).start() - - # Audio stream - stream = audio.get_audio_stream() - stream.start() - - # Hotkey - def _on_release(): - threading.Thread(target=transcriber.stop_and_transcribe, daemon=True).start() - - app.hotkey_listener = hotkey.HotkeyListener( - config.config["hotkey"], - on_press=_start_recording, - on_release=_on_release, - ) - - # Tray - icon = tray.create( - on_settings=lambda: settings_window.open(root, _reload), - on_vocab=lambda: vocab_window.open(root), - on_show_log=lambda: root.after(0, log_window.show), - on_quit=lambda: _quit(stream, icon), - ) - threading.Thread(target=icon.run, daemon=True).start() - - app.log(f"Bereit. Hotkey: {config.config['hotkey']}") - root.mainloop() - stream.stop() - -def _start_recording(): - from whisper_app import app, transcriber - app.audio_chunks = [] - transcriber.set_state(app.AppState.RECORDING) - app.log("Recording...") - -def _reload(): - from whisper_app import app, config, transcriber, hotkey - if app.hotkey_listener: - app.hotkey_listener.stop() - threading.Thread(target=transcriber.load_model, daemon=True).start() - app.hotkey_listener = hotkey.HotkeyListener( - config.config["hotkey"], - on_press=_start_recording, - on_release=lambda: threading.Thread( - target=transcriber.stop_and_transcribe, daemon=True).start(), - ) - app.log(f"Hotkey aktualisiert: {config.config['hotkey']}") - -def _quit(stream, icon): - stream.stop() - icon.stop() - from whisper_app import app - if app.overlay_tk: - app.overlay_tk.after(0, app.overlay_tk.quit) - -if __name__ == "__main__": - import multiprocessing - multiprocessing.freeze_support() - main() +# main.py +import os +import queue +import sys +import threading + +def _setup_error_log(): + """Last-resort error log for crashes before tray appears (frozen mode).""" + if not getattr(sys, "frozen", False): + return + import traceback + if os.name == "nt": + log_dir = os.path.join(os.environ.get("LOCALAPPDATA", ""), "WhisperDictation") + else: + log_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "WhisperDictation") + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, "error.log") + sys.excepthook = lambda *args: open(log_path, "a").write( + "".join(traceback.format_exception(*args)) + "\n" + ) + +def main(): + _setup_error_log() + + from whisper_app import app, config, audio, transcriber, hotkey + from whisper_app import overlay, tray, log_window, settings_window, vocab_window + import tkinter as tk + + config.load_config() + config.load_vocab() + + # Tkinter root (hidden) + root = tk.Tk() + root.withdraw() + app.overlay_tk = root + + # Log queue — connect before model load so early messages appear + log_q: queue.Queue = queue.Queue() + app.set_log_queue(log_q) + + # Windows + overlay.create(root) + log_win = log_window.create( + root, log_q, + on_settings=lambda: settings_window.open(root, _reload), + on_vocab=lambda: vocab_window.open(root), + ) + + # Load model in background so the tray appears immediately + threading.Thread(target=transcriber.load_model, daemon=True).start() + + # Audio stream with device hotplug monitoring + app.audio_manager = audio.AudioManager() + app.audio_manager.start() + + # Hotkey + def _on_release(): + threading.Thread(target=transcriber.stop_and_transcribe, daemon=True).start() + + app.hotkey_listener = hotkey.HotkeyListener( + config.config["hotkey"], + on_press=_start_recording, + on_release=_on_release, + ) + + # Tray + icon = tray.create( + on_settings=lambda: settings_window.open(root, _reload), + on_vocab=lambda: vocab_window.open(root), + on_show_log=lambda: root.after(0, log_window.show), + on_quit=lambda: _quit(icon), + ) + threading.Thread(target=icon.run, daemon=True).start() + + app.log(f"Bereit. Hotkey: {config.config['hotkey']}") + root.mainloop() + app.audio_manager.stop() + +def _start_recording(): + from whisper_app import app, transcriber + app.audio_chunks = [] + transcriber.set_state(app.AppState.RECORDING) + app.log("Recording...") + +def _reload(): + from whisper_app import app, config, transcriber, hotkey + if app.hotkey_listener: + app.hotkey_listener.stop() + threading.Thread(target=transcriber.load_model, daemon=True).start() + if app.audio_manager: + app.audio_manager.restart() + app.hotkey_listener = hotkey.HotkeyListener( + config.config["hotkey"], + on_press=_start_recording, + on_release=lambda: threading.Thread( + target=transcriber.stop_and_transcribe, daemon=True).start(), + ) + app.log(f"Hotkey aktualisiert: {config.config['hotkey']}") + +def _quit(icon): + from whisper_app import app + if app.audio_manager: + app.audio_manager.stop() + icon.stop() + if app.overlay_tk: + app.overlay_tk.after(0, app.overlay_tk.quit) + +if __name__ == "__main__": + import multiprocessing + multiprocessing.freeze_support() + main() diff --git a/whisper_app/app.py b/whisper_app/app.py index 6089280..4f9e9b0 100644 --- a/whisper_app/app.py +++ b/whisper_app/app.py @@ -41,3 +41,4 @@ tray_icon = None overlay_window = None overlay_tk = None hotkey_listener = None +audio_manager = None diff --git a/whisper_app/audio.py b/whisper_app/audio.py index 9d002df..0d2f617 100644 --- a/whisper_app/audio.py +++ b/whisper_app/audio.py @@ -1,5 +1,7 @@ import logging +import re import threading +import time import numpy as np import sounddevice as sd @@ -8,6 +10,8 @@ from whisper_app import app, config log = logging.getLogger(__name__) +_HWDEV_RE = re.compile(r"\(hw:(\d+),(\d+)\)") + def audio_callback(indata, frames, time_info, status): if app.state == app.AppState.RECORDING: @@ -26,12 +30,32 @@ def resolve_device(name: str | None) -> int | None: def get_input_devices() -> list[tuple[int, str]]: - """Return list of (index, name) for input devices on the default host API.""" + """Return list of (index, name) for real input devices on the default host API. + + On ALSA (Linux): filters out virtual/plugin devices (pulse, pipewire, jack, + spdif, etc.) and deduplicates entries with identical names. + """ default_api = sd.query_hostapis(sd.default.hostapi)["name"] - return [(i, d["name"]) for i, d in enumerate(sd.query_devices()) + devs = [(i, d["name"]) for i, d in enumerate(sd.query_devices()) if d["max_input_channels"] > 0 and sd.query_hostapis(d["hostapi"])["name"] == default_api] + # Detect ALSA: real hardware devices contain "(hw:X,Y)" + has_hw = any(_HWDEV_RE.search(name) for _, name in devs) + if not has_hw: + return devs + + seen_names: set[str] = set() + result = [] + for i, name in devs: + if not _HWDEV_RE.search(name): + continue # virtual/plugin device + if name in seen_names: + continue # exact duplicate + seen_names.add(name) + result.append((i, name)) + return result + def test_device(device_name: str | None, duration: float, on_level: callable, on_done: callable) -> None: @@ -61,15 +85,105 @@ def test_device(device_name: str | None, duration: float, threading.Thread(target=_run, daemon=True).start() -def get_audio_stream(): - device = resolve_device(config.config.get("audio_device")) - sr = config.config["sample_rate"] - try: - return sd.InputStream( - samplerate=sr, channels=1, device=device, callback=audio_callback, - ) - except sd.PortAudioError: - log.warning("Audio device %s failed, falling back to default", device) - return sd.InputStream( - samplerate=sr, channels=1, device=None, callback=audio_callback, - ) +class AudioManager: + """Manages audio stream with automatic device hotplug handling. + + Polls the device list every few seconds. When devices change (USB + plug/unplug, docking station) the stream is silently restarted so + the configured (or system-default) device is used. + """ + + _POLL_INTERVAL = 3 # seconds + + def __init__(self): + self._stream: sd.InputStream | None = None + self._lock = threading.Lock() + self._running = False + self._monitor_thread: threading.Thread | None = None + self._last_devices: set[str] = set() + + # -- public API -- + + def start(self): + """Open audio stream and begin device monitoring.""" + self._open_stream() + self._running = True + self._monitor_thread = threading.Thread( + target=self._monitor_loop, daemon=True) + self._monitor_thread.start() + + def stop(self): + """Stop monitoring and close stream.""" + self._running = False + with self._lock: + if self._stream: + try: + self._stream.stop() + self._stream.close() + except Exception: + pass + self._stream = None + + def restart(self): + """Restart with (possibly changed) config. Called after settings save.""" + with self._lock: + self._close_stream_locked() + self._open_stream_locked() + + # -- internals -- + + def _open_stream(self): + with self._lock: + self._open_stream_locked() + + def _open_stream_locked(self): + device_name = config.config.get("audio_device") + device = resolve_device(device_name) + sr = config.config["sample_rate"] + try: + self._stream = sd.InputStream( + samplerate=sr, channels=1, device=device, + callback=audio_callback, + ) + self._stream.start() + except sd.PortAudioError: + log.warning("Audio device %s failed, falling back to default", + device_name) + self._stream = sd.InputStream( + samplerate=sr, channels=1, device=None, + callback=audio_callback, + ) + self._stream.start() + self._last_devices = self._device_snapshot() + + def _close_stream_locked(self): + if self._stream: + try: + self._stream.stop() + self._stream.close() + except Exception: + pass + self._stream = None + + @staticmethod + def _device_snapshot() -> set[str]: + try: + return {d["name"] for d in sd.query_devices() + if d["max_input_channels"] > 0} + except Exception: + return set() + + def _monitor_loop(self): + while self._running: + time.sleep(self._POLL_INTERVAL) + if not self._running: + break + if app.state == app.AppState.RECORDING: + continue # never disrupt an active recording + current = self._device_snapshot() + if current != self._last_devices: + log.info("Audio devices changed, restarting stream") + with self._lock: + self._close_stream_locked() + self._open_stream_locked() + app.log("Audio-Gerät neu verbunden") diff --git a/whisper_app/settings_window.py b/whisper_app/settings_window.py index 65e2b99..00767b0 100644 --- a/whisper_app/settings_window.py +++ b/whisper_app/settings_window.py @@ -332,12 +332,12 @@ def _open_main(root: tk.Tk, on_reload) -> None: _add_installation_section(win, content, section, row, BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B) - # Center on screen after layout + # Center on screen with sensible default size win.update_idletasks() sw = win.winfo_screenwidth() sh = win.winfo_screenheight() - w = max(win.winfo_reqwidth(), 700) - h = min(win.winfo_reqheight(), sh - 100) + w = max(win.winfo_reqwidth(), 820) + h = min(max(win.winfo_reqheight(), 700), sh - 100) win.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")