v0.6.1: the parking brake — stop playing audio at 3am

A paused or stopped QMediaPlayer on Qt's FFmpeg backend keeps its PipeWire
stream open and never corks or drains it, so the few seconds the backend
decoded ahead sit there live. A later audio-graph change — a USB DAC waking
from idle suspend, a device appearing — flushes that stale buffer to the
speakers, hours after the app was last touched.

That is the "LinTunes plays by itself" haunting: the v0.1.4 provenance log
recorded zero control events between the 17:48 pause and the 00:30 incident,
while MPRIS still reported Paused at exactly 6974000us — the position it was
paused at seven hours earlier — and PipeWire showed the stream state=running.
It explains the whole shape of it: always a short burst (only what was
buffered), always mid-song, never a recorded play count.

After 30s idle, release the pipeline: stop() then setSource(QUrl()), which
removes the PipeWire node outright, so no buffer survives to leak. Gain drops
to zero first as an independent second layer. stop() arms the brake too — Qt
leaves the source loaded there, and _advance() takes that path when a playlist
runs out. Resume rebuilds the source and seeks back via the existing
custom-start-time machinery.

Verified end-to-end against the real LocalSink with a silent WAV, watched
through pw-dump: present while playing, still present right after pause, gone
once parked, rebuilt on resume with position and duration intact.

