Files
whisper-local/docs/superpowers/plans/2026-04-10-tray-icon.md
T

1466 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```