A track that needs no boost keeps unity (or its own attenuation) even when
its peaks already sit above the -1 dBTP headroom: that is the master's
doing, not ours. Without this, every hot modern master exactly at the
target lost ~0.5-1 dB the moment the guard saw its peak.
cc
A new audio setting, on by default: the normalization boost is capped so the
track's known peak stays 1 dB under full scale. Store column + migration,
portable in the settings bundle, shown in diagnostics, and settable from
qbzd (settings get/set audio.normalization_prevent_clipping).
cc
The cache kept a target-relative gain (a target change invalidated every
row) and was rewritten every 5 s, so a skip left a 5-second window behind.
Rows now hold integrated LUFS + true/sample peak + a ranked source that a
weaker measurement cannot overwrite; the last window of a track is stored
as the full-track figure, and nothing shorter than 30 s is stored at all.
The analyser no longer re-levels a track that started with a known gain,
moves the live gain at most once (a seek keeps it), measures true peak, and
DynamicAmplify glides over 400 ms instead of 50 while polling the shared
atomic every 1024 samples instead of every sample. loudness_scan is the
pure full-track measurement the player's pre-analysis will feed.
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
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
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
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.
cc
An ISRC or fingerprint column plus a partial index, with
set_content_key / get_by_content_key helpers, so a local or Plex copy of
the same recording can reuse an analysis. The analyser and every audio
path are untouched; set/get keep their track_id contract.
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
A spawn of an image that does not exist fails before a process is created, so
pactl, pw-dump and systemctl were harmless on Windows. sh is not: it exists
wherever Git for Windows is installed, so the five sh -c probes in
audio_stack_health really ran and flashed five console windows past a
GUI-subsystem binary that owns no console.
Scoped to windows / not(windows), NOT to linux / not(linux). macOS and the
BSDs can have a real pactl or pw-dump from Homebrew or ports, and answering
false for them would take away an answer they can give.
qbz-theme needed nothing: its dispatch is by XDG_CURRENT_DESKTOP, so Windows
resolves DesktopEnvironment::Unknown, whose arm returns Err without calling
any desktop probe.
cc
WASAPI Exclusive played nothing at all. The stream opened correctly every
time (Initialized EXCLUSIVE @ 44100 Hz, S24In32, DirectHardware); the render
thread was simply gone by the time audio arrived:
Stop requested
Writer thread started (gapless-capable)
stream closed with 2 underrun(s)
ERROR Write failed: render thread is gone
The player calls stop() on EVERY track change and then reuses the same stream
object for the next track. ALSA states that contract in its own comment --
"Stop PCM immediately (prepare for next playback)": DROP to halt and discard,
then prepare, and the object survives. Ctl::Stop here did break, so the stream
was single-use and the first track never reached the device.
Ctl::Stop now halts (stop_stream + reset_stream), discards the carry and the
queued PCM, answers any pending drain, and parks waiting for the next track's
first chunk, which it pre-rolls before restarting. The IAudioClient is never
re-Initialized, so the exclusive lock is held across the track change.
Three consequences, each found before shipping:
- Ctl::Shutdown is new, and only Drop sends it. Drop's comment asserted "the
thread's own loop exits on Stop"; once that stopped being true, join()
blocked forever at process exit. Measured as a 10-minute hang.
- stop() now blocks on an acknowledgement. Fire-and-forget raced the player,
which queues the next track's PCM immediately after stop() returns, so the
purge could eat audio belonging to the track that was starting.
- The halt and pre-roll results are no longer discarded, a Drain arriving
while stopped completes at once instead of blocking for the deadline,
drain() reports Err when a stop threw the audio away rather than claiming
it played, and Resume no longer Starts an unprimed Reset stream.
The example only tested drain-then-write, which is why a stream that could not
play a second track passed it. It now plays three audible one-second beeps --
after playback, after the drain, after the stop -- and judges underruns on
continuous playback only, since the gaps between beeps are underruns the test
creates itself.
Measured against the DAC at 44100 and 192000: three beeps, stop acknowledged
in 109 ms, write after stop OK, 0 underruns, 0 event timeouts, exit 0.
Verified by smoke in the app: one exclusive open at 96000 Hz across two track
changes, zero "render thread is gone", and gapless confirmed audibly.
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
The render thread and the handle. COM objects are !Send, so one thread owns
every one of them for the life of the stream and this struct is a bounded
channel plus atomics - the same back-pressure snd_pcm_writei gives the ALSA
path for free.
Opens by ENDPOINT ID, never by friendly name: Windows gives several
endpoints the same name, which is what hid this DAC from the picker until
249adc77b. Walks the format ladder, pre-aligns the period to 128 bytes,
keeps the documented NOT_ALIGNED recovery for the devices that still need
it, joins MMCSS Pro Audio, and refuses S_FALSE loudly - a "closest match" is
by definition not the format we asked for.
Verified against the owner's Cambridge Audio USB Audio 2.0 with the new
example, and it matches the spike exactly:
rate 96000, S24In32 (24/32), period 30000 hns, buffer 288 frames,
realigned false, timing Events, mode DirectHardware,
960000 frames, 0 underruns, 0 event timeouts
Three faults were found on the way, and all three were in the MEASUREMENT
rather than the audio - worth recording because each looked like a hardware
problem:
- 339 "underruns" were event timeouts counted in the same counter. Split
them; they are different faults and were hiding each other.
- 337 event timeouts came from a 2x-period deadline. At a 3 ms period that
expired on 10% of wakeups because Windows scheduling is not that
punctual. The deadline exists to stop the thread hanging on a dead
device, not to detect glitches, and the spike waited 1000 ms and saw
none in ten minutes. It is generous now.
- The last 2 were a drain's trailing silence. A drain emitting silence is
the drain working.
The stub for every other platform mirrors alsa_direct's: constructors refuse,
teardown succeeds, so a caller written against this compiles and unwinds
everywhere. `cargo tree` shows exactly one windows v0.62.2 - no second
windows tree entered the graph.
cc
Format ladder, PCM packing and period arithmetic for phase B, with the tests
that make them provable. No COM yet: this half compiles and is tested on
every host, which matters because it is where the platform-specific mistakes
live.
The one that bites: Windows 24-in-32 is LEFT-aligned, the 24 valid bits in
the HIGH bytes, where ALSA's S24_LE is right-aligned. Porting the ALSA
packing verbatim shifts every sample down 8 bits and plays 48 dB QUIET, which
sounds like a volume bug and survives a listening test. There is a test whose
only job is to fail if that ever happens.
Scaling matches alsa_direct exactly (8_388_607 and 32767, not the power of
two), so the two backends cannot disagree about full scale. The plan's test
sketch asserted i16::MIN and -8_388_608; those follow from the OTHER scaling
choice, so the tests assert the behaviour this file actually has.
aligned_period_hns rounds the period so frames * block_align is a whole
number of 128-byte blocks, which is how AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED
stops firing rather than being recovered from. Verified against the two
periods measured on the owner's DAC: 3 ms at 192k 24-in-32 is already
aligned at 576 frames and must not move.
LADDER order comes from research/01; the spike found that this DAC accepts
only S24In32, so the ladder is one rung deep there. It stays a ladder because
other devices are not.
cc
Windows endpoint friendly names are not unique. Measured on this box, FOUR
active render endpoints resolved to three display strings, and two were both
called "Altavoces": the onboard Realtek and a Cambridge Audio USB DAC.
device_filter dedups on the display, so the DAC was folded away and could
not be selected at all - and a DAC you cannot select cannot be sent a
bit-perfect stream, so this also blocked phase B.
device_filter.rs:20-23 states the opposite in a comment - "macOS/Windows
device names carry unique displays, so they pass through untouched". That is
the third comment in this tree asserting a mechanism that does not hold.
Nothing new is queried: cpal 0.17.3 already fills the fields this backend
ignored. name() is the ENDPOINT and repeats; driver() is the ADAPTER and is
what tells them apart; id() is the stable WASAPI endpoint GUID. On Windows
the id becomes the GUID, the display becomes "endpoint (adapter)", and the
adapter lands in the description field that was hard-coded None. The default
device is matched by id there too, since three endpoints shared a name.
Additive cfg arms only, per the protected-core rule: every other platform
takes the same (name, name, None, name == default_name) tuple it took
before, byte for byte. The open path keeps the name comparison as a fallback
so a device_id saved before this change still resolves.
Verified against the real hardware, before and after, with a standalone cpal
probe: the DAC goes from DROPPED to listed, labelled and flagged default.
cc
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
Add a theme-aware goniometer and pitch-locked oscilloscope to the large dock and immersive focus modes.
Gate scope DSP and scenegraph updates by actual panel visibility so hidden modes remain idle, including while shader scenes cover immersive panels.
cx
Owner-authorised change inside the protected backend, and it touches no audio:
the fix is entirely in what the log CLAIMS.
`get_pipewire_current_rate` wrote `clock.force-rate` and then read `clock.rate`
to check it. Those are different things in PipeWire — `force-rate` drives the
graph, `clock.rate` is the graph's NOMINAL default and does not move when a rate
is forced. So the check compared its request against a key it had never touched,
got the untouched 48000 default, and logged "Audio may play at wrong speed" over
perfectly correct output. The retry never helped because the delay was never the
problem.
Measured directly on the owner's box with the app closed:
clock.rate = 48000 <- what the check read
clock.force-rate = 96000 <- what the code wrote, and what the DAC showed
The owner had been living with the warning precisely because the hardware
disagreed with it: "el dac muestra los 96 ... por eso no me he clavado".
Now: read `force-rate`, fall back to `clock.rate` when nothing is forced (it
reads 0 then), which is the honest answer in that case. The parse is split into
`read_pw_setting(key)` so the two keys cannot drift apart, and it matches
`key:'<name>'` EXACTLY — `clock.rate` is a substring of `clock.force-rate`, so a
`contains` would cheerfully report the forced rate when asked for the nominal
one.
Worth fixing even though the audio was always right: in a bit-perfect player a
warning that cries "wrong speed" over correct output trains you to ignore it,
and buries the day it is true.
Not addressed, observed while measuring: `clock.force-rate` stays at 96000 after
the app exits, so the system graph is left pinned until something else changes
it. May well be deliberate; flagged, not touched.
cc
HwParams::set_buffer_size_near and set_period_size_near take alsa::pcm::Frames, which is libc::c_long: i64 on 64-bit targets but i32 on 32-bit ones. The three buffer_size computations in alsa_direct.rs cast to i64 explicitly, so the crate does not compile for any 32-bit target.
Casting to the alsa type instead is a no-op on 64-bit, where Frames already is i64, and correct on 32-bit. No behavioural change on any currently supported target: every value involved is a sample rate divided by 2, 4 or 8, so at most 96000, far below an i32 overflow.
Verified with cargo check -p qbz-audio:
x86_64-unknown-linux-gnu passes before and after
arm-unknown-linux-gnueabihf 6 x E0308 before, passes after
set_hardware_volume attached the mixer to the stream's raw PCM id
(iec958:/hdmi:/front:/DEV-qualified hw:) — snd_mixer_attach needs the
per-card ctl device, so hardware volume silently no-oped on plugin-alias
devices (HiFiBerry Digi as iec958:CARD=sndrpihifiberry,DEV=0). Derive
hw:CARD=<name> / hw:<n> first. With this, qbzd's 'Hardware volume'
toggle reaches the DAC's mixer control (e.g. 'Digital') as intended;
with the toggle off, ALSA Direct stays fixed-volume by design
(bit-perfect, no software gain).
km
Three transition killers on drivers that require an explicit drop
(snd-rpi-hifiberry/PCM5122; the desktop's USB DAC tolerated all three,
which is why only the Pi died):
1. stop() called prepare() without drop(): prepare from
RUNNING/DRAINING returns EBUSY there — every stop failed and left
the pcm in limbo. Now drop+prepare (drop EBADFD tolerated).
2. A write racing a still-draining pcm (natural end + late gapless
append) gets EBADFD, and snd_pcm_recover(77) is unsupported on this
stack, so the write died and the writer thread exited. New
recover_write_error(): normal recover first, then drop+prepare for
EBADFD (11 write sites via one helper).
3. snd_pcm_drain can block forever when the device stops clocking —
the writer wedged and no 'engine empty' ever fired. drain() is now
bounded: poll pcm.state() with a 10s deadline; the tail finishing
shows as XRUN (drop+prepare), and a stuck device is cancelled.
km
Two prerequisites for wiring limit_quality_to_device to the local output
path (#638 fix 3, spec Phase C):
- DacCapabilities gains an additive detected flag (F26): assemble() records
whether sample_rates came from real /proc/asound-ALSA detection or from
the FALLBACK_RATES common set, so a consumer deriving a quality cap can
disclose that a fallback-based cap is not guaranteed to match the
hardware (owner Decision B: the cap still applies either way). Existing
consumers (HiFi wizard, qbzd TUI wizard) only read fields and compile
unchanged; module tests updated to assert the flag.
- One-time user_version-gated backfill of limit_quality_to_device to 0
(F10): installs that first ran a pre-#45 build carry a column backfilled
to 1 by the original DEFAULT-1 migration. That divergence was inert while
the flag had zero consumers; the moment fix 3 consumes it those installs
would silently gain a cap nobody asked for on upgrade. The backfill runs
once, stamps user_version only on success, and never touches a value the
user sets after this release. Stale field/default doc comments revised to
match the wired semantics.
Protected-crate note: dac_capabilities.rs is the read-only probe/DTO
assembler and settings.rs is the SQLite settings store — neither is part of
the protected bit-perfect path (alsa_direct.rs, alsa_backend.rs, the
backends, playback_engine.rs are untouched). No sample-rate handling
changes; the flag only shapes which Qobuz tier is REQUESTED.
cc
The unwrap_or(1) was stale residue from when the column shipped as
DEFAULT 1 with a struct default of true; since 1.1.9 (#45) the struct
default and the ALTER are both 0/false. The NULL branch is unreachable
today (the migration carries DEFAULT 0 and the seed INSERT materializes
it), so this is hygiene, not a behavior change - but it would become a
live wrong-default the moment anything consumes the flag, which the
local device-cap wiring for #638 is about to do.
This touches the settings store only, not a backend: no contact with
alsa_direct.rs, alsa_backend.rs or any playback path.
cc
front:CARD=X,DEV=Y is an alsa-lib config alias resolved through per-card
config (cards.<driver>.pcm.front.<DEV>); snd-aloop's Loopback.conf only
declares front for DEV=0 (and via softvol), so a saved DEV=1 device failed
snd_pcm_open with ENOENT and even DEV=0 got an extra plugin stage. Derive
the raw hw:/plughw: open ids from the alias args instead — they go straight
to the kernel PCM with no config dependency, for any DEV. Saved settings
ids are unchanged; the D-Bus reservation parser and /proc rate pre-check
already handle the CARD= form.
km
Store the CMAF duration at command-accept so the seek bar and progress can't
freeze at 0:00, backfill it on the Resume rebuild, and clear + surface the
zombie stream-create-failure path. Degrade every non-priority device-reservation
contention error (only a higher-priority holder stays fatal) so a sandboxed
D-Bus RequestName denial can't block bit-perfect playback. Honor the
stream_buffer_seconds floor in the dynamic CMAF path and wire the network
throttle (record_segment_bandwidth / record_underrun / prefetch cap) to tame the
#591 underrun loop. Grant --own-name=org.freedesktop.ReserveDevice1.* in the
repo flatpak manifest so WirePlumber releases the card cleanly.
cc
Two stacked defects broke playback under sandboxes (Snap/Flatpak/minimal .deb) in 2.0.1: PipeWireBackend::is_available() probed only `pactl info`, which those images don't ship, so PipeWire reported unavailable even though it was running (native pw-dump enumeration succeeded in the same log). And the streaming stream-init path had no legacy fallback (unlike init_device), so a failure dead-ended into a stuck spinner until the 45s watchdog. is_available() now checks the pipewire-0 socket + pw-dump (both time-bounded, fail-fast) with pactl kept as a last fallback; the StartStreaming path falls back to legacy CPAL on non-macOS. Sample path untouched (no gain/resample/rate change). Addresses #591/#592.
cc
Under Snap's mediated session bus, requesting the org.freedesktop.ReserveDevice name is denied and the error aborted ALSA Direct stream creation, so there was no audio. Fall back to the existing Degraded state (proceed without the reservation) on acquire failure, reusing the same non-fatal path the card-index / no-bus cases already take. Audio sample path untouched. Resolves#534.
cc
The NPB Large dock idled at ~2.5% CPU paused: the visualizer gates on
visibility only, so the FFT producer kept re-transforming the stale
ring buffer at 30fps and the drain timer kept churning the 28 bar
models. Producer now parks when enabled-but-paused (same bounded
200ms park_timeout shape as the existing disabled-park; the atomic is
a lone Relaxed store, submit path untouched); the drain early-returns
when not playing and unparks the producer on the resume edge, so
fresh bars land in <100ms. The paused flag is stored at every
playing-state flip: toggle, track meta seed, the poll loop dirty-push
(catch-all for MPRIS/tray/hotkeys/QConnect within one 450ms tick),
the cast poll while casting, stop-after, queue end and the
unavailable-skip halts. Bars freeze at their last values while
paused; immersive freezes too — intended. Legacy Tauri tree's tap
literal gained the field to keep compiling.
cc
Conversion rides the existing play_streaming path as an exact-size
88.2 kHz/24-bit WAV. DoP and native DSD (Phases 2-3) get a dedicated
direct engine: additive S32/DSD_U32 ALSA stream constructors and a
verbatim i32 write path (no f32, no gain), a writer thread that feeds
0x69 DSD silence on pause and pads stream teardown, and a source queue
for gapless DSD. AudioSettings gains dsd_mode (convert|dop|native,
default convert). Delivery resolves per track: user mode + ALSA direct
backend + stereo + rate support, everything else converts. Seek is
ignored in direct modes and the pause suspend path is gated off there.
cc
- Gate the shader drain on the immersive view being OPEN, not just
shader-mode > 0: the three close handlers never reset shader-mode, so
a picked shader scene kept rendering a full-res pass at 30fps forever
after closing the immersive view.
- Start/stop the drain timer from set-enabled instead of running it 30x/s
for the app lifetime; park the FFT producer thread on a condvar while
the tap is disabled.
- Compute the 5 energy bands in one spectrum pass (was two identical
scans) and reuse the waveform buffer.
- Shader underlay: rotate a small pool of render targets instead of
creating a fresh 2560x1440 texture every frame (~440 MB/s alloc churn),
clamp the target to the window physical size, and build pipelines
lazily on first use instead of compiling all six WGSL shaders at
startup.
cc
Fixes from owner testing of the Slice 9 playback test:
- No audio: removed the incomplete reinit_device(node) that routed output to the
selected DAC (the proper switch is set_output_device -> reload_settings ->
reinit; doing it half-way broke playback). The test now plays through the
current output; route QBZ to the DAC under test in Settings > Audio.
- Bit-perfect read-back: dropped the pw-top panel (batch snapshot never reflected
the live nodes) and enriched the N6 read-back to show the DAC's real
/proc/asound hw_params — rate + ALSA container format (S32_LE etc.) + channels.
That container format is the bit-perfect proof. Probe scans for the actively
playing card, so it's DAC-agnostic (answers the multi-DAC case: one output at
a time).
- Track jump: clicking a test row now re-sets the queue at that index via the
working play_tracks path (play_index alone only moved the pointer). Resolved
tracks are stashed for this.
- "Use my own music" button: starts the read-back + resumes the user's own queue
for those who don't want the curated tracks.
- Row hover no longer changes the row height (fixed-size glyph slot; the ♪->▶
swap can't reflow what's below).
cc
InitSystem gains ALL/index/label so the wizard can offer a manual init override
(for sandboxes and for generating commands for another machine).
Sandbox awareness: detect_sandbox() (/.flatpak-info, $SNAP). Inside a sandbox
/etc/os-release is the RUNTIME's (Flatpak freedesktop-sdk) or the snap base's,
not the user's distro, and /run + /proc/1 reflect the sandbox — so detect_distro
now reads the host-exposed os-release first (Flatpak /run/host/os-release, Snap
/var/lib/snapd/hostfs/etc/os-release, then /etc), and detect_init returns
Unknown in a sandbox (the host init is invisible; the wizard's override decides).
cc
Decouple service commands from the distro: the init system is detected at
RUNTIME (detect_init: /run/systemd/system, /run/openrc, /run/runit, /run/s6-rc,
/run/dinitctl, then /proc/1/comm), because it is orthogonal to the distro —
Gentoo runs OpenRC *or* systemd, Debian runs systemd or sysVinit/runit (antiX).
So install commands key off the distro (package manager) and service/restart
commands key off the detected init.
Adds the systemd-free derivatives Antix (Debian) and Artix (Arch) — matched
BEFORE their parent family since their ID_LIKE would otherwise misclassify them
and emit systemd commands — plus NixOS (declarative). InitSystem enum +
parse_init_from_comm. 6 health tests (incl. systemd-free-beats-parent + init
classification).
cc
`qbz-audio::health` for the wizard's check step: audio_stack_health() probes
wireplumber (systemctl OR `pgrep` fallback so non-systemd inits like
Gentoo/OpenRC and Void/runit don't read as down), pw-dump, the ALSA->PipeWire
bridge PCM (`aplay -L` `^pipewire$` — needed for CPAL PLAYBACK, not just
enumeration), pactl, and any-sink presence. detect_distro() classifies
/etc/os-release (ID then ID_LIKE; strips single AND double quotes — Gentoo
single-quotes its value). Distro enum carries the dropdown order + labels.
Pure parse_distro + 4 unit tests (Ubuntu, Pop/EndeavourOS via ID_LIKE,
fedora/arch/suse/gentoo+single-quote/void/other, index round-trip). Read-only.
cc
Extract the DAC capability assembly out of the Tauri-only
`v2_query_dac_capabilities` into `qbz-audio::dac_capabilities` so the Slint
wizard can probe a DAC's real supported sample rates + pretty description
without a Tauri command. The detection itself already lived in this crate
(`PipeWireBackend::get_sink_supported_rates` -> /proc/asound, with the
`get_device_supported_rates` ALSA-direct fallback); this just assembles the DTO
frontend-agnostically and benefits from the Slice-0 pw-dump-robust enumeration
for the description.
Formats stay nominal (S16/S24/F32) and are documented as NOT probed — honest
port of the prior behavior, not a fake measurement. Pure `assemble` helper +
3 unit tests. Read-only.
cc
The playback test needs the rate the DAC is ACTUALLY clocked at, not the rate
QBZ requested (`player.state.get_sample_rate()` only proves "QBZ asked for X").
Ground truth on Linux is `/proc/asound/cardN/pcm*p/sub0/hw_params` (`rate:` line
while a stream is open, `closed` when idle). The card number is resolved from
the PipeWire node.name via `pw-dump` (robust — no pactl/pipewire-pulse).
New `qbz-audio::dac_probe`: `NegotiatedRate { sample_rate, format, channels }`,
pure `parse_hw_params` + `parse_alsa_card_for_node` helpers, and the composed
`negotiated_stream_rate(node_name)`. Read-only — never opens/reconfigures a
stream, protected bit-perfect path untouched. 4 unit tests (active 24/192
fixture, closed/empty/no-rate, card resolution from int and string props).
cc