f981bbcec5
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>
198 lines
6.7 KiB
Markdown
198 lines
6.7 KiB
Markdown
# 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.
|