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:
@@ -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