# Media-Pause während Aufnahme **Status:** Spec **Datum:** 2026-04-14 ## Ziel Während eine Whisper-Aufnahme läuft, soll eine laufende Medienwiedergabe (Musik, Video) automatisch pausiert werden. Nach Ende der Aufnahme soll dieselbe Wiedergabe wieder fortgesetzt werden. So muss der Nutzer nicht manuell Musik stoppen, bevor er das Hotkey drückt, und nicht gegen die eigene Wiedergabe anreden. ## Scope **In Scope (erste Iteration):** - Linux-Implementierung via MPRIS (D-Bus) - Cross-platform Protocol-Interface analog zu `listener`/`inserter` - Noop-Fallback für Windows und wenn das Feature deaktiviert ist - Opt-out-Konfiguration (Default an) über Tray-Settings und `config.toml` **Out of Scope:** - Windows-Implementierung (Protocol vorbereitet, kann später via `GlobalSystemMediaTransportControls` ergänzt werden) - PulseAudio/PipeWire-Sink-Input-Erkennung — MPRIS deckt Browser-Video, Spotify, VLC, mpv ab, mehr Abdeckung lohnt den Aufwand nicht - Per-Player Allow-/Blocklist — alle MPRIS-Player im Status `Playing` werden pausiert - Warnungen über nicht-MPRIS-Audioquellen ## Architektur Neues Modul `whisper_local/media/` analog zur bestehenden Factory-Dispatch-Struktur: ``` whisper_local/media/ __init__.py # Protocol + create_media_controller() _mpris.py # Linux-Impl via dbus-next _noop.py # Plattform- und Opt-out-Fallback ``` ### Protocol ```python class MediaController(Protocol): async def pause(self) -> None: """Snapshot + Pause aller aktuell abspielenden MPRIS-Player.""" async def resume(self) -> None: """Fortsetzen nur der Player, die pause() selbst angehalten hat.""" ``` ### Factory `create_media_controller(enabled: bool) -> MediaController` - `enabled == False` → `NoopController` - `sys.platform == "linux"` → `MprisController` - sonst → `NoopController` Das `enabled`-Flag kommt aus der Konfiguration. Die Factory wird so aufgerufen wie `create_listener()` / `create_inserter()` heute. ## Kern-Logik (MPRIS / Linux) ### `pause()` 1. D-Bus-Session verbinden (lazy, beim ersten Aufruf). 2. Über `org.freedesktop.DBus.ListNames` alle Namen auflisten, nach Präfix `org.mpris.MediaPlayer2.` filtern. 3. Für jeden Player parallel (`asyncio.gather`) die Property `PlaybackStatus` von `org.mpris.MediaPlayer2.Player` lesen. 4. Alle Player mit Status `"Playing"` in `self._paused: list[str]` (Bus-Namen) speichern und auf ihnen `Pause()` aufrufen — parallel. 5. Fehler pro Player werden geloggt, brechen die Gesamtaktion nicht ab. Ein Player, der zwischen ListNames und Pause verschwindet, zählt als nicht pausiert (Bus-Name wird **nicht** in `self._paused` aufgenommen). ### `resume()` 1. Für jeden Bus-Namen in `self._paused` parallel `Play()` aufrufen. 2. Fehler pro Player loggen und ignorieren (Player kann seit `pause()` verschwunden sein — Tab zu, App beendet). 3. `self._paused` leeren. ### State-Isolation Nur Player anfassen, die `pause()` selbst in den `Paused`-Zustand versetzt hat. Ein Player, der vor Aufnahmebeginn bereits `Paused` war, bleibt unangetastet — sowohl bei `pause()` als auch bei `resume()`. ## Integration in die Aufnahme-Pipeline Im Aufnahme-Callback-Pfad (Hotkey-Handler): ``` on_press → await media.pause() → recorder.start() on_release → audio = recorder.stop() → await media.resume() → text = transcriber.transcribe(audio) → inserter.insert(text) ``` `resume()` wird **vor** der Transkription aufgerufen. So läuft die Musik weiter, während Whisper rechnet — das kann mehrere Sekunden dauern und soll nicht als Wiedergabe-Hänger spürbar sein. ## Fehlerbehandlung - Kein D-Bus erreichbar (z. B. Headless, keine User-Session) → `MprisController` loggt einmalig eine Warnung, `pause()` und `resume()` verhalten sich wie No-Ops. Aufnahme läuft normal weiter. - Kein Player läuft → `pause()` ist wirkungslos, `resume()` ebenfalls. - Einzelner D-Bus-Call schlägt fehl → Fehler loggen, anderen Player nicht blockieren (`asyncio.gather(..., return_exceptions=True)`). - `resume()` wird immer aufgerufen, auch wenn `pause()` geworfen hat oder die Aufnahme keine gültigen Audiodaten lieferte (`stop()` liefert `None`). Aufrufer-Pflicht: `try/finally`. ## Konfiguration **`config.toml`:** ```toml [media] pause_during_recording = true ``` Default: `true`. Wird in [config.py](whisper_local/config.py) als neues Feld ergänzt. **Tray-Settings:** Neue Checkbox „Medienwiedergabe während Aufnahme pausieren" in [_settings.py](whisper_local/tray/_settings.py), konsistent zu bestehenden Toggles. Änderung wird beim Speichern in die Config geschrieben und wirkt ab der nächsten Aufnahme (Factory wird pro Aufnahme oder beim Config-Reload neu konsultiert — entsprechend dem bestehenden Muster für andere Toggles). ## Dependencies `dbus-next` als Linux-only optionale Abhängigkeit in [pyproject.toml](pyproject.toml): ```toml dependencies = [ ..., 'dbus-next ; sys_platform == "linux"', ] ``` Begründung: pure Python, async-nativ (passt zu den bestehenden `asyncio`-Callbacks), keine Subprocess-Latenz, keine zusätzliche System-Dependency wie `playerctl`. ## Tests **`tests/test_media_mpris.py`** (`@pytest.mark.skipif(sys.platform != "linux", ...)`): - D-Bus-Calls mit `unittest.mock.AsyncMock` / `MagicMock` mocken — kein echter D-Bus-Zugriff im Test. - Szenarien: 1. Keine MPRIS-Player vorhanden → `pause()` ist No-Op, `resume()` ebenfalls, kein Fehler. 2. Zwei Player im Status `Playing` → beide erhalten `Pause()`, nach `resume()` beide `Play()`. 3. Ein Player im Status `Playing`, einer im Status `Paused` → nur der Playing-Player wird pausiert; nach `resume()` wird nur dieser wieder gestartet, der andere bleibt pausiert. 4. Ein Player verschwindet zwischen `pause()` und `resume()` → kein Crash, Fehler wird geloggt, andere Player werden korrekt wieder gestartet. 5. D-Bus ist nicht erreichbar → `MprisController` loggt Warnung, Aufrufe sind No-Ops. **`tests/test_media_factory.py`:** - `sys.platform == "linux"` + `enabled=True` → `MprisController`. - `enabled=False` (jede Plattform) → `NoopController`. - `sys.platform != "linux"` → `NoopController`. Async-Tests nutzen `pytest-asyncio` wie im Projekt üblich. ## Offene Fragen / bewusst verschoben - Windows-Implementierung wird bei Bedarf als separate Iteration gebaut. Das Protocol und die Factory sind so ausgelegt, dass nur eine neue `_smtc.py` hinzugefügt und die Factory-Dispatch erweitert werden muss. - Detail-Anpassungen an der Tray-UI (Gruppierung, Reihenfolge der Checkboxen) werden beim Einbau entschieden, da sie vom aktuellen Stand des Settings-Dialogs abhängen.