upd
This commit is contained in:
parent
7b711f6ca2
commit
a86f44c342
|
|
@ -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
219
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()
|
||||
|
|
|
|||
|
|
@ -41,3 +41,4 @@ tray_icon = None
|
|||
overlay_window = None
|
||||
overlay_tk = None
|
||||
hotkey_listener = None
|
||||
audio_manager = None
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue