Files
whisper-local/docs/superpowers/specs/2026-04-14-media-pause-during-recording-design.md
T

198 lines
6.7 KiB
Markdown
Raw Normal View History

# 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.