diff --git a/docs/superpowers/specs/2026-04-14-media-pause-during-recording-design.md b/docs/superpowers/specs/2026-04-14-media-pause-during-recording-design.md new file mode 100644 index 0000000..3614302 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-media-pause-during-recording-design.md @@ -0,0 +1,197 @@ +# 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.