This is a workaround for an upstream Qt Multimedia bug; TASKS.md now carries
the assessment for moving the local sink to GStreamer, which gets correct
pause behavior for free and would unlock the parked equalizer and gapless.
This commit is contained in:
2026-08-17 14:01:36 -04:00
parent c0455c7f75
commit 72994a6a6a
5 changed files with 395 additions and 12 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.6.0"
__version__ = "0.6.1"
+124 -11
View File
@@ -2,7 +2,7 @@ import random
import time
from pathlib import Path
from PyQt6.QtCore import QObject, QUrl, pyqtSignal
from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal
from PyQt6.QtMultimedia import (
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices, QAudio)
@@ -19,6 +19,23 @@ RESTART_THRESHOLD_MS = 3000
# audible until a seek re-primes the sink. Seen on trav's Debian 13 machine.
RESUME_NUDGE_THRESHOLD_S = 30
# The "parking brake". A merely paused/stopped QMediaPlayer keeps its PipeWire
# stream open and RUNNING, still holding the few seconds of audio the FFmpeg
# backend had decoded ahead. Nothing drains or corks it, so when the audio
# graph is later rewired — a USB DAC waking from idle suspend, a device
# appearing — that stale buffer flushes to the speakers: LinTunes "plays by
# itself" for ~4s, mid-song, hours after it was paused, and never records a
# play count. Diagnosed 2026-08-15 (the app sat Paused at 6974ms for 7h while
# PipeWire reported its stream state=running) and reproduced standalone on
# Qt 6.8.2 / FFmpeg 7.1.5.
#
# Qt owns that stream, so the fix has to be ours: once playback has been
# idle this long, tear the pipeline down and rebuild it on resume. Releasing
# the source is verified to remove the PipeWire node outright, so there is no
# buffer left to leak. Players built on GStreamer get this for free by corking
# on pause — if we ever move to a GStreamer sink, all of this goes away.
PARK_AFTER_IDLE_S = 30
def make_shuffle_order(count: int, start_index: int) -> list[int]:
"""A random permutation of range(count) that begins at start_index."""
@@ -113,6 +130,17 @@ class LocalSink(PlaybackSink):
self._pending_start_url = QUrl()
self._paused_at: float | None = None # monotonic time of last pause
self._shutdown_done = False
# Parking-brake state (see PARK_AFTER_IDLE_S). While parked there is no
# pipeline at all, so position/duration are served from these caches
# and the media object's own signals are suppressed.
self._parked = False
self._parked_url = QUrl()
self._parked_position_ms = 0
self._parked_duration_ms = 0
self._park_timer = QTimer(self)
self._park_timer.setSingleShot(True)
self._park_timer.setInterval(int(PARK_AFTER_IDLE_S * 1000))
self._park_timer.timeout.connect(self._park)
self._audio = QAudioOutput(self)
self._media = QMediaPlayer(self)
@@ -128,14 +156,19 @@ class LocalSink(PlaybackSink):
self._media_devices = QMediaDevices(self)
self._media_devices.audioOutputsChanged.connect(
self._on_audio_outputs_changed)
self._media.positionChanged.connect(self.position_changed)
self._media.durationChanged.connect(self.duration_changed)
self._media.positionChanged.connect(self._on_position_changed)
self._media.durationChanged.connect(self._on_duration_changed)
self._media.playbackStateChanged.connect(self._on_state_changed)
self._media.mediaStatusChanged.connect(self._on_media_status)
self._media.errorOccurred.connect(self._on_error)
def load(self, track, autoplay: bool, start_ms: int):
start_ms = max(0, start_ms)
# A new track supersedes anything parked; the source below rebuilds the
# pipeline anyway, and the cached position belongs to the old track.
self._park_timer.stop()
self._parked = False
self._parked_url = QUrl()
url = QUrl.fromLocalFile(track.location)
if self._media.source() == url:
# setSource() no-ops on an unchanged URL, so LoadedMedia never
@@ -161,32 +194,49 @@ class LocalSink(PlaybackSink):
self._media.play()
def play(self):
self._apply_volume()
if (self._paused_at is not None
self._park_timer.stop()
if self._parked:
self._unpark() # rebuilds the pipeline and re-arms the seek
elif (self._paused_at is not None
and time.monotonic() - self._paused_at
>= RESUME_NUDGE_THRESHOLD_S):
# Re-prime a possibly idle-suspended sink (the manual workaround
# was "rewind slightly"); seek in place so the listener doesn't
# lose their spot.
# lose their spot. Rarely reached now that a pause this long parks
# instead — a rebuilt pipeline can't be idle-suspended-dead — but
# kept as the fallback for a park that didn't happen.
self._media.setPosition(self._media.position())
self._apply_volume()
self._paused_at = None
self._media.play()
def pause(self):
self._paused_at = time.monotonic()
self._media.pause()
self._park_timer.start()
def stop(self):
# Also parked: Qt's stop() leaves the source loaded, so the stream
# survives here too — this is the path _advance() takes when a playlist
# runs out, which would otherwise sit loaded all night.
self._media.stop()
self._park_timer.start()
def seek(self, position_ms: int):
self._media.setPosition(max(0, position_ms))
position_ms = max(0, position_ms)
if self._parked:
# No pipeline to seek; move the resume point instead and keep the
# scrubber honest.
self._parked_position_ms = position_ms
self.position_changed.emit(position_ms)
return
self._media.setPosition(position_ms)
def position_ms(self) -> int:
return self._media.position()
return self._parked_position_ms if self._parked else self._media.position()
def duration_ms(self) -> int:
return self._media.duration()
return self._parked_duration_ms if self._parked else self._media.duration()
def is_playing(self) -> bool:
return self._media.playbackState() == QMediaPlayer.PlaybackState.PlayingState
@@ -203,13 +253,71 @@ class LocalSink(PlaybackSink):
"""Push the stored logical volume to the sink. Called again on resume
and after device swaps: some backends (seen with Bluetooth sinks)
re-create the sink with a stale/zero gain, which played silently until
a seek re-primed it."""
a seek re-primed it. Forced to silence while parked, so moving the
volume slider can't re-arm a buffer the teardown failed to drop."""
level = 0.0 if self._parked else self._volume
self._audio.setVolume(QAudio.convertVolume(
self._volume,
level,
QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale,
))
# ---- the parking brake ----
def _park(self):
"""Release the audio pipeline after a long idle (see PARK_AFTER_IDLE_S).
Two independent layers, because this is guarding someone's sleep:
the gain goes to zero first (so anything already buffered downstream
drains silently even if the teardown misbehaves), then the source is
released, which removes the PipeWire stream outright — verified on
Qt 6.8.2 — leaving nothing that could ever flush to the speakers.
"""
if self._parked or self._shutdown_done or self.is_playing():
return
if self._media.source().isEmpty():
return # nothing loaded; nothing to release
self._parked_url = self._media.source()
self._parked_position_ms = self._media.position()
self._parked_duration_ms = self._media.duration()
# Set before teardown so the stop/clear churn below can't reach the UI
# as a jump to 0:00, and so _apply_volume() forces silence.
self._parked = True
self._apply_volume()
self._media.stop()
self._media.setSource(QUrl()) # this is what drops the stream
log_control("player", "park",
f"released the audio pipeline at "
f"{self._parked_position_ms}ms")
def _unpark(self):
"""Rebuild the pipeline parked by _park() and resume where we left off.
The seek rides the existing custom-start-time machinery: arming
_pending_start_* makes _on_media_status() jump there once the media is
loaded, which is the only point it is reliably seekable.
"""
url = self._parked_url
start = self._parked_position_ms
self._parked = False
self._parked_url = QUrl()
self._apply_volume() # back to the real gain
if url.isEmpty():
return
self._pending_start_ms = start
self._pending_start_url = url
self._media.setSource(url)
log_control("player", "unpark",
f"rebuilt the audio pipeline at {start}ms")
def _on_position_changed(self, position):
if not self._parked:
self.position_changed.emit(position)
def _on_duration_changed(self, duration):
if not self._parked:
self.duration_changed.emit(duration)
def shutdown(self):
"""Tear down the Qt Multimedia pipeline in a safe order before Qt
destroys the objects. Without this, QObject children are destroyed in
@@ -219,6 +327,7 @@ class LocalSink(PlaybackSink):
if self._shutdown_done:
return
self._shutdown_done = True
self._park_timer.stop()
try:
self._media_devices.audioOutputsChanged.disconnect(
self._on_audio_outputs_changed)
@@ -244,9 +353,13 @@ class LocalSink(PlaybackSink):
self._media.play()
def _on_state_changed(self, state):
if self._parked:
return # the teardown's own Stopped, not a user-visible change
self.state_changed.emit(state == QMediaPlayer.PlaybackState.PlayingState)
def _on_media_status(self, status):
if self._parked:
return # statuses from tearing the pipeline down
if (status == QMediaPlayer.MediaStatus.LoadedMedia
and self._pending_start_ms
and self._media.source() == self._pending_start_url):