upd
This commit is contained in:
parent
7b711f6ca2
commit
a86f44c342
|
|
@ -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
19
main.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
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"]
|
sr = config.config["sample_rate"]
|
||||||
try:
|
try:
|
||||||
return sd.InputStream(
|
self._stream = sd.InputStream(
|
||||||
samplerate=sr, channels=1, device=device, callback=audio_callback,
|
samplerate=sr, channels=1, device=device,
|
||||||
|
callback=audio_callback,
|
||||||
)
|
)
|
||||||
|
self._stream.start()
|
||||||
except sd.PortAudioError:
|
except sd.PortAudioError:
|
||||||
log.warning("Audio device %s failed, falling back to default", device)
|
log.warning("Audio device %s failed, falling back to default",
|
||||||
return sd.InputStream(
|
device_name)
|
||||||
samplerate=sr, channels=1, device=None, callback=audio_callback,
|
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,
|
_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}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue