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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,54 @@
|
||||
## Done
|
||||
|
||||
### Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1)
|
||||
|
||||
The long-running "LinTunes plays by itself" haunting, finally attributed and
|
||||
fixed. Five-plus incidents since July, always the same shape: a few seconds of
|
||||
audio from the middle of a song, at some random hour, hours after the app was
|
||||
last touched, never recording a play count.
|
||||
|
||||
It was never a control event. The v0.1.4 provenance log had **zero** entries
|
||||
between the 17:48 pause and the 00:30 incident — no MPRIS, no media key, no
|
||||
AVRCP ghost — while MPRIS still reported `Paused` at exactly `6974000µs`, the
|
||||
same position it was paused at seven hours earlier. Meanwhile PipeWire showed
|
||||
LinTunes owning a stream in state `running`, linked to the DAC, gain 1.0.
|
||||
|
||||
Cause: a paused (or stopped) `QMediaPlayer` on the FFmpeg backend keeps its
|
||||
PipeWire stream open and never corks or drains it, so the few seconds the
|
||||
backend had decoded ahead sit there live. When the audio graph is later rewired
|
||||
— a USB DAC waking from idle suspend, a device appearing — that stale buffer
|
||||
flushes to the speakers. It explains every observation: always a short burst
|
||||
(it can only be what was buffered), always mid-song, never a play count (nothing
|
||||
resumed), and why the Fedora machine never does it (no USB DAC to wake up).
|
||||
Reproduced standalone on Qt 6.8.2 / FFmpeg 7.1.5 in ~20 lines.
|
||||
|
||||
- [x] `PARK_AFTER_IDLE_S` (30s) + `LocalSink._park()` / `_unpark()`. After that
|
||||
long idle, release the pipeline: `stop()` then `setSource(QUrl())`, which
|
||||
is verified to remove the PipeWire node outright — there is no buffer left
|
||||
that could ever leak. Resume rebuilds the source and rides the existing
|
||||
custom-start-time machinery to seek back.
|
||||
- [x] Two independent layers, deliberately: gain goes to 0 *before* the
|
||||
teardown, so anything still buffered downstream drains silently even if
|
||||
the release misbehaves. `_apply_volume()` forces silence while parked, so
|
||||
the volume slider can't re-arm it.
|
||||
- [x] `stop()` arms the brake too — Qt's `stop()` leaves the source loaded, and
|
||||
`Player._advance()` calls it when a playlist runs out, so "playlist ended
|
||||
at 1am" was a second, equally real leak path.
|
||||
- [x] Parked state serves `position_ms()`/`duration_ms()` from a cache and
|
||||
suppresses the media object's position/duration/state/status signals, so
|
||||
the teardown never reaches the UI as a jump to 0:00 and a stray
|
||||
`EndOfMedia` can't advance the queue.
|
||||
- [x] `seek()` while parked moves the resume point instead of poking a dead
|
||||
pipeline; `load()` discards parked state; `shutdown()` disarms the timer.
|
||||
- [x] Verified end-to-end against the real `LocalSink` (silent WAV, zero gain,
|
||||
watched via `pw-dump`): stream present while playing, still present right
|
||||
after pause, **gone** once parked, rebuilt on resume with position and
|
||||
duration intact. Tests in `tests/test_round34.py` (17).
|
||||
|
||||
Note: this is a workaround for an upstream Qt Multimedia bug, not a LinTunes
|
||||
design flaw — GStreamer-based players get correct pause behavior for free by
|
||||
corking. See the backend note under "Parked / deferred".
|
||||
|
||||
### Round 33 (2026-08-14) — Delete songs from the library (v0.6.0)
|
||||
|
||||
An iTunes 12 habit LinTunes had no answer for: getting rid of a song you don't
|
||||
|
||||
Reference in New Issue
Block a user