docs: Spec für Media-Pause während Aufnahme
Design-Dokument für automatisches Pausieren laufender MPRIS-Player während einer Whisper-Aufnahme, mit Resume nach Aufnahme-Stop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user