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>
6.7 KiB
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
GlobalSystemMediaTransportControlsergä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
Playingwerden 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
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→NoopControllersys.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()
- D-Bus-Session verbinden (lazy, beim ersten Aufruf).
- Über
org.freedesktop.DBus.ListNamesalle Namen auflisten, nach Präfixorg.mpris.MediaPlayer2.filtern. - Für jeden Player parallel (
asyncio.gather) die PropertyPlaybackStatusvonorg.mpris.MediaPlayer2.Playerlesen. - Alle Player mit Status
"Playing"inself._paused: list[str](Bus-Namen) speichern und auf ihnenPause()aufrufen — parallel. - 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._pausedaufgenommen).
resume()
- Für jeden Bus-Namen in
self._pausedparallelPlay()aufrufen. - Fehler pro Player loggen und ignorieren (Player kann seit
pause()verschwunden sein — Tab zu, App beendet). self._pausedleeren.
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) →
MprisControllerloggt einmalig eine Warnung,pause()undresume()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 wennpause()geworfen hat oder die Aufnahme keine gültigen Audiodaten lieferte (stop()liefertNone). Aufrufer-Pflicht:try/finally.
Konfiguration
config.toml:
[media]
pause_during_recording = true
Default: true. Wird in config.py als
neues Feld ergänzt.
Tray-Settings:
Neue Checkbox „Medienwiedergabe während Aufnahme pausieren" in _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:
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/MagicMockmocken — kein echter D-Bus-Zugriff im Test. - Szenarien:
- Keine MPRIS-Player vorhanden →
pause()ist No-Op,resume()ebenfalls, kein Fehler. - Zwei Player im Status
Playing→ beide erhaltenPause(), nachresume()beidePlay(). - Ein Player im Status
Playing, einer im StatusPaused→ nur der Playing-Player wird pausiert; nachresume()wird nur dieser wieder gestartet, der andere bleibt pausiert. - Ein Player verschwindet zwischen
pause()undresume()→ kein Crash, Fehler wird geloggt, andere Player werden korrekt wieder gestartet. - D-Bus ist nicht erreichbar →
MprisControllerloggt Warnung, Aufrufe sind No-Ops.
- Keine MPRIS-Player vorhanden →
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.pyhinzugefü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.