This commit is contained in:
Christian Kauer 2026-03-26 20:33:18 +01:00
parent 7b711f6ca2
commit a86f44c342
5 changed files with 245 additions and 126 deletions

View File

@ -100,7 +100,8 @@
"Bash(pip3 install:*)", "Bash(pip3 install:*)",
"Bash(python3 -m pip install Pillow)", "Bash(python3 -m pip install Pillow)",
"Bash(python3 -m pip install pyinstaller)", "Bash(python3 -m pip install pyinstaller)",
"Bash(python3 build.py)" "Bash(python3 build.py)",
"Bash(source .venv-linux/bin/activate)"
] ]
} }
} }

19
main.py
View File

@ -49,9 +49,9 @@ def main():
# Load model in background so the tray appears immediately # Load model in background so the tray appears immediately
threading.Thread(target=transcriber.load_model, daemon=True).start() threading.Thread(target=transcriber.load_model, daemon=True).start()
# Audio stream # Audio stream with device hotplug monitoring
stream = audio.get_audio_stream() app.audio_manager = audio.AudioManager()
stream.start() app.audio_manager.start()
# Hotkey # Hotkey
def _on_release(): def _on_release():
@ -68,13 +68,13 @@ def main():
on_settings=lambda: settings_window.open(root, _reload), on_settings=lambda: settings_window.open(root, _reload),
on_vocab=lambda: vocab_window.open(root), on_vocab=lambda: vocab_window.open(root),
on_show_log=lambda: root.after(0, log_window.show), on_show_log=lambda: root.after(0, log_window.show),
on_quit=lambda: _quit(stream, icon), on_quit=lambda: _quit(icon),
) )
threading.Thread(target=icon.run, daemon=True).start() threading.Thread(target=icon.run, daemon=True).start()
app.log(f"Bereit. Hotkey: {config.config['hotkey']}") app.log(f"Bereit. Hotkey: {config.config['hotkey']}")
root.mainloop() root.mainloop()
stream.stop() app.audio_manager.stop()
def _start_recording(): def _start_recording():
from whisper_app import app, transcriber from whisper_app import app, transcriber
@ -87,6 +87,8 @@ def _reload():
if app.hotkey_listener: if app.hotkey_listener:
app.hotkey_listener.stop() app.hotkey_listener.stop()
threading.Thread(target=transcriber.load_model, daemon=True).start() threading.Thread(target=transcriber.load_model, daemon=True).start()
if app.audio_manager:
app.audio_manager.restart()
app.hotkey_listener = hotkey.HotkeyListener( app.hotkey_listener = hotkey.HotkeyListener(
config.config["hotkey"], config.config["hotkey"],
on_press=_start_recording, on_press=_start_recording,
@ -95,10 +97,11 @@ def _reload():
) )
app.log(f"Hotkey aktualisiert: {config.config['hotkey']}") app.log(f"Hotkey aktualisiert: {config.config['hotkey']}")
def _quit(stream, icon): def _quit(icon):
stream.stop()
icon.stop()
from whisper_app import app from whisper_app import app
if app.audio_manager:
app.audio_manager.stop()
icon.stop()
if app.overlay_tk: if app.overlay_tk:
app.overlay_tk.after(0, app.overlay_tk.quit) app.overlay_tk.after(0, app.overlay_tk.quit)

View File

@ -41,3 +41,4 @@ tray_icon = None
overlay_window = None overlay_window = None
overlay_tk = None overlay_tk = None
hotkey_listener = None hotkey_listener = None
audio_manager = None

View File

@ -1,5 +1,7 @@
import logging import logging
import re
import threading import threading
import time
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@ -8,6 +10,8 @@ from whisper_app import app, config
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_HWDEV_RE = re.compile(r"\(hw:(\d+),(\d+)\)")
def audio_callback(indata, frames, time_info, status): def audio_callback(indata, frames, time_info, status):
if app.state == app.AppState.RECORDING: 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]]: 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"] 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 if d["max_input_channels"] > 0
and sd.query_hostapis(d["hostapi"])["name"] == default_api] 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, def test_device(device_name: str | None, duration: float,
on_level: callable, on_done: callable) -> None: 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() threading.Thread(target=_run, daemon=True).start()
def get_audio_stream(): class AudioManager:
device = resolve_device(config.config.get("audio_device")) """Manages audio stream with automatic device hotplug handling.
sr = config.config["sample_rate"]
try: Polls the device list every few seconds. When devices change (USB
return sd.InputStream( plug/unplug, docking station) the stream is silently restarted so
samplerate=sr, channels=1, device=device, callback=audio_callback, the configured (or system-default) device is used.
) """
except sd.PortAudioError:
log.warning("Audio device %s failed, falling back to default", device) _POLL_INTERVAL = 3 # seconds
return sd.InputStream(
samplerate=sr, channels=1, device=None, callback=audio_callback, 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")

View File

@ -332,12 +332,12 @@ def _open_main(root: tk.Tk, on_reload) -> None:
_add_installation_section(win, content, section, row, _add_installation_section(win, content, section, row,
BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B) 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() win.update_idletasks()
sw = win.winfo_screenwidth() sw = win.winfo_screenwidth()
sh = win.winfo_screenheight() sh = win.winfo_screenheight()
w = max(win.winfo_reqwidth(), 700) w = max(win.winfo_reqwidth(), 820)
h = min(win.winfo_reqheight(), sh - 100) h = min(max(win.winfo_reqheight(), 700), sh - 100)
win.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") win.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")