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(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)"
]
}
}

219
main.py
View File

@ -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()

View File

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

View File

@ -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")

View File

@ -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}")