Start gain resolves from the loudness cache, then from ReplayGain tags in
the bytes; the catalog's own ReplayGain is fetched once per track alongside
the CMAF setup (and the cached replays, the disk replay and the gapless
download), and a prefetched track the catalog cannot describe is
pre-analysed in full (EBU R128 + true peak) off the audio thread, reading
the bytes back from the playback cache since the prefetch streams them
straight in. DynamicAmplify now starts AT that gain instead of ramping from
unity, the published normalization gain is the real one, and the four
seeding sites share one resolver (normalization::plan_start_gain).
cc
Volume restore (desktop session and qbzd driver), a playback-thread panic
at stop, and a credentials decrypt failure (now naming the file and the
likely cause, without the inert legacy retry).
cc
The streaming sources unwrap NonZero::new on these values on the audio
thread; the decoded path already guarded both, the codec-params path did
not.
cc
classify_reqwest formatted the raw reqwest error, whose Display embeds the
request URL with its hmac= token; the string is logged on every retry and
in the player's CMAF-failed warning. It now uses the URL-free helper the
legacy stream path already had, moved to qbz-qobuz::net_diag so both paths
share it. The log redaction layer additionally scrubs hmac=, Signature=,
Policy= and Key-Pair-Id= values.
cc
LoudnessCache::new failing (full disk, read-only profile, corrupt
loudness_cache.db) used to panic inside the audio thread, which is created
once in Player:🆕 playback was dead until restart while the log promised
the opposite. The cache now degrades to an in-memory session cache, then to
a disabled cache, and normalization keeps running.
cc
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.
Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.
Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.
cx
Split the manual insertion block into two tiers so "Play later" no longer
behaves identically to "Play next":
- add a `manual_later_count` to InternalState tracking the trailing
"Play later" sub-block: [now][play-next block][play-later block][source].
- play-next inserts at the FRONT of the manual block; play-later appends at
its END (after any earlier play-later tracks), in order.
- fix the idle + shuffle case: with no current track, both actions anchor at
base 0 instead of the end of shuffle_order, so an idle-built queue plays
play-next first and play-later right after, and does not collapse the rest
of the queue on the first transport play.
- next()/remove_at()/jump decrement drain the play-next tier before the
play-later tier; set_queue/clear/shuffle-enable dissolve both.
- the public QueueState.manual_next_count exposes mnc+mlc so both tiers render
inside the single "Next in queue" visual container.
NorinB's shuffle-randomization report keeps a debug log in next() to capture
a repro if it recurs; the temporary INFO instrumentation is lowered to debug.
MixView Q-mixes reach transport parity with playlists: functional multiselect
(SelectionModel + multi-select bar), header queue menu (play next / later /
add to queue via bulkTracksAction), and create-playlist-from-selection.
Queue engine covered by 86 passing tests (play-later ordering non-shuffle,
under shuffle, and idle-shuffle cases added).
cc
cpal 0.17's ALSA worker reports `alsa::poll() returned POLLERR` and polls
again immediately, without snd_pcm_recover: a PCM left in XRUN/SUSPENDED/
DISCONNECTED (CPU starvation at a track change on a Pi, a suspend, a pulled
cable) becomes a tight loop that never plays again until the stream is
dropped. rodio's default callback eprintln!s every one of them, which is the
POLLERR flood in the issue. The System default and PipeWire routes installed
no error callback at all, and nothing rebuilt a wedged stream.
- New qbz-audio::stream_health: classify (Wedged vs Transient), a flood
limiter (one log line per 5s window with a suppressed count), a wedge
latch, and one shared error_callback installed on every CPAL route
(System default x4, PipeWire, ALSA) and the legacy player path.
- The audio thread checks the wedge latch at the TOP of its loop, every
iteration, not in the recv_timeout Timeout arm: under QConnect the
renderer-state command flow keeps recv returning Ok(...) and the Timeout
arm barely runs, which left a wedged worker spinning a core for ~22s.
On a wedge it drops the stream (joining cpal's worker), and when playing
rebuilds and resumes at the current position; a second wedge within a 10s
backoff releases the device and stops instead of looping.
Bit-perfect paths (ALSA Direct, DSD, WASAPI) are untouched; they never use
cpal's worker. The low-memory profile from 2.1.0 already addressed the exit
137 OOM; this is the separate flood/wedge half.
Verified on a Pi 4B + HiFiBerry DAC+ over the System default route with a
192 kHz 835 MB track and SIGSTOP-induced wedges: rebuild+resume in ~40 ms,
auto-recovery after the backoff, release+stop on a repeat wedge, and 7
POLLERR log lines instead of a flood. qbz-audio 125 and qbz-player 139 tests
pass; new stream_health and wedge_recovery tests included.
cc
Keep queue edits from resetting the playback clock, preserve Play next and Play later batches, and report observed renderer state and owned output telemetry. Guard duplicate-track gapless transitions by queue occurrence.
cx
Preserve the remaining queue when shuffle is disabled, expose clear transport state tooltips, retain Library source filters for the session, and add independent track-artwork preferences for Queue and Library.
cx
Add random access for DSF, DFF, and SACD demuxers, replace live DoP/native sources without releasing ALSA, and expose seek through Qt, MPRIS, and the daemon API.
cx
rodio's Decoder::new leaves byte_len unset and the source non-seekable, so
symphonia's MP3 demuxer never estimates the duration of a file without a
Xing/Info/VBRI frame. The local play path then fell back to duration 0 and
the playback clock stayed clamped at 0 for the whole track, which the Slint
2.0.2 frontend read as a stuck load and restarted every 45 s (#734).
The decoder is now built with the buffer length, and an unknown (0)
duration no longer clamps the position. Regression fixture: a 2 s CBR MP3
written without an Info tag.
cc
InMemorySource::from_shared decodes an Arc<Vec<u8>> without copying and
MediaServer::register_audio_shared serves the same Arc, so the cast media
server and the cast visualizer's shadow decoder can sit on one copy of a
100 MB track.
cc
Player::open_external_stream starts the CMAF download and returns a
shared buffer (exact final FLAC size known from the segment table) once
the first segments are in, instead of after the whole file. The finished
download is cached like a played stream. Core exposes it as
open_external_stream_resolved.
cc
Probe exact mixer element identities, retain stable device routes, and fail closed for ambiguous or switch-only controls. Synchronize player volume through ALSA events without changing direct PCM configuration.
cx
aarch64 runner: cxx-qt-build reported QtMissing with a correct
QT_ROOT_DIR — set QMAKE/CMAKE_PREFIX_PATH explicitly (as macOS/Windows
already do) and print the qmake version before the build.
macOS: StreamType::output_sample_rate (macOS-only) matched Rodio only;
Direct is intentionally not cfg-gated, so the macOS build failed with
E0004. A direct sink answers with its opened rate.
cc
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.
Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).
cc
The Windows counterpart of alsa_backend::get_device_supported_rates. It exists
because the answer cannot be inferred: measured on the box, the owner's DAC
accepts 44100/48000/88200/96000/192000 in exclusive mode and REFUSES 176400 --
the 44.1 family stops at 88.2 while the 48 family reaches 192 -- while a second
endpoint on the same machine does accept 176400. No rule about higher rates
implying lower ones survives that, and 176400 is the DSD64 DoP carrier.
Exclusive is the only question worth asking. IsFormatSupported(SHARED) answers
about the mixer, which resamples and therefore says yes to nearly everything.
"I could not ask" is not "it supports nothing". A busy endpoint, a failed
activation and a device that genuinely accepts nothing all produce an empty
list, and the first draft cached that as authoritative -- which would have
outlived its cause, because a stream CLOSING raises no device notification.
Only a real answer is stored.
The DoP guard is now FAIL-CLOSED on Windows, and the fix turned out simpler
than probing: the DoP handler is cfg(target_os = "linux") and answers "DoP
playback is Linux-only" with a stream error, so dispatching there can only
fail. Converting to PCM is the only outcome that plays the track. It matters
that this is closed rather than open: an undecoded DoP stream is read as PCM
carrying packed DSD and marker bytes, which comes out as very loud broadband
noise. An earlier draft fell back to "yes" whenever the probe could not answer
-- preserving exactly the guess this work existed to remove.
The hotplug callbacks run on a thread the Windows audio system owns, where the
documented rule is to return immediately. Each does ONE lock-free atomic
increment and nothing else: no mutex, and no logging either, since a log macro
can allocate, take its own lock and perform synchronous output. Invalidation is
a generation counter rather than per-endpoint removal, which cannot block,
cannot lose an event, and matches the real granularity -- a device arriving
changes exclusive contention for endpoints other than itself. The generation is
read BEFORE a probe and stored with the result, so an event landing mid-sweep
expires that answer instead of being overwritten by it.
One IAudioClient serves every probe: IsFormatSupported may be called any time
after activation and neither initialises nor reserves the endpoint, so a client
per probe was forty activations buying nothing. A sweep gate coalesces
concurrent misses -- startup enumerates from two independent places and the log
showed every endpoint measured exactly twice. Measured after: four sweeps for
four endpoints, 34 ms for the lot.
is_supported, deliberately not is_supported_exclusive_with_quirks: the sweep
must predict what the OPEN path does, and that path asks the plain question. A
rate accepted only through a quirk the opener never tries would appear in the
UI and then fail to open.
cc
When the Direct arm moved from a concrete AlsaDirectStream to
Arc<dyn DirectSink>, the set_hardware_volume call kept its
cfg(target_os = "linux") gate, so on every other platform the setting
silently did nothing.
Added as a second arm rather than by removing the Linux one, per the
protected-code rule. Linux keeps its verbatim block and its behaviour;
a Windows sink can now answer for itself, and one with no hardware volume
returns Err and says so instead of the call never happening. macOS and the
remaining targets keep their previous silence, since CoreAudio has its own
path through set_coreaudio_hardware_volume.
Latent rather than live: the row is visible only on ALSA.
cc
AudioBackendType::WasapiExclusive, offered on Windows, plus the selection arm
that opens the exclusive stream before the shared one - the same shape ALSA
Direct has on Linux.
No separate enumeration was needed: 249adc77b already made AudioDevice.id the
WASAPI ENDPOINT ID, which is exactly what WasapiDirectStream::new wants. That
fix turns out to have been the load-bearing one; by friendly name this could
not have addressed a device at all.
Two deliberate fallbacks, both of which prefer a resampled track to silence:
- No device chosen: there is nothing to open exclusively, so it says so and
uses the shared stream. Grabbing the default DAC would be deciding for the
user.
- The endpoint refuses the rate: warn and fall through. A DAC that will not
take 192/24 in exclusive still plays it shared.
The mode LED lights EXCL only when a device IS chosen, because with none the
player falls through to shared - a lit EXCL there would be the LED lying,
which is the one thing output_labels must never do.
backendIsWasapi joins the settings document, which is what unhides the
Exclusive-mode and DSD rows that fc0d35e29 gated on it.
229 + 71 + 110 tests pass; five QML audits pass.
cc
PlaybackEngine::AlsaDirect and StreamType::AlsaDirect become Direct, carrying
Arc<dyn DirectSink> instead of Arc<AlsaDirectStream>. Eleven match arms and
seven sites keep working untouched because they only ever called trait
methods; duplicating them per platform would have been the alternative.
CORRECTION to e61cd7bd8's message: it claimed the ALSA path still
monomorphises to static dispatch. That was true when written and is NOT true
now - the payload is a trait object, so those calls are virtual. Measured
where it lands: write_f32 runs once per 8192-frame chunk, i.e. 5.4 indirect
calls/second at 44.1 kHz and 23.4 at 192 kHz, each immediately entering
blocking device I/O. channels() once at construction, sample_rate/stop/drain
at boundaries. Nothing in the sample path.
Two things Windows CANNOT compile-check, found by reading rather than by the
compiler, and both would have broken the Linux build:
- Four cfg(linux) arms passed a matched Arc<dyn DirectSink> to
new_alsa_direct, which takes the concrete type. Arc unsizes one way only.
- set_hardware_volume is called inside a cfg(linux) block and was not on the
trait. It is now, with a default that REFUSES rather than silently
succeeding, overridden by ALSA on Linux only - a sink with no mixer must
not pretend it moved the volume.
StreamType::Direct is also no longer cfg(linux). Gating it was what stopped
this compiler from checking any arm that handles it, which is the exact blind
spot this port has to work around; the variant now compiles everywhere and
Windows will refuse anything I get wrong in it.
The log prefix moved onto the sink (DirectSink::log_label) rather than being
passed in: four call sites hard-coding "ALSA Direct Engine" is four chances to
mislabel a WASAPI stream. play/pause/stop/set_volume use it too - previously
only the writer thread did, so a WASAPI stream would have logged ALSA
lifecycle lines.
Sol reviewed as the Linux compiler before this commit and found no Linux type
error; the stale references and hard-coded labels above are its findings.
cc
ALSA and WASAPI need byte-identical behaviour in the writer thread - same
chunking, same gapless hand-off, same drain - and two copies of 112 lines
drift. `qbz_audio::backend::DirectSink` names the five methods the engine
already called on AlsaDirectStream, both streams forward to their inherent
methods with no wrapping, and `alsa_writer_thread` becomes
`direct_writer_thread<S: DirectSink>` plus a log label.
Linux is unchanged: the ALSA call site passes its old label verbatim, so
every emitted string is identical, and `S` infers to AlsaDirectStream, so the
call monomorphises to the same static dispatch it had - no trait object, no
vtable in the audio path.
Sol reviewed this before the commit, per the rule added today, and found the
defect that mattered:
WasapiDirectStream::drain only SENT Ctl::Drain and returned immediately,
while the trait - and the ALSA drain it now shares a thread with - promises
to play the queue out first. Worse, the render thread EXITED after draining,
so a later append would have written into a dead thread. Gapless on Windows
would have been broken in a way no unit test reaches.
drain now blocks on a reply from the render thread, and the render thread
answers and KEEPS RUNNING. Proven with the example: drain returns Ok in 28 ms
rather than 0, and a write after it succeeds. Starvation is also counted as an
EVENT now rather than per period - an idle stream otherwise reported hundreds
of underruns for one real one.
Sol also caught two stale doc references to the old function name and a
"draining ALSA buffer" line that a WASAPI stream would have printed.
cc
Preflight fallible queue transitions and publish now-playing state only after the requested source is audible, leaving the prior stream intact when a file or server is unavailable.
Carry source-owned request headers through remote probes, streaming and fallback downloads without exposing sensitive values.
cx
Ignore empty Vorbis and Opus packets before Symphonia interleaving so a decoder edge case cannot panic the CPAL output thread and kill audio for the session.
cx
Analyze audible tracks into a progressive 512-bin RMS document, cache complete results, and preserve gapless track boundaries. Render the waveform through a scenegraph item in both player bars behind a default-off preference.\n\ncx
The sibling of patch_plex_quality, for the other thing a row can learn after it
was enqueued. A disc carries no artwork — it is fetched, and the fetch outlives
the click that started playback — so a CD queued at second two is still holding
artwork_url: None when the cover lands at second ten. Every consumer downstream
reads the QUEUE row (the now-playing bar, the miniplayer, MPRIS through
art_url_for), so patching the session store alone left all of them blank.
Rows that already have art are left alone: this fills a gap rather than
overwriting a cover somebody else resolved. It answers two questions
separately, because they drive different work — any patch makes the queue
panel's thumbnails stale, only the current one needs the now-playing stamp
re-pushed.
cc
The conversion thread relied on `push_chunk` returning `Err` to notice the
track had changed — the comment said so in as many words ("Reader gone (track
changed/stopped) — just stop"). That check could never fire: `push_chunk`
only extends a Vec and fails solely on a poisoned mutex, and `BufferState`
carries no reader-gone flag and no Drop.
So skipping a DSD track left the thread converting to the END of the file,
growing a buffer nothing would ever read. A 60-minute DSD64 album track is
~1.9 GB of 88.2 kHz/24-bit PCM held for nothing, on a box that hard-freezes
under memory pressure.
The fix reuses the contract that already exists for the CMAF feeder:
`register_stream_feeder` with the play generation, plus a cooperative check
once per converted block. Registration happens AFTER the spawn and carries
the generation the play was born under, so a supersede that lands in between
cancels the thread instead of registering it. Like the network feeder's own
cancel, it neither errors nor completes the buffer — the outgoing engine keeps
playing what is already buffered until the new source is installed.
Confirmed in a live session log, not inferred:
[CMAF-STREAM] Cancelling feeder for superseded track 5672
Player: DSD conversion for superseded track 5672 cancelled
cc
A mid-playback stream error (e.g. an ALSA buffer underrun/EPIPE) was
only visible through rodio's default error callback, which eprintln!s
and drops it: playback died with a bare PlaybackStateChanged(paused)
and no PlaybackError on the bus, so hook/SSE integrators (moOde) could
not tell a device hiccup from a user pause.
Install an error callback on the rodio output stream that records the
error on SharedState, where the playback driver already drains it into
the stream-error latch and the daemon bridges it to
CoreEvent::PlaybackError. Rate-limited to one recording per 5s since a
wedged ALSA device repeats EPIPE every period and each drained message
becomes one bus event (and one forked hook script).
Found while testing the moOde qbzevent.sh integration on-device: a
buffer underrun paused playback with no PlaybackError emitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.
Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.
Session persistence, both levels the port was missing:
- The startup-page preference is finally read. It had been rendering and
persisting a value nothing consumed. `last_view` is written in the SHARED
ui_prefs vocabulary, not this port's route ids -- four of the six differ,
and writing the raw ids would have silently broken the other frontend's
restore. `on_session_entered` recorded "home" unconditionally, which also
rewrote last_view on every boot, so the setting could not survive its own
startup.
- Queue and active track survive a restart. session_persist was tied to a
frontend by exactly one type alias, so it moved to qbz-app generic over the
adapter rather than being copied. Verified end to end: the snapshot restored
here had been written by the Slint binary, and the exit flush keeps an
un-consumed resume position instead of overwriting it with the engine's
idle 0.
Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.
Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.
cc
The segment feeder spawned by play_track kept downloading to completion
after the user switched tracks, holding the link: a shuffle&play pressed
mid-stream measured 5441 ms for the new track's init segment (vs
423-760 ms on an idle link) because the init's connect/TLS/TTFB queued
behind ~15 MB of in-flight segments of a track nobody was listening to.
begin_play (every superseding intent: play_*, stop) now cancels the
registered feeder cooperatively via a watch flag the feeder selects on;
the in-flight request is dropped immediately. The abandoned buffer is
deliberately NOT errored or completed at cancel time, so the outgoing
engine keeps playing what is already buffered until the audio thread
installs the new source; the buffer is sealed (complete) once the new
source is in place (or on stop) so a parked reader wakes to EOF instead
of hanging. Cancelled partial tracks are not cached.
km
The PlayStreaming arm was the only start path that did not clear
gapless_pending/gapless_request_armed on supersede (Play, PlayDsdDop,
PlayDsdNative, Stop and Seek all did). A pending that outlives its
engine both blocks gapless re-arming for the new track and fires
positionally at the new track's end, swapping current_track_id to a
track whose engine no longer exists — observed as the queue stopping
dead with a full queue. Also adds [gapless-trace] set/clear logging at
every lifecycle point to pin down the surviving X -> X self-transition.
cc
The system_capabilities profiler (Normal vs LowMemory, built for #331)
lost all its consumers in the Tauri removal; 2.0.2 shipped it as dead
code and qbzd got OOM-killed (exit 137) on 1 GB Pi hosts at track
transitions. Phase A wiring:
- Frontends read memory_profile() at startup and push it down via the
new player memory_tuning (initial-buffer cap, L1 budget, low-memory
gate) before the Player is created.
- L1 audio cache sized from the profile (400 MB Normal / 50 MB
LowMemory; was hardcoded 400 MB).
- Oversized tracks (>L1/4 = 12.5 MB on LowMemory) skip the promotion
clone and the parallel CMAF RAM accumulator entirely; the finished
track is streamed to the L2 disk cache in 1 MB chunks straight from
the playback buffer — no second in-RAM copy. Gapless degrades to a
small gap for those tracks (a gap beats an OOM kill).
- Stream buffer preallocates total_size up front (try_reserve_exact,
1 GiB cap, dynamic fallback) — no more ~8 doubling reallocs + memcpy
per track (weak-CPU relief).
- New memory-pressure watchdog (15 s): critical pressure evicts the
L1 (L2 disk kept) and halts prefetch; low pressure pauses HiRes
prefetch (downgrades to Lossless). Spawned by both frontends; qbzd
aborts+joins it for the #521 shutdown ordering.
- Prefetch honors the profile (depth cap, HiRes->Lossless downgrade).
Normal-profile behavior is byte-identical (every behavioral change is
gated on the LowMemory class; profile values equal the old hardcodes).
Phase B (follow-ups): tempfile spill of the live stream buffer for
giant tracks, POLLERR flood rate-limit, desktop kick_prefetch halt
check, L2-read on the play_track cache-hit path.
km