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

44 KiB
Raw Blame History

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:

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.pyConfig-Dataclass und load_config und neue Funktion save_config:

@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:

    if "device" in audio_section:
        config.microphone = audio_section["device"]

Neue Funktion am Ende der Datei:

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
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:

    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:

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:

        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
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:

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:

@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:

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.pystop()-Stub für Protocol-Konformität:

    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
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):

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
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:

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:

"""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:

"""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
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.pyWin32TrayApp + 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:

@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:

"""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
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:

@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:

"""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
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:

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

"""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
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:

@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.pySettingsDialog-Klasse am Ende der Datei ergänzen:

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
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:

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:

"""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
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:

    @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:

    @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:

"""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
git add whisper_local/__main__.py tests/test_main.py
git commit -m "feat: integrate tray icon, settings dialog, and config reload into App"