1466 lines
44 KiB
Markdown
1466 lines
44 KiB
Markdown
|
|
# Tray-Icon mit Einstellungs-Dialog — Implementierungsplan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** whisper-local erhält ein Windows-Tray-Icon mit 3 Zustandsfarben (Warten/Aufnahme/Transkription), Rechtsklick-Menü (Einstellungen/Beenden) und einem Einstellungs-Dialog für Hotkey und Mikrofon.
|
|||
|
|
|
|||
|
|
**Architecture:** Neues Package `whisper_local/tray/` mit Factory-Funktion `create_tray()` analog zu `create_listener()` und `create_inserter()`. Auf Linux gibt `create_tray()` ein No-Op-Stub zurück. Tray-Thread und tkinter-Dialog laufen in dedizierten Threads; Kommunikation mit dem asyncio-Loop via `call_soon_threadsafe` / `run_coroutine_threadsafe`.
|
|||
|
|
|
|||
|
|
**Tech Stack:** pystray, Pillow, tkinter, sv-ttk, darkdetect, ctypes (Win32), sounddevice (bereits vorhanden), pynput (bereits vorhanden)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Dateiübersicht
|
|||
|
|
|
|||
|
|
| Aktion | Datei | Verantwortung |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Ändern | `whisper_local/config.py` | `microphone`-Feld + `save_config()` |
|
|||
|
|
| Ändern | `whisper_local/recorder.py` | `device`-Parameter für sounddevice |
|
|||
|
|
| Ändern | `whisper_local/hotkey/__init__.py` | `stop()` im Protocol |
|
|||
|
|
| Ändern | `whisper_local/hotkey/_pynput.py` | `stop()` implementieren |
|
|||
|
|
| Ändern | `whisper_local/hotkey/_evdev.py` | `stop()` Stub für Protocol-Konformität |
|
|||
|
|
| Ändern | `pyproject.toml` | Neue Windows-Abhängigkeiten |
|
|||
|
|
| Erstellen | `whisper_local/tray/__init__.py` | `create_tray()`, `AppState` re-export |
|
|||
|
|
| Erstellen | `whisper_local/tray/_icon.py` | Pillow-Icon-Generierung |
|
|||
|
|
| Erstellen | `whisper_local/tray/_tray.py` | `AppState`, `NoOpTray`, `Win32TrayApp` |
|
|||
|
|
| Erstellen | `whisper_local/tray/_theme.py` | System-Theme-Erkennung |
|
|||
|
|
| Erstellen | `whisper_local/tray/_settings.py` | `SettingsDialog`, Konflikt-Erkennung |
|
|||
|
|
| Ändern | `whisper_local/__main__.py` | Tray in `App` integrieren |
|
|||
|
|
| Ändern | `tests/test_config.py` | Tests für `microphone` + `save_config` |
|
|||
|
|
| Ändern | `tests/test_recorder.py` | Test für `device`-Parameter |
|
|||
|
|
| Ändern | `tests/test_hotkey.py` | Tests für `stop()` |
|
|||
|
|
| Erstellen | `tests/test_tray.py` | Alle Tray-Tests |
|
|||
|
|
| Ändern | `tests/test_main.py` | `create_tray` mocken, Tray-Callbacks testen |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: Config — `microphone`-Feld + `save_config()`
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/config.py`
|
|||
|
|
- Modify: `tests/test_config.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
Ans Ende von `tests/test_config.py` anfügen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class TestMicrophoneConfig:
|
|||
|
|
def test_default_microphone_is_empty(self):
|
|||
|
|
config = Config()
|
|||
|
|
assert config.microphone == ""
|
|||
|
|
|
|||
|
|
def test_load_device_from_toml(self, tmp_path):
|
|||
|
|
config_file = tmp_path / "config.toml"
|
|||
|
|
config_file.write_text('[audio]\ndevice = "Headset Mic"\n')
|
|||
|
|
config = load_config(config_file)
|
|||
|
|
assert config.microphone == "Headset Mic"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestSaveConfig:
|
|||
|
|
def test_save_and_reload(self, tmp_path):
|
|||
|
|
from whisper_local.config import save_config
|
|||
|
|
path = tmp_path / "config.toml"
|
|||
|
|
config = Config(hotkey="KEY_F8", microphone="USB Mic")
|
|||
|
|
save_config(config, path)
|
|||
|
|
loaded = load_config(path)
|
|||
|
|
assert loaded.hotkey == "KEY_F8"
|
|||
|
|
assert loaded.microphone == "USB Mic"
|
|||
|
|
|
|||
|
|
def test_save_creates_parent_dirs(self, tmp_path):
|
|||
|
|
from whisper_local.config import save_config
|
|||
|
|
path = tmp_path / "subdir" / "config.toml"
|
|||
|
|
save_config(Config(), path)
|
|||
|
|
assert path.exists()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_config.py::TestMicrophoneConfig tests/test_config.py::TestSaveConfig -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `Config` hat kein `microphone`, `save_config` existiert nicht.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Implementierung**
|
|||
|
|
|
|||
|
|
`whisper_local/config.py` — `Config`-Dataclass und `load_config` und neue Funktion `save_config`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@dataclass
|
|||
|
|
class Config:
|
|||
|
|
hotkey: str = "KEY_F12"
|
|||
|
|
whisper_model: str = "small"
|
|||
|
|
language: str = "de"
|
|||
|
|
compute_type: str = "int8"
|
|||
|
|
sample_rate: int = 16000
|
|||
|
|
channels: int = 1
|
|||
|
|
min_duration: float = 0.5
|
|||
|
|
microphone: str = ""
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `load_config`, nach dem `audio_section`-Block ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
if "device" in audio_section:
|
|||
|
|
config.microphone = audio_section["device"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Neue Funktion am Ende der Datei:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def save_config(config: Config, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|||
|
|
"""Schreibt Config als TOML-Datei. Erstellt Verzeichnisse bei Bedarf."""
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
content = (
|
|||
|
|
f'[hotkey]\nkey = "{config.hotkey}"\n\n'
|
|||
|
|
f'[whisper]\nmodel = "{config.whisper_model}"\n'
|
|||
|
|
f'language = "{config.language}"\n'
|
|||
|
|
f'compute_type = "{config.compute_type}"\n\n'
|
|||
|
|
f'[audio]\nsample_rate = {config.sample_rate}\n'
|
|||
|
|
f'channels = {config.channels}\n'
|
|||
|
|
f'min_duration = {config.min_duration}\n'
|
|||
|
|
f'device = "{config.microphone}"\n'
|
|||
|
|
)
|
|||
|
|
path.write_text(content, encoding="utf-8")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_config.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/config.py tests/test_config.py
|
|||
|
|
git commit -m "feat: add microphone field and save_config to Config"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: Recorder — `device`-Parameter
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/recorder.py`
|
|||
|
|
- Modify: `tests/test_recorder.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
Ans Ende von `tests/test_recorder.py` anfügen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def test_device_passed_to_inputstream(self):
|
|||
|
|
recorder = Recorder(sample_rate=16000, channels=1, min_duration=0.0, device="USB Mic")
|
|||
|
|
with patch("sounddevice.InputStream") as mock_cls:
|
|||
|
|
mock_cls.return_value = MagicMock()
|
|||
|
|
recorder.start()
|
|||
|
|
call_kwargs = mock_cls.call_args.kwargs
|
|||
|
|
assert call_kwargs["device"] == "USB Mic"
|
|||
|
|
recorder.stop()
|
|||
|
|
|
|||
|
|
def test_default_device_is_none(self):
|
|||
|
|
recorder = Recorder()
|
|||
|
|
assert recorder.device is None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_recorder.py::TestRecorder::test_device_passed_to_inputstream tests/test_recorder.py::TestRecorder::test_default_device_is_none -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `Recorder` kennt kein `device`.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Implementierung**
|
|||
|
|
|
|||
|
|
`whisper_local/recorder.py`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class Recorder:
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
sample_rate: int = 16000,
|
|||
|
|
channels: int = 1,
|
|||
|
|
min_duration: float = 0.5,
|
|||
|
|
device: str | None = None,
|
|||
|
|
):
|
|||
|
|
self.sample_rate = sample_rate
|
|||
|
|
self.channels = channels
|
|||
|
|
self.min_duration = min_duration
|
|||
|
|
self.device = device
|
|||
|
|
self.is_recording = False
|
|||
|
|
self._chunks: list[np.ndarray] = []
|
|||
|
|
self._stream: sd.InputStream | None = None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `start()` den `InputStream`-Aufruf um `device=self.device` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
self._stream = sd.InputStream(
|
|||
|
|
samplerate=self.sample_rate,
|
|||
|
|
channels=self.channels,
|
|||
|
|
dtype=np.float32,
|
|||
|
|
callback=self._audio_callback,
|
|||
|
|
device=self.device,
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_recorder.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/recorder.py tests/test_recorder.py
|
|||
|
|
git commit -m "feat: add optional device parameter to Recorder"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: HotkeyListener — `stop()`-Methode
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/hotkey/__init__.py`
|
|||
|
|
- Modify: `whisper_local/hotkey/_pynput.py`
|
|||
|
|
- Modify: `whisper_local/hotkey/_evdev.py`
|
|||
|
|
- Modify: `tests/test_hotkey.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
Ans Ende von `tests/test_hotkey.py` anfügen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class TestPynputHotkeyListenerStop:
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_stop_ends_listen(self):
|
|||
|
|
from whisper_local.hotkey._pynput import PynputHotkeyListener
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
listener = PynputHotkeyListener("KEY_F12")
|
|||
|
|
|
|||
|
|
with patch("whisper_local.hotkey._pynput.Listener") as mock_listener_cls:
|
|||
|
|
mock_listener_cls.return_value = MagicMock()
|
|||
|
|
listen_task = asyncio.create_task(listener.listen())
|
|||
|
|
await asyncio.sleep(0) # Loop einen Schritt weiter
|
|||
|
|
listener.stop()
|
|||
|
|
await asyncio.wait_for(listen_task, timeout=1.0)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_hotkey.py::TestPynputHotkeyListenerStop -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `PynputHotkeyListener` hat keine `stop()`-Methode.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Protocol aktualisieren**
|
|||
|
|
|
|||
|
|
`whisper_local/hotkey/__init__.py`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@runtime_checkable
|
|||
|
|
class HotkeyListener(Protocol):
|
|||
|
|
on_press: AsyncCallback | None
|
|||
|
|
on_release: AsyncCallback | None
|
|||
|
|
|
|||
|
|
async def listen(self) -> None: ...
|
|||
|
|
def stop(self) -> None: ...
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: PynputHotkeyListener aktualisieren**
|
|||
|
|
|
|||
|
|
`whisper_local/hotkey/_pynput.py` — `__init__` und `listen` und neue Methode `stop`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class PynputHotkeyListener:
|
|||
|
|
def __init__(self, key_name: str = "KEY_F12"):
|
|||
|
|
self.key_name = key_name
|
|||
|
|
self._target_key = _evdev_to_pynput_key(key_name)
|
|||
|
|
self.on_press: AsyncCallback | None = None
|
|||
|
|
self.on_release: AsyncCallback | None = None
|
|||
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|||
|
|
self._pressed = False
|
|||
|
|
self._stop_event: asyncio.Event | None = None
|
|||
|
|
|
|||
|
|
def stop(self) -> None:
|
|||
|
|
"""Signalisiert dem listen()-Loop zu beenden."""
|
|||
|
|
if self._loop is not None and self._stop_event is not None:
|
|||
|
|
self._loop.call_soon_threadsafe(self._stop_event.set)
|
|||
|
|
|
|||
|
|
async def listen(self) -> None:
|
|||
|
|
self._loop = asyncio.get_running_loop()
|
|||
|
|
self._stop_event = asyncio.Event()
|
|||
|
|
|
|||
|
|
listener = Listener(on_press=self._on_press, on_release=self._on_release)
|
|||
|
|
listener.start()
|
|||
|
|
logger.info("Lausche auf %s via pynput", self.key_name)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
await self._stop_event.wait()
|
|||
|
|
finally:
|
|||
|
|
listener.stop()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: EvdevHotkeyListener Stub hinzufügen**
|
|||
|
|
|
|||
|
|
`whisper_local/hotkey/_evdev.py` — `stop()`-Stub für Protocol-Konformität:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def stop(self) -> None:
|
|||
|
|
"""Stub — evdev-Listener läuft bis zum Prozessende."""
|
|||
|
|
pass
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 6: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_hotkey.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 7: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/hotkey/__init__.py whisper_local/hotkey/_pynput.py whisper_local/hotkey/_evdev.py tests/test_hotkey.py
|
|||
|
|
git commit -m "feat: add stop() method to HotkeyListener protocol and PynputHotkeyListener"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: Abhängigkeiten
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `pyproject.toml`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: `pyproject.toml` aktualisieren**
|
|||
|
|
|
|||
|
|
Im `dependencies`-Array ergänzen (nach `pywin32`-Zeile):
|
|||
|
|
|
|||
|
|
```toml
|
|||
|
|
dependencies = [
|
|||
|
|
"faster-whisper>=1.1.0",
|
|||
|
|
"sounddevice>=0.5.0",
|
|||
|
|
"numpy>=2.0.0",
|
|||
|
|
"evdev>=1.7.0; sys_platform == 'linux'",
|
|||
|
|
"pynput>=1.7.0; sys_platform == 'win32'",
|
|||
|
|
"pywin32>=306; sys_platform == 'win32'",
|
|||
|
|
"pystray>=0.19.0; sys_platform == 'win32'",
|
|||
|
|
"Pillow>=10.0.0; sys_platform == 'win32'",
|
|||
|
|
"sv-ttk>=2.6.0; sys_platform == 'win32'",
|
|||
|
|
"darkdetect>=0.8.0; sys_platform == 'win32'",
|
|||
|
|
]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Lock-Datei aktualisieren**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv lock
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: `uv.lock` wird aktualisiert, kein Fehler.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pyproject.toml uv.lock
|
|||
|
|
git commit -m "build: add pystray, Pillow, sv-ttk, darkdetect as Windows dependencies"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: `_icon.py` — Icon-Generierung
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `whisper_local/tray/__init__.py` (leer)
|
|||
|
|
- Create: `whisper_local/tray/_icon.py`
|
|||
|
|
- Create: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Leeres Package anlegen**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/__init__.py` mit leerem Inhalt erstellen (wird in Task 10 befüllt).
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
`tests/test_tray.py` erstellen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import sys
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows")
|
|||
|
|
class TestCreateIcon:
|
|||
|
|
def test_returns_image_for_each_state(self):
|
|||
|
|
from PIL import Image
|
|||
|
|
from whisper_local.tray._tray import AppState
|
|||
|
|
from whisper_local.tray._icon import create_icon
|
|||
|
|
|
|||
|
|
for state in AppState:
|
|||
|
|
img = create_icon(state)
|
|||
|
|
assert isinstance(img, Image.Image)
|
|||
|
|
assert img.size == (64, 64)
|
|||
|
|
assert img.mode == "RGBA"
|
|||
|
|
|
|||
|
|
def test_different_states_have_different_colors(self):
|
|||
|
|
from whisper_local.tray._tray import AppState
|
|||
|
|
from whisper_local.tray._icon import create_icon
|
|||
|
|
|
|||
|
|
waiting = create_icon(AppState.WAITING)
|
|||
|
|
recording = create_icon(AppState.RECORDING)
|
|||
|
|
assert waiting.tobytes() != recording.tobytes()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestCreateIcon -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — Module nicht vorhanden.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: `_tray.py` — AppState-Enum anlegen** (wird in Task 6 vervollständigt)
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_tray.py` erstellen — nur den Enum, damit `_icon.py` importieren kann:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Tray-App und App-Zustände für whisper-local (Windows)."""
|
|||
|
|
|
|||
|
|
import enum
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AppState(enum.Enum):
|
|||
|
|
WAITING = "waiting"
|
|||
|
|
RECORDING = "recording"
|
|||
|
|
TRANSCRIBING = "transcribing"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: `_icon.py` implementieren**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_icon.py` erstellen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Programmatische Icon-Generierung via Pillow."""
|
|||
|
|
|
|||
|
|
from PIL import Image, ImageDraw
|
|||
|
|
|
|||
|
|
from whisper_local.tray._tray import AppState
|
|||
|
|
|
|||
|
|
_STATE_COLORS: dict[AppState, tuple[int, int, int]] = {
|
|||
|
|
AppState.WAITING: (150, 150, 150),
|
|||
|
|
AppState.RECORDING: (220, 50, 50),
|
|||
|
|
AppState.TRANSCRIBING: (220, 180, 0),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_icon(state: AppState, size: int = 64) -> Image.Image:
|
|||
|
|
"""Erzeugt ein Mikrofon-Icon in der Farbe des übergebenen Zustands."""
|
|||
|
|
color = _STATE_COLORS[state]
|
|||
|
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|||
|
|
draw = ImageDraw.Draw(img)
|
|||
|
|
|
|||
|
|
cx = size // 2
|
|||
|
|
bw = max(4, size // 6) # Hälfte der Körperbreite
|
|||
|
|
top = size // 8
|
|||
|
|
mid = size // 2
|
|||
|
|
|
|||
|
|
# Mikrofon-Körper (abgerundetes Rechteck)
|
|||
|
|
draw.rounded_rectangle(
|
|||
|
|
[cx - bw, top, cx + bw, mid + bw],
|
|||
|
|
radius=bw,
|
|||
|
|
fill=color,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Bogen (Stativ-Bogen)
|
|||
|
|
arc_r = size // 4
|
|||
|
|
arc_top = mid
|
|||
|
|
lw = max(2, size // 20)
|
|||
|
|
draw.arc(
|
|||
|
|
[cx - arc_r, arc_top, cx + arc_r, arc_top + arc_r],
|
|||
|
|
start=0, end=180, fill=color, width=lw,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Stiel
|
|||
|
|
pole_top = arc_top + arc_r // 2
|
|||
|
|
pole_bot = size - size // 8
|
|||
|
|
draw.line([cx, pole_top, cx, pole_bot], fill=color, width=lw)
|
|||
|
|
|
|||
|
|
# Sockel
|
|||
|
|
base = size // 5
|
|||
|
|
draw.line([cx - base, pole_bot, cx + base, pole_bot], fill=color, width=lw)
|
|||
|
|
|
|||
|
|
return img
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 6: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestCreateIcon -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS (oder skip auf Linux).
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 7: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/__init__.py whisper_local/tray/_tray.py whisper_local/tray/_icon.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add AppState enum and programmatic tray icon generation"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: `_tray.py` — `Win32TrayApp` + `NoOpTray`
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/tray/_tray.py`
|
|||
|
|
- Modify: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
In `tests/test_tray.py` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows")
|
|||
|
|
class TestWin32TrayApp:
|
|||
|
|
def test_set_state_updates_icon(self):
|
|||
|
|
from unittest.mock import MagicMock, patch
|
|||
|
|
from whisper_local.tray._tray import AppState, Win32TrayApp
|
|||
|
|
|
|||
|
|
app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock())
|
|||
|
|
|
|||
|
|
mock_icon = MagicMock()
|
|||
|
|
app._icon = mock_icon
|
|||
|
|
|
|||
|
|
app.set_state(AppState.RECORDING)
|
|||
|
|
|
|||
|
|
assert mock_icon.icon is not None
|
|||
|
|
|
|||
|
|
def test_set_state_before_start_is_safe(self):
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from whisper_local.tray._tray import AppState, Win32TrayApp
|
|||
|
|
|
|||
|
|
app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock())
|
|||
|
|
app.set_state(AppState.WAITING) # kein Fehler, _icon ist None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestNoOpTray:
|
|||
|
|
def test_start_does_nothing(self):
|
|||
|
|
from whisper_local.tray._tray import AppState, NoOpTray
|
|||
|
|
tray = NoOpTray()
|
|||
|
|
tray.start() # kein Fehler
|
|||
|
|
|
|||
|
|
def test_set_state_does_nothing(self):
|
|||
|
|
from whisper_local.tray._tray import AppState, NoOpTray
|
|||
|
|
tray = NoOpTray()
|
|||
|
|
tray.set_state(AppState.RECORDING) # kein Fehler
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestWin32TrayApp tests/test_tray.py::TestNoOpTray -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `Win32TrayApp` und `NoOpTray` nicht vorhanden.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: `_tray.py` vervollständigen**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_tray.py` komplett ersetzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Tray-App und App-Zustände für whisper-local (Windows)."""
|
|||
|
|
|
|||
|
|
import enum
|
|||
|
|
import sys
|
|||
|
|
import threading
|
|||
|
|
from typing import Callable
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AppState(enum.Enum):
|
|||
|
|
WAITING = "waiting"
|
|||
|
|
RECORDING = "recording"
|
|||
|
|
TRANSCRIBING = "transcribing"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Win32TrayApp:
|
|||
|
|
"""Tray-Icon via pystray für Windows."""
|
|||
|
|
|
|||
|
|
def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]):
|
|||
|
|
self._on_settings = on_settings
|
|||
|
|
self._on_quit = on_quit
|
|||
|
|
self._icon = None
|
|||
|
|
|
|||
|
|
def start(self) -> None:
|
|||
|
|
"""Startet pystray in einem Daemon-Thread."""
|
|||
|
|
import pystray
|
|||
|
|
from whisper_local.tray._icon import create_icon
|
|||
|
|
|
|||
|
|
menu = pystray.Menu(
|
|||
|
|
pystray.MenuItem("Einstellungen", self._menu_settings),
|
|||
|
|
pystray.MenuItem("Beenden", self._menu_quit),
|
|||
|
|
)
|
|||
|
|
self._icon = pystray.Icon(
|
|||
|
|
"whisper-local",
|
|||
|
|
create_icon(AppState.WAITING),
|
|||
|
|
"whisper-local",
|
|||
|
|
menu,
|
|||
|
|
)
|
|||
|
|
thread = threading.Thread(target=self._icon.run, daemon=True)
|
|||
|
|
thread.start()
|
|||
|
|
|
|||
|
|
def set_state(self, state: AppState) -> None:
|
|||
|
|
"""Tauscht das Icon aus (thread-sicher)."""
|
|||
|
|
if self._icon is not None:
|
|||
|
|
from whisper_local.tray._icon import create_icon
|
|||
|
|
self._icon.icon = create_icon(state)
|
|||
|
|
|
|||
|
|
def _menu_settings(self, icon, item) -> None:
|
|||
|
|
self._on_settings()
|
|||
|
|
|
|||
|
|
def _menu_quit(self, icon, item) -> None:
|
|||
|
|
if self._icon is not None:
|
|||
|
|
self._icon.stop()
|
|||
|
|
self._on_quit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class NoOpTray:
|
|||
|
|
"""Platzhalter-Implementierung für nicht-Windows-Plattformen."""
|
|||
|
|
|
|||
|
|
def start(self) -> None:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def set_state(self, state: AppState) -> None:
|
|||
|
|
pass
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS (Windows), Skips auf Linux.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/_tray.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add Win32TrayApp and NoOpTray with state management"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: `_theme.py` — System-Theme-Erkennung
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `whisper_local/tray/_theme.py`
|
|||
|
|
- Modify: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
In `tests/test_tray.py` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Theme nur auf Windows")
|
|||
|
|
class TestApplySystemTheme:
|
|||
|
|
def test_applies_light_theme(self):
|
|||
|
|
import tkinter as tk
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
from whisper_local.tray._theme import apply_system_theme
|
|||
|
|
|
|||
|
|
root = tk.Tk()
|
|||
|
|
root.withdraw()
|
|||
|
|
try:
|
|||
|
|
with patch("darkdetect.theme", return_value="Light"), \
|
|||
|
|
patch("sv_ttk.set_theme") as mock_set:
|
|||
|
|
apply_system_theme(root)
|
|||
|
|
mock_set.assert_called_once_with("light")
|
|||
|
|
finally:
|
|||
|
|
root.destroy()
|
|||
|
|
|
|||
|
|
def test_applies_dark_theme(self):
|
|||
|
|
import tkinter as tk
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
from whisper_local.tray._theme import apply_system_theme
|
|||
|
|
|
|||
|
|
root = tk.Tk()
|
|||
|
|
root.withdraw()
|
|||
|
|
try:
|
|||
|
|
with patch("darkdetect.theme", return_value="Dark"), \
|
|||
|
|
patch("sv_ttk.set_theme") as mock_set:
|
|||
|
|
apply_system_theme(root)
|
|||
|
|
mock_set.assert_called_once_with("dark")
|
|||
|
|
finally:
|
|||
|
|
root.destroy()
|
|||
|
|
|
|||
|
|
def test_falls_back_to_light_when_none(self):
|
|||
|
|
import tkinter as tk
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
from whisper_local.tray._theme import apply_system_theme
|
|||
|
|
|
|||
|
|
root = tk.Tk()
|
|||
|
|
root.withdraw()
|
|||
|
|
try:
|
|||
|
|
with patch("darkdetect.theme", return_value=None), \
|
|||
|
|
patch("sv_ttk.set_theme") as mock_set:
|
|||
|
|
apply_system_theme(root)
|
|||
|
|
mock_set.assert_called_once_with("light")
|
|||
|
|
finally:
|
|||
|
|
root.destroy()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestApplySystemTheme -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `_theme.py` nicht vorhanden.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: `_theme.py` implementieren**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_theme.py` erstellen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""System-Theme-Erkennung und sv-ttk-Anwendung."""
|
|||
|
|
|
|||
|
|
import tkinter as tk
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_system_theme(root: tk.Tk) -> None:
|
|||
|
|
"""Setzt sv-ttk-Theme passend zum Windows-System-Theme (Light/Dark)."""
|
|||
|
|
import darkdetect
|
|||
|
|
import sv_ttk
|
|||
|
|
|
|||
|
|
detected = darkdetect.theme() or "Light"
|
|||
|
|
sv_ttk.set_theme(detected.lower())
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestApplySystemTheme -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/_theme.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add system theme detection for sv-ttk"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: `_settings.py` — Hilfsfunktionen
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `whisper_local/tray/_settings.py`
|
|||
|
|
- Modify: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
In `tests/test_tray.py` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows")
|
|||
|
|
class TestCheckHotkeyConflict:
|
|||
|
|
def test_returns_false_when_key_is_free(self):
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
from whisper_local.tray._settings import check_hotkey_conflict
|
|||
|
|
|
|||
|
|
mock_user32 = MagicMock()
|
|||
|
|
mock_user32.RegisterHotKey.return_value = 1 # Erfolg
|
|||
|
|
with patch("ctypes.windll") as mock_windll:
|
|||
|
|
mock_windll.user32 = mock_user32
|
|||
|
|
result = check_hotkey_conflict("KEY_F12")
|
|||
|
|
assert result is False
|
|||
|
|
mock_user32.UnregisterHotKey.assert_called_once()
|
|||
|
|
|
|||
|
|
def test_returns_true_when_key_is_taken(self):
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
from whisper_local.tray._settings import check_hotkey_conflict
|
|||
|
|
|
|||
|
|
mock_user32 = MagicMock()
|
|||
|
|
mock_user32.RegisterHotKey.return_value = 0 # Belegt
|
|||
|
|
with patch("ctypes.windll") as mock_windll:
|
|||
|
|
mock_windll.user32 = mock_user32
|
|||
|
|
result = check_hotkey_conflict("KEY_F12")
|
|||
|
|
assert result is True
|
|||
|
|
mock_user32.UnregisterHotKey.assert_not_called()
|
|||
|
|
|
|||
|
|
def test_returns_false_for_unknown_key(self):
|
|||
|
|
from whisper_local.tray._settings import check_hotkey_conflict
|
|||
|
|
result = check_hotkey_conflict("KEY_NONEXISTENT_999")
|
|||
|
|
assert result is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestListMicrophones:
|
|||
|
|
def test_returns_only_input_devices(self):
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
from whisper_local.tray._settings import list_microphones
|
|||
|
|
|
|||
|
|
fake_devices = [
|
|||
|
|
{"name": "Speakers", "max_input_channels": 0},
|
|||
|
|
{"name": "Headset Mic", "max_input_channels": 1},
|
|||
|
|
{"name": "USB Mic", "max_input_channels": 2},
|
|||
|
|
]
|
|||
|
|
with patch("sounddevice.query_devices", return_value=fake_devices):
|
|||
|
|
result = list_microphones()
|
|||
|
|
assert result == [("Headset Mic", 1), ("USB Mic", 2)]
|
|||
|
|
|
|||
|
|
def test_returns_empty_list_when_no_input(self):
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
from whisper_local.tray._settings import list_microphones
|
|||
|
|
|
|||
|
|
with patch("sounddevice.query_devices", return_value=[]):
|
|||
|
|
result = list_microphones()
|
|||
|
|
assert result == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestPynputToEvdevKey:
|
|||
|
|
def test_function_key(self):
|
|||
|
|
from pynput.keyboard import Key
|
|||
|
|
from whisper_local.tray._settings import pynput_to_evdev_key
|
|||
|
|
assert pynput_to_evdev_key(Key.f12) == "KEY_F12"
|
|||
|
|
|
|||
|
|
def test_space_key(self):
|
|||
|
|
from pynput.keyboard import Key
|
|||
|
|
from whisper_local.tray._settings import pynput_to_evdev_key
|
|||
|
|
assert pynput_to_evdev_key(Key.space) == "KEY_SPACE"
|
|||
|
|
|
|||
|
|
def test_char_key(self):
|
|||
|
|
from pynput.keyboard import KeyCode
|
|||
|
|
from whisper_local.tray._settings import pynput_to_evdev_key
|
|||
|
|
key = KeyCode.from_char("a")
|
|||
|
|
assert pynput_to_evdev_key(key) == "KEY_A"
|
|||
|
|
|
|||
|
|
def test_unknown_returns_empty(self):
|
|||
|
|
from whisper_local.tray._settings import pynput_to_evdev_key
|
|||
|
|
assert pynput_to_evdev_key(None) == ""
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestCheckHotkeyConflict tests/test_tray.py::TestListMicrophones tests/test_tray.py::TestPynputToEvdevKey -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `_settings.py` nicht vorhanden.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: Hilfsfunktionen implementieren**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_settings.py` erstellen (nur Hilfsfunktionen, Dialog folgt in Task 9):
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Einstellungs-Dialog für whisper-local (Windows)."""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import ctypes
|
|||
|
|
import threading
|
|||
|
|
from typing import Callable
|
|||
|
|
|
|||
|
|
import sounddevice as sd
|
|||
|
|
|
|||
|
|
from whisper_local.config import Config, save_config
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pynput_to_evdev_key(key) -> str:
|
|||
|
|
"""Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12')."""
|
|||
|
|
from pynput.keyboard import Key, KeyCode
|
|||
|
|
|
|||
|
|
if isinstance(key, Key):
|
|||
|
|
return f"KEY_{key.name.upper()}"
|
|||
|
|
if isinstance(key, KeyCode) and key.char:
|
|||
|
|
return f"KEY_{key.char.upper()}"
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_hotkey_conflict(evdev_name: str) -> bool:
|
|||
|
|
"""Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist."""
|
|||
|
|
from whisper_local.hotkey._pynput import _evdev_to_pynput_key
|
|||
|
|
from pynput.keyboard import Key
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
pynput_key = _evdev_to_pynput_key(evdev_name)
|
|||
|
|
except ValueError:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if not isinstance(pynput_key, Key):
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
vk = getattr(pynput_key.value, "vk", None)
|
|||
|
|
if vk is None:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
HOTKEY_ID = 0x7FFF
|
|||
|
|
user32 = ctypes.windll.user32
|
|||
|
|
if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk):
|
|||
|
|
user32.UnregisterHotKey(None, HOTKEY_ID)
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_microphones() -> list[tuple[str, int]]:
|
|||
|
|
"""Gibt Liste aller Eingabegeräte als (name, index) zurück."""
|
|||
|
|
devices = sd.query_devices()
|
|||
|
|
return [
|
|||
|
|
(dev["name"], idx)
|
|||
|
|
for idx, dev in enumerate(devices)
|
|||
|
|
if dev["max_input_channels"] > 0
|
|||
|
|
]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestCheckHotkeyConflict tests/test_tray.py::TestListMicrophones tests/test_tray.py::TestPynputToEvdevKey -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/_settings.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add hotkey conflict detection and microphone listing helpers"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: `_settings.py` — SettingsDialog
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/tray/_settings.py`
|
|||
|
|
- Modify: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
In `tests/test_tray.py` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows")
|
|||
|
|
class TestSettingsDialog:
|
|||
|
|
def test_on_save_called_with_new_config(self):
|
|||
|
|
import tkinter as tk
|
|||
|
|
from unittest.mock import patch, MagicMock, call
|
|||
|
|
from whisper_local.config import Config
|
|||
|
|
from whisper_local.tray._settings import SettingsDialog
|
|||
|
|
|
|||
|
|
saved = []
|
|||
|
|
dialog = SettingsDialog(
|
|||
|
|
config=Config(hotkey="KEY_F12", microphone=""),
|
|||
|
|
on_save=saved.append,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Dialog direkt aufrufen (nicht in Thread), mit gemocktem mainloop
|
|||
|
|
with patch("tkinter.Tk.mainloop"), \
|
|||
|
|
patch("whisper_local.tray._settings.apply_system_theme"), \
|
|||
|
|
patch("whisper_local.tray._settings.list_microphones", return_value=[]), \
|
|||
|
|
patch("whisper_local.tray._settings.save_config") as mock_save:
|
|||
|
|
dialog._run()
|
|||
|
|
|
|||
|
|
# _run() ruft mainloop auf, der sofort zurückkehrt.
|
|||
|
|
# save() wird nicht automatisch aufgerufen — das ist korrekt.
|
|||
|
|
# Wir prüfen nur, dass _run() ohne Fehler durchläuft.
|
|||
|
|
assert mock_save.call_count == 0 # Noch nicht gespeichert ohne Klick
|
|||
|
|
|
|||
|
|
def test_on_save_callback_called_when_save_invoked(self):
|
|||
|
|
import tkinter as tk
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
from whisper_local.config import Config
|
|||
|
|
from whisper_local.tray._settings import SettingsDialog
|
|||
|
|
|
|||
|
|
saved_configs = []
|
|||
|
|
dialog = SettingsDialog(
|
|||
|
|
config=Config(hotkey="KEY_F12", microphone=""),
|
|||
|
|
on_save=saved_configs.append,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# _run() intern aufrufen und save() direkt triggern
|
|||
|
|
captured_save_fn = []
|
|||
|
|
|
|||
|
|
def fake_button(frame, text, command, **kwargs):
|
|||
|
|
if text == "Speichern":
|
|||
|
|
captured_save_fn.append(command)
|
|||
|
|
mock = MagicMock()
|
|||
|
|
mock.pack = MagicMock()
|
|||
|
|
return mock
|
|||
|
|
|
|||
|
|
with patch("tkinter.Tk.mainloop"), \
|
|||
|
|
patch("whisper_local.tray._settings.apply_system_theme"), \
|
|||
|
|
patch("whisper_local.tray._settings.list_microphones", return_value=[("USB Mic", 0)]), \
|
|||
|
|
patch("whisper_local.tray._settings.save_config"), \
|
|||
|
|
patch("tkinter.ttk.Button", side_effect=fake_button):
|
|||
|
|
dialog._run()
|
|||
|
|
|
|||
|
|
if captured_save_fn:
|
|||
|
|
captured_save_fn[0]()
|
|||
|
|
assert len(saved_configs) == 1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestSettingsDialog -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `SettingsDialog` nicht vorhanden.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: SettingsDialog implementieren**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/_settings.py` — `SettingsDialog`-Klasse am Ende der Datei ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class SettingsDialog:
|
|||
|
|
"""Einstellungs-Dialog (läuft in eigenem Thread)."""
|
|||
|
|
|
|||
|
|
def __init__(self, config: Config, on_save: Callable[[Config], None]):
|
|||
|
|
self._config = config
|
|||
|
|
self._on_save = on_save
|
|||
|
|
|
|||
|
|
def open(self) -> None:
|
|||
|
|
"""Öffnet den Dialog in einem Daemon-Thread."""
|
|||
|
|
thread = threading.Thread(target=self._run, daemon=True)
|
|||
|
|
thread.start()
|
|||
|
|
|
|||
|
|
def _run(self) -> None:
|
|||
|
|
import tkinter as tk
|
|||
|
|
from tkinter import ttk
|
|||
|
|
from whisper_local.tray._theme import apply_system_theme
|
|||
|
|
|
|||
|
|
root = tk.Tk()
|
|||
|
|
root.title("whisper-local – Einstellungen")
|
|||
|
|
root.resizable(False, False)
|
|||
|
|
apply_system_theme(root)
|
|||
|
|
|
|||
|
|
frame = ttk.Frame(root, padding=16)
|
|||
|
|
frame.pack(fill=tk.BOTH, expand=True)
|
|||
|
|
|
|||
|
|
# --- Hotkey ---
|
|||
|
|
ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4)
|
|||
|
|
hotkey_var = tk.StringVar(value=self._config.hotkey)
|
|||
|
|
ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid(
|
|||
|
|
row=0, column=1, padx=8
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
conflict_var = tk.StringVar()
|
|||
|
|
ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid(
|
|||
|
|
row=1, column=0, columnspan=3, sticky=tk.W
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def record_hotkey():
|
|||
|
|
hotkey_var.set("...")
|
|||
|
|
conflict_var.set("")
|
|||
|
|
|
|||
|
|
captured: list[str] = []
|
|||
|
|
|
|||
|
|
def on_press(key):
|
|||
|
|
evdev = pynput_to_evdev_key(key)
|
|||
|
|
if evdev:
|
|||
|
|
captured.append(evdev)
|
|||
|
|
return False # Listener stoppen
|
|||
|
|
|
|||
|
|
def listen():
|
|||
|
|
from pynput.keyboard import Listener
|
|||
|
|
with Listener(on_press=on_press) as lst:
|
|||
|
|
lst.join()
|
|||
|
|
if captured:
|
|||
|
|
evdev = captured[0]
|
|||
|
|
root.after(0, lambda: hotkey_var.set(evdev))
|
|||
|
|
if check_hotkey_conflict(evdev):
|
|||
|
|
root.after(
|
|||
|
|
0,
|
|||
|
|
lambda: conflict_var.set(
|
|||
|
|
"⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)"
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
threading.Thread(target=listen, daemon=True).start()
|
|||
|
|
|
|||
|
|
ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid(
|
|||
|
|
row=0, column=2, padx=4
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- Mikrofon ---
|
|||
|
|
ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4)
|
|||
|
|
mics = list_microphones()
|
|||
|
|
mic_names = ["Standard"] + [name for name, _ in mics]
|
|||
|
|
current_mic = self._config.microphone or "Standard"
|
|||
|
|
mic_var = tk.StringVar(value=current_mic)
|
|||
|
|
ttk.Combobox(
|
|||
|
|
frame, textvariable=mic_var, values=mic_names, state="readonly", width=32
|
|||
|
|
).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4)
|
|||
|
|
|
|||
|
|
# --- Buttons ---
|
|||
|
|
btn_frame = ttk.Frame(frame)
|
|||
|
|
btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E)
|
|||
|
|
|
|||
|
|
def save():
|
|||
|
|
new_config = Config(
|
|||
|
|
hotkey=hotkey_var.get(),
|
|||
|
|
whisper_model=self._config.whisper_model,
|
|||
|
|
language=self._config.language,
|
|||
|
|
compute_type=self._config.compute_type,
|
|||
|
|
sample_rate=self._config.sample_rate,
|
|||
|
|
channels=self._config.channels,
|
|||
|
|
min_duration=self._config.min_duration,
|
|||
|
|
microphone="" if mic_var.get() == "Standard" else mic_var.get(),
|
|||
|
|
)
|
|||
|
|
save_config(new_config)
|
|||
|
|
self._on_save(new_config)
|
|||
|
|
root.destroy()
|
|||
|
|
|
|||
|
|
ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4)
|
|||
|
|
ttk.Button(btn_frame, text="Abbrechen", command=root.destroy).pack(side=tk.RIGHT)
|
|||
|
|
|
|||
|
|
root.mainloop()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestSettingsDialog -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/_settings.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add SettingsDialog with hotkey recording and microphone selection"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 10: `tray/__init__.py` — Factory `create_tray()`
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/tray/__init__.py`
|
|||
|
|
- Modify: `tests/test_tray.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Fehlschlagenden Test schreiben**
|
|||
|
|
|
|||
|
|
In `tests/test_tray.py` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class TestCreateTray:
|
|||
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Win32TrayApp nur auf Windows")
|
|||
|
|
def test_returns_win32_tray_on_windows(self):
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from whisper_local.tray import create_tray
|
|||
|
|
from whisper_local.tray._tray import Win32TrayApp
|
|||
|
|
|
|||
|
|
tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock())
|
|||
|
|
assert isinstance(tray, Win32TrayApp)
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="NoOpTray nur auf nicht-Windows")
|
|||
|
|
def test_returns_noop_tray_on_non_windows(self):
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
from whisper_local.tray import create_tray
|
|||
|
|
from whisper_local.tray._tray import NoOpTray
|
|||
|
|
|
|||
|
|
tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock())
|
|||
|
|
assert isinstance(tray, NoOpTray)
|
|||
|
|
|
|||
|
|
def test_appstate_exported_from_package(self):
|
|||
|
|
from whisper_local.tray import AppState
|
|||
|
|
assert AppState.WAITING is not None
|
|||
|
|
assert AppState.RECORDING is not None
|
|||
|
|
assert AppState.TRANSCRIBING is not None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Test ausführen — muss scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py::TestCreateTray -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL — `create_tray` nicht in `__init__.py`.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: `__init__.py` implementieren**
|
|||
|
|
|
|||
|
|
`whisper_local/tray/__init__.py`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Tray-Package — plattformspezifische Tray-App."""
|
|||
|
|
|
|||
|
|
import sys
|
|||
|
|
from typing import Callable
|
|||
|
|
|
|||
|
|
from whisper_local.tray._tray import AppState, NoOpTray
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_tray(
|
|||
|
|
on_settings: Callable[[], None],
|
|||
|
|
on_quit: Callable[[], None],
|
|||
|
|
) -> "Win32TrayApp | NoOpTray":
|
|||
|
|
"""Gibt den plattformspezifischen Tray zurück."""
|
|||
|
|
if sys.platform == "win32":
|
|||
|
|
from whisper_local.tray._tray import Win32TrayApp
|
|||
|
|
return Win32TrayApp(on_settings=on_settings, on_quit=on_quit)
|
|||
|
|
return NoOpTray()
|
|||
|
|
|
|||
|
|
|
|||
|
|
__all__ = ["create_tray", "AppState"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_tray.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS (Windows), Skips wo plattformspezifisch.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/tray/__init__.py tests/test_tray.py
|
|||
|
|
git commit -m "feat: add create_tray() factory with platform dispatch"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 11: `__main__.py` — App-Integration
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `whisper_local/__main__.py`
|
|||
|
|
- Modify: `tests/test_main.py`
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 1: Bestehende Tests anpassen**
|
|||
|
|
|
|||
|
|
In `tests/test_main.py` alle drei `@patch`-Decorator-Blöcke um `create_tray`-Mock erweitern:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@patch("whisper_local.__main__.create_tray")
|
|||
|
|
@patch("whisper_local.__main__.Transcriber")
|
|||
|
|
@patch("whisper_local.__main__.create_listener")
|
|||
|
|
@patch("whisper_local.__main__.create_inserter")
|
|||
|
|
def test_app_init(self, mock_inserter_factory, mock_listener_factory,
|
|||
|
|
mock_transcriber_class, mock_tray_factory):
|
|||
|
|
app = App()
|
|||
|
|
assert app.recorder is not None
|
|||
|
|
mock_transcriber_class.assert_called_once()
|
|||
|
|
mock_listener_factory.assert_called_once()
|
|||
|
|
mock_inserter_factory.assert_called_once()
|
|||
|
|
mock_tray_factory.assert_called_once()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Gleiche Erweiterung für `test_on_press_starts_recording`, `test_on_release_stops_and_transcribes`, `test_on_release_no_audio_skips`.
|
|||
|
|
|
|||
|
|
Außerdem neue Tests am Ende anfügen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@patch("whisper_local.__main__.create_tray")
|
|||
|
|
@patch("whisper_local.__main__.Transcriber")
|
|||
|
|
@patch("whisper_local.__main__.create_listener")
|
|||
|
|
@patch("whisper_local.__main__.create_inserter")
|
|||
|
|
def test_on_press_sets_recording_state(
|
|||
|
|
self, mock_inserter_factory, mock_listener_factory,
|
|||
|
|
mock_transcriber_class, mock_tray_factory
|
|||
|
|
):
|
|||
|
|
from whisper_local.tray import AppState
|
|||
|
|
mock_tray = MagicMock()
|
|||
|
|
mock_tray_factory.return_value = mock_tray
|
|||
|
|
|
|||
|
|
app = App()
|
|||
|
|
app.recorder = MagicMock()
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
asyncio.run(app.on_press())
|
|||
|
|
|
|||
|
|
mock_tray.set_state.assert_called_with(AppState.RECORDING)
|
|||
|
|
|
|||
|
|
@patch("whisper_local.__main__.create_tray")
|
|||
|
|
@patch("whisper_local.__main__.Transcriber")
|
|||
|
|
@patch("whisper_local.__main__.create_listener")
|
|||
|
|
@patch("whisper_local.__main__.create_inserter")
|
|||
|
|
def test_on_release_sets_waiting_state_after_transcription(
|
|||
|
|
self, mock_inserter_factory, mock_listener_factory,
|
|||
|
|
mock_transcriber_class, mock_tray_factory
|
|||
|
|
):
|
|||
|
|
from whisper_local.tray import AppState
|
|||
|
|
mock_tray = MagicMock()
|
|||
|
|
mock_tray_factory.return_value = mock_tray
|
|||
|
|
|
|||
|
|
mock_transcriber = MagicMock()
|
|||
|
|
mock_transcriber.transcribe.return_value = "Text"
|
|||
|
|
mock_transcriber_class.return_value = mock_transcriber
|
|||
|
|
|
|||
|
|
app = App()
|
|||
|
|
app.recorder = MagicMock()
|
|||
|
|
app.recorder.stop.return_value = np.zeros(16000, dtype=np.float32)
|
|||
|
|
app.inserter = MagicMock()
|
|||
|
|
app.inserter.insert = AsyncMock()
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
asyncio.run(app.on_release())
|
|||
|
|
|
|||
|
|
calls = [c.args[0] for c in mock_tray.set_state.call_args_list]
|
|||
|
|
assert AppState.TRANSCRIBING in calls
|
|||
|
|
assert calls[-1] == AppState.WAITING
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 2: Tests ausführen — bestehende müssen scheitern**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest tests/test_main.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: FAIL auf alten Tests (fehlender `create_tray`-Mock) + FAIL auf neuen Tests.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 3: `__main__.py` aktualisieren**
|
|||
|
|
|
|||
|
|
`whisper_local/__main__.py` komplett ersetzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
"""Entry-Point für whisper-local."""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import logging
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
from whisper_local.config import Config, load_config
|
|||
|
|
from whisper_local.hotkey import create_listener
|
|||
|
|
from whisper_local.inserter import create_inserter
|
|||
|
|
from whisper_local.recorder import Recorder
|
|||
|
|
from whisper_local.transcriber import Transcriber
|
|||
|
|
from whisper_local.tray import AppState, create_tray
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class App:
|
|||
|
|
def __init__(self, config: Config | None = None):
|
|||
|
|
if config is None:
|
|||
|
|
config = load_config()
|
|||
|
|
|
|||
|
|
self._config = config
|
|||
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|||
|
|
self._hotkey_task: asyncio.Task | None = None
|
|||
|
|
|
|||
|
|
self.recorder = Recorder(
|
|||
|
|
sample_rate=config.sample_rate,
|
|||
|
|
channels=config.channels,
|
|||
|
|
min_duration=config.min_duration,
|
|||
|
|
device=config.microphone or None,
|
|||
|
|
)
|
|||
|
|
self.transcriber = Transcriber(
|
|||
|
|
model_name=config.whisper_model,
|
|||
|
|
compute_type=config.compute_type,
|
|||
|
|
language=config.language,
|
|||
|
|
)
|
|||
|
|
self.inserter = create_inserter()
|
|||
|
|
self.hotkey = create_listener(key_name=config.hotkey)
|
|||
|
|
self.hotkey.on_press = self.on_press
|
|||
|
|
self.hotkey.on_release = self.on_release
|
|||
|
|
self.tray = create_tray(on_settings=self._open_settings, on_quit=self._quit)
|
|||
|
|
|
|||
|
|
async def on_press(self) -> None:
|
|||
|
|
"""Callback: Hotkey gedrückt — Aufnahme starten."""
|
|||
|
|
logger.info("Aufnahme startet...")
|
|||
|
|
self.tray.set_state(AppState.RECORDING)
|
|||
|
|
self.recorder.start()
|
|||
|
|
|
|||
|
|
async def on_release(self) -> None:
|
|||
|
|
"""Callback: Hotkey losgelassen — Aufnahme stoppen, transkribieren, einfügen."""
|
|||
|
|
audio = self.recorder.stop()
|
|||
|
|
if audio is None:
|
|||
|
|
logger.info("Keine Audio-Daten, übersprungen")
|
|||
|
|
self.tray.set_state(AppState.WAITING)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
logger.info("Transkribiere...")
|
|||
|
|
self.tray.set_state(AppState.TRANSCRIBING)
|
|||
|
|
text = self.transcriber.transcribe(audio)
|
|||
|
|
if text:
|
|||
|
|
await self.inserter.insert(text)
|
|||
|
|
self.tray.set_state(AppState.WAITING)
|
|||
|
|
|
|||
|
|
def _quit(self) -> None:
|
|||
|
|
"""Beendet die Anwendung sauber."""
|
|||
|
|
if self._loop is not None:
|
|||
|
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
|||
|
|
|
|||
|
|
def _open_settings(self) -> None:
|
|||
|
|
"""Öffnet den Einstellungs-Dialog."""
|
|||
|
|
from whisper_local.tray._settings import SettingsDialog
|
|||
|
|
SettingsDialog(config=self._config, on_save=self._on_config_reload).open()
|
|||
|
|
|
|||
|
|
def _on_config_reload(self, new_config: Config) -> None:
|
|||
|
|
"""Übernimmt neue Konfiguration ohne App-Neustart."""
|
|||
|
|
self._config = new_config
|
|||
|
|
self.recorder = Recorder(
|
|||
|
|
sample_rate=new_config.sample_rate,
|
|||
|
|
channels=new_config.channels,
|
|||
|
|
min_duration=new_config.min_duration,
|
|||
|
|
device=new_config.microphone or None,
|
|||
|
|
)
|
|||
|
|
if self._loop is not None:
|
|||
|
|
asyncio.run_coroutine_threadsafe(
|
|||
|
|
self._restart_hotkey(new_config.hotkey), self._loop
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _restart_hotkey(self, key_name: str) -> None:
|
|||
|
|
"""Stoppt den alten Hotkey-Listener und startet einen neuen."""
|
|||
|
|
self.hotkey.stop()
|
|||
|
|
await asyncio.sleep(0.1)
|
|||
|
|
self.hotkey = create_listener(key_name=key_name)
|
|||
|
|
self.hotkey.on_press = self.on_press
|
|||
|
|
self.hotkey.on_release = self.on_release
|
|||
|
|
self._hotkey_task = asyncio.create_task(self.hotkey.listen())
|
|||
|
|
|
|||
|
|
async def run(self) -> None:
|
|||
|
|
"""Startet den Hauptloop."""
|
|||
|
|
self._loop = asyncio.get_running_loop()
|
|||
|
|
logger.info("whisper-local gestartet, warte auf Hotkey...")
|
|||
|
|
self.tray.start()
|
|||
|
|
self._hotkey_task = asyncio.create_task(self.hotkey.listen())
|
|||
|
|
await self._hotkey_task
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|||
|
|
)
|
|||
|
|
app = App()
|
|||
|
|
try:
|
|||
|
|
asyncio.run(app.run())
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
logger.info("Beendet")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 4: Alle Tests ausführen — müssen bestehen**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
uv run pytest -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Erwartet: alle PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Schritt 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add whisper_local/__main__.py tests/test_main.py
|
|||
|
|
git commit -m "feat: integrate tray icon, settings dialog, and config reload into App"
|
|||
|
|
```
|