Commit Graph
75 Commits
Author SHA1 Message Date
vicrodh fbc03f41b7 feat(audio): JACK backend foundation (#263 Tier 3 — player wiring pending)
Backend-side foundation for a native JACK output. The hard, RT-sensitive part
is done and compiles: jack_backend.rs holds a JackStream that opens a JACK client
'qbz' with stable ports qbz:out_FL/FR, a lock-free SPSC ring buffer, and an
allocation-free process callback (de-interleaves stereo f32 to the ports). The
feeder pushes interleaved f32 via write_f32().

Also: jack 0.13 dep (linux), AudioBackendType::Jack + its factory arm + the
qbz-slint label/bit-perfect match arms. NOT yet offered in the selector
(available_backends push commented) because the player wiring is still pending:
StreamType::Jack + the dispatch branch (backend==Jack -> JackStream) + a
PlaybackEngine::Jack feeder thread that resamples each track to the JACK graph
rate. JACK mode will be opt-in and NOT bit-perfect (one fixed graph rate); the
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.

TODO(distribution): feature-gate jack for Flatpak/distro builds without libjack.

cc
2026-05-31 12:36:37 -06:00
vicrodh d2c4aaf99c feat(audio): route locked output to the selected sink via PIPEWIRE_NODE (#263)
Completes the "Lock output device" (skip_sink_switch) fix. The lock already
skips set-default-sink + (Tier 1) the global clock force, but targeting was
implemented AS set-default-sink — so with the lock on QBZ's stream followed the
current default, and if the selected DAC was not the default, locked audio went
to the wrong device.

In locked mode, set PIPEWIRE_NODE=<selected sink> before the cpal PCM open so the
pipewire-ALSA plugin routes this stream to the chosen node WITHOUT changing the
system default. A Drop guard restores the prior env value after the open.

Delivers the reporter's full comment-3 ask: output to the selected sink AND never
change the default/priority AND never force the graph clock. Verified on hardware:
lock on + DAC selected + system default = onboard -> QBZ plays on the Cambridge DAC
(alsa_playback.qbz-slint linked to the DAC sink) while the default stays onboard and
another app keeps using it. Bit-perfect is traded for routing freedom in locked mode
(the graph clock is not forced), as designed.

cc
2026-05-31 11:31:33 -06:00
vicrodh 64fb77ade1 fix(audio): resume the suspended sink after exclusive access (#263 leak)
Exclusive-mode device acquisition suspends the default PipeWire sink
(pactl suspend-sink @DEFAULT_SINK@ 1) on the EBUSY-retry and CPAL-exclusive
paths, but never resumed it — leaving the rest of the system on a suspended
sink after exclusive playback.

Add suspend_default_sink_for_exclusive() (resolves + records the suspended
sink name) and resume_suspended_sink() (resumes that exact name; PipeWire sink
names are deterministic, so resume-by-name survives a vacate/re-create). The
player resumes on the pause-suspend teardown — after the exclusive stream is
actually dropped, never while the device is still held. Self-gating: a no-op
unless QBZ suspended a sink, so it is safe to call on every stop.

Does not touch the bit-perfect path. Verified no regression on hardware:
exclusive mode plays through the DAC (LED-confirmed) and system audio uses the
onboard sink (not stuck suspended). The suspend/resume paths themselves
(EBUSY race / CPAL-exclusive fallback) are rare and were not exercised in that
test; the fix is correct-by-construction and cannot affect bit-perfect.

cc
2026-05-31 10:58:16 -06:00
vicrodh b139392b9b fix(audio): reset clock.force-rate on stop for all forced sessions (#263 leak)
QBZ forces the global PipeWire clock for any non-locked PipeWire stream, but the
stop/pause-suspend reset was gated on pw_force_bitperfect — which is only set in
DAC-passthrough mode. So a plain PipeWire user (no passthrough) forced the graph
clock on play and never reset it, leaving the whole graph force-clocked after
stopping (a stale clock.force-rate that resamples every other app).

Track whether QBZ actually wrote the force (CLOCK_FORCE_APPLIED) and make
reset_pipewire_clock self-gating: it only resets a force WE applied, never
clobbering another app's forced rate. The player's two reset call sites (Stop,
pause-suspend) now call it unconditionally.

Verified on hardware (Cambridge USB DAC, PipeWire + passthrough off): a 192kHz
track forces clock.force-rate=192000; on pause-suspend the clock returns to 0
(previously stayed stuck at 192000).

cc
2026-05-31 09:57:56 -06:00
vicrodh d19312fe17 fix(audio): gate global clock.force-rate by skip_sink_switch (#263 Tier 1)
When "Lock output device" (skip_sink_switch) is on, also skip the global
pw-metadata clock.force-rate writes (the pre-stream force and the hi-res
re-apply), not just set-default-sink. This stops QBZ from yanking the whole
PipeWire graph clock on every track-rate change, which broke external routing
(qjackctl/qpwgraph/Reaper) for users who enabled the lock — issue #263 comment-3.

The bit-perfect path is unchanged: with the lock off (including DAC passthrough)
the clock is still forced to the track's native rate. skip_sink_switch is
transitively mutually exclusive with dac_passthrough/pw_force_bitperfect, so this
never collides with a forced-rate bit-perfect session.

Verified on hardware (Cambridge USB DAC): lock on + 44.1k->192k leaves
clock.force-rate untouched; lock off + passthrough forces 44100/192000 with the
DAC running at the native rate.

cc
2026-05-30 23:38:48 -06:00
vicrodh 022b3e46e6 Merge pre-release into slint-mvp (1.2.14 backend catch-up)
One-way backend catch-up bringing the 1.2.14 maintenance work into
slint-mvp: audio (#470/#449/#466), player/offline/prefetch (#467),
qobuz retry/backoff, credential-store + image-cache refactor, dep
bumps (cpal 0.17.3 / alsa 0.11 / tokio / ashpd / tower-http), and the
qbzd daemon removal.

Conflicts resolved:
- qbz-audio settings.rs: keep slint-mvp stream_first_track/buffer
  defaults (1/2, opt-out); adopt #470's CREATE-TABLE seed removal
  (row is seeded with backend_type below).
- crates/Cargo.toml: keep qbz-slint workspace member (qbzd removed
  upstream).
- crates/Cargo.lock: regenerated (no ratatui/qbzd; cpal/alsa bumped).

cargo check --workspace green.

cc
2026-05-25 15:12:58 -06:00
vicrodh a0051ab513 fix(audio): default to shared System output and resolve Auto to a concrete backend (#470)
OOTB playback froze (seekbar advancing, no audio) on hosts where the
configured backend was not usable — e.g. PipeWire as the Linux default but
`pactl` absent (minimal Debian/Ubuntu). "Auto" (a null backend_type) fell
through to a legacy CPAL path that forced the track rate onto the shared
default device, producing a suspended/silent stream and leaving a
process-wide stuck audio handle that survived Reset.

- Add a cross-platform "System" backend (CpalDefaultBackend now on Linux
  too): opens the OS default output at its negotiated rate with rodio
  resampling and an explicit ~100ms CPAL buffer (MixerDeviceSink has zero
  internal buffering, so the bare default underran and stayed suspended).
  Shared, no exclusivity, no `pactl` — mixes with other apps like any
  normal player.
- Make System the OOTB default and what Reset lands on; seed it on first
  run. Drop the per-startup #375 NULL->PipeWire backfill migration.
- Resolve a null backend_type to System in the player so the legacy CPAL
  path is never reached (covers legacy installs and headless callers).
- "Auto" in Settings is now a resolve-and-set action: it picks the best
  available backend (PipeWire if available, else System), stores that
  concrete value and updates the dropdown + device picker, so backend_type
  is never null.
- Explicit PipeWire (DAC passthrough) and ALSA Direct (exclusive) configs
  are untouched and remain bit-perfect.

cc
2026-05-24 21:13:11 -06:00
vicrodh 3a09ce7fe7 fix(audio): detect PipeWire via runtime socket so the backend is not hidden without pactl (#466)
is_pipewire_available() shelled out to `pactl info`, which lives in
pulseaudio-utils and is absent on PipeWire-only systems — hiding the PipeWire
backend even though PipeWire was running. Detect via the
$XDG_RUNTIME_DIR/pipewire-0 socket first (no extra package required), with
pw-cli and pactl as fallbacks.

cc
2026-05-22 23:16:53 -06:00
vicrodh 133e90f752 fix(offline): hysteresis + cheap probe + streaming liveness override (#467)
Long sessions died when a single transient blip flipped the app to offline and
the queue then skipped every cached-but-not-downloaded track. Harden the
detector:

- Consecutive-failure hysteresis: declare offline only after 2 failed polls,
  not a single blip.
- Replace the HEAD https://1.1.1.1 probe with a concurrent race of cheap
  generate_204 endpoints, so a saturated link (long Hi-Res tracks) no longer
  times the probe out.
- Positive liveness override: if a segment downloaded within the last 45s we
  are online by definition, regardless of the probe.

cc
2026-05-22 21:46:33 -06:00
vicrodh e4fa90c1b2 feat(prefetch): adaptive throttle based on observed bandwidth + underrun signal
Recovered from 63ecc324 (authored on the native-port branch, never merged to
pre-release). Backend-only, no Slint. Scales the prefetch cap down on slow
links and during audio underruns, and exposes the per-segment download signal
the offline detector reuses as a positive liveness override (#467).

cc
2026-05-22 21:46:07 -06:00
vicrodh 08c3f0135a fix(qbz-audio): skip capture-only ALSA cards in device enumeration
/proc/asound/cards lists every registered sound card, including
capture-only hardware (USB webcams, microphones). Those expose only
pcmXc capture nodes, so read_card_pcm_devices yields an empty playback
list for them. Without a guard such a card still got a bogus
sysdefault:CARD=<name> output entry, making a webcam appear as a
selectable output device.

cc
2026-05-19 02:21:43 -06:00
vicrodh 7e6e548598 feat(slint): minimal playback wiring + Queue, plus detail/Settings fixes
Playback (minimal, album-focused):
- playback.rs controller: play an album (set queue + start audio),
  play a single track, enqueue, play a queue index, transport
  (play/pause, next, previous, seek, volume)
- the audible step uses Player::play_track (fetch -> download -> play);
  the protected audio backend is untouched
- a 450ms poll loop pushes position/progress/times/playing onto
  NowPlayingState and auto-advances at end of track
- NowPlayingState / QueueState globals; NowPlayingBar bound to real
  state (song card, seek bar, transport, volume); QueueSidebar lists
  the queue and rows play on click
- a played track is recorded into the recently-played store

Detail views:
- album description and artist biography stay with the metadata,
  filling the width to the right of the artwork (not below the header)
- header nav tabs gained a dropdown caret and 2px side padding

Settings > Playback defaults are now opt-out: show context icon,
restore session, resume position and stream-uncached default on;
streaming-only stays opt-in; initial buffer defaults to 2s.

Known gaps: queue/now-playing artwork not yet threaded (Image is not
Send); autoplay-mode preference not consulted by auto-advance; buffer
progress line stays at 0.

cc
2026-05-18 22:16:13 -06:00
vicrodh f85df353ca refactor(qbz-audio): align audio settings store
cx
2026-05-15 19:42:17 -06:00
Afonso Jorge Ramos 1edde8a28a fix(audio): rephrase CoreAudio rate-mismatch errors for end users
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
2026-05-11 15:19:30 +02:00
Afonso Jorge Ramos 9e632386b9 fix(audio): retry CoreAudio shared-mode open on rate mismatch
CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.

Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
2026-05-11 15:16:46 +02:00
Vudgekek 7bad704e21 fix(audio): fix macOS Shared Mode playback from changing speed 2026-05-09 23:43:26 -05:00
vicrodh a67e3c9217 Merge PR #391 (Vudgekek): macOS CoreAudio Exclusive Mode
Adds CoreAudio Hog Mode integration on macOS so Exclusive Mode actually
takes the device exclusively (previously the macOS path used the
Linux-shaped logic without any kernel-level lock). Pins the CoreAudio
device id through Hog Mode acquisition, sample-rate switching, and
stream creation so System Default doesn't drift mid-session. Linux
behaviour is unchanged.

Review asks (all four resolved by Vudgekek + afonsojramos):

- set_coreaudio_hardware_volume falls back to software volume on Err
  rather than reporting success, so knob-only DACs keep a working
  volume slider via engine.set_volume().
- CoreAudioExclusiveGuard snapshots the device's hardware volume on
  acquire and restores it before releasing Hog Mode, so leaving
  Exclusive Mode returns the DAC to the user's pre-Exclusive level.
  Guard is constructed before the FFI call so partial-acquire failures
  still trigger Drop and release.
- Linux unused-variable warnings on stream_opt and exclusive_guard
  cleaned up via cfg_attr(allow(unused_variables)) on the macOS-only
  guard plumbing. cargo check is clean.
- Silent shared-mode regression on macOS when backend init fails:
  init_device now sets_current_device(None) and returns None on
  exclusive_mode==true so the user sees an error instead of
  unknowingly playing in shared mode.

Bonus: 5f10b4ba adds real CoreAudio device-rate detection for macOS
Shared Mode, +90 lines in backend.rs.

Conflict resolution: single textual collision in
crates/qbz-audio/src/lib.rs (both branches added a pub use line).
Resolved by keeping both, alphabetical order — coreaudio_direct
re-export from pr-391, device_reservation re-export from pre-release.
All other auto-merges verified line-by-line: AlsaDirectStream's
DeviceReservation::acquire flow (alsa_direct.rs:60-97), the macOS
exclusive_mode guard (player/mod.rs:1077-1085 in qbz-player and the
mirror in src-tauri:1024), and CoreAudioExclusiveGuard's volume
snapshot/restore (coreaudio_direct.rs:351,369,373,388) all coexist
correctly because each branch touched different functions.

cargo check clean across all four crates. 9 device_reservation +
3 reserve_dac + 214 src-tauri unit tests pass.

cc
2026-05-07 19:09:37 -06:00
Vudgekek 5f10b4baa4 fix(audio): check real CoreAudio device rate with macOS Shared Mode 2026-05-07 14:51:51 -05:00
vicrodh d2185f7a6b fix(qbz-audio): resolve ALSA card by id, not by long name
The CARD= parameter in ALSA device strings refers to the card's short
id (e.g. "C20", "HDMI", "Generic"), not its long descriptive name
("Cambridge Audio USB Audio 2.0"). The previous resolver called
Card::get_name() and silently swallowed errors, so every CARD=<id>
lookup failed and acquire() degraded gracefully -- meaning the
reservation has been non-functional for users with named-card device
strings since Task 3 landed.

Reported by the maintainer on Cambridge DacMagic Plus exposed as
front:CARD=C20,DEV=0, 2026-05-07. Smoke-test scenario 2 had been a
false positive: audio worked because we degraded silently, not because
the reservation succeeded.

In alsa-rs 0.10, Card::get_name() wraps snd_card_get_name and despite
the name returns the LONG descriptive string. The short id (what
aplay -l shows in brackets and what CARD= references) is exposed via
ctl::CardInfo::get_id() (snd_ctl_card_info_get_id). Open a Ctl per
card, read the id from its CardInfo, and surface ALSA enumeration
errors as ReservationError::AlsaError instead of unwrap_or_default()
into an empty string.

cc
2026-05-07 12:07:03 -06:00
vicrodh a4eb441c4e fix(qbz-audio): accept all ALSA plugin prefixes; degrade on parse failure
The previous parse_card_index only accepted hw: and plughw: prefixes,
which broke playback for users whose ALSA device string uses front:,
plughw:, surround*:, iec958:, hdmi:, etc. The plugin prefix is
irrelevant for D-Bus reservation purposes - what matters is the card
identifier in the args. Generalise the parser to extract the card
from any plugin prefix.

Defence in depth: when parse_card_index fails (unrecognised string,
no card identifier extractable, etc.), acquire() now logs a warning
and returns a degraded guard instead of propagating the error.
This preserves playback for non-card-targeting names like 'default'
and 'pulse' that are not meaningful for per-card reservation.

Reported on Cambridge DacMagic Plus exposed as front:CARD=C20,DEV=0,
2026-05-07.

cc
2026-05-07 11:10:12 -06:00
vicrodh 6b7ed75cc9 chore(qbz-audio): polish AlsaDirectStream reservation wiring
- Skip the 50 ms PipeWire-vacate margin on the degraded D-Bus path;
  no reservation transition happened, so no settle delay is needed.
- Add TODO(Task 5) breadcrumb for the app_device_name placeholder
  so Task 5's UI surfacing wires it to the user-facing DAC name.
- Soften the timing-claim comment on the sleep; the specific "~2-4 ms"
  was spec-cited, not measured in this commit.
- Lift the 50 ms literal into a named PIPEWIRE_VACATE_MARGIN const.

No behaviour change for the active-reservation path.

cc
2026-05-07 10:19:59 -06:00
vicrodh 92633dcf12 feat(qbz-audio): hold device reservation for AlsaDirectStream lifetime
Acquire org.freedesktop.ReserveDevice1.Audio<N> before opening the PCM,
hold for the entire stream lifetime, release on Drop after the PCM
closes. Closes the path where exclusive open would silently fight an
active PipeWire route.

Field order in AlsaDirectStream is significant: _reservation is the
last field so it drops AFTER the PCM closes, ensuring the kernel-level
exclusive lock is released before the D-Bus bus name is freed and
PipeWire reclaims the device.

This is the canonical Lifetime-A consumer the device_reservation
acquire doc-comment's tight-coupling rule allows: a DeviceReservation
created immediately before a real PCM::new() and held for as long as
the PCM is open. A 50 ms defensive sleep gives PipeWire time to vacate
the device after releasing the reservation.

cc
2026-05-07 10:09:07 -06:00
vicrodh 41b844cfa8 chore(qbz-audio): document tight-coupling rule and refactor contention proxy
- Document the load-bearing tight-coupling rule on acquire(): never
  construct a DeviceReservation without an immediate device consumer.
  Cambridge DacMagic Plus was stranded by a standalone reservation
  churn during initial Task 2 smoke testing on 2026-05-07.
- Document the Connection lifecycle: zbus does not pool session-bus
  connections, so acquire() pays a SASL handshake per call. Task 5
  will add acquire_with_connection() to reuse a long-lived bus.
- Refactor resolve_contention to open a single holder Proxy and read
  Priority + ApplicationName through it, instead of opening two.
- Specificity polish on the QBZ_APPLICATION_NAME dead-code comment;
  defensive-InQueue comment; release_name From-chain comment;
  clippy doc-list-overindentation fixes.

No behaviour change.

cc
2026-05-07 10:00:57 -06:00
vicrodh bc5d316e5a feat(qbz-audio): wire zbus client for ReserveDevice1 acquisition
Implements the full priority-resolution algorithm: request bus name
with DO_NOT_QUEUE; on EXISTS look up holder Priority via the
Properties interface; if our priority (5) is higher, call
RequestRelease and retry with REPLACE_EXISTING; if equal-or-lower,
return HigherPriorityHolder. AlreadyOwner is treated as success for
same-connection idempotency. Drop releases the bus name and logs
the ReleaseName reply variant.

D-Bus failure paths degrade to a no-op guard so playback continues
exactly as before in environments without a session bus.

Smoke-tested against PipeWire/WirePlumber via dbus-monitor.

cc
2026-05-07 09:43:37 -06:00
vicrodh f0ea0c88ec chore(qbz-audio): address Task 1 review feedback
- Add ReservationError::AlsaError variant; stop conflating ALSA
  enumeration errors with DbusError.
- Mirror ReservationError variant set on the non-Linux stub so cross-
  platform pattern matching compiles on every target.
- Add empty Drop to stub for RAII symmetry with the Linux variant.
- Collapse ReservationState to its real Task 1 shape (Degraded only);
  Task 2 reintroduces Active when it has real fields.
- Rewrite acquire() doc-comment to honestly describe Task 1's behaviour
  and hint at Task 2's full failure-mode set.

No behaviour change. Foundation commit for Task 2.

cc
2026-05-07 09:18:19 -06:00
vicrodh 4bc4213b97 feat(qbz-audio): add device-reservation module skeleton with parsers
Adds the new module that will host the org.freedesktop.ReserveDevice1
D-Bus client. This commit only ships the public surface, the parser
helpers (hw:N -> Audio<N> bus name, hw:CARD=name resolution) and unit
tests for them. acquire() currently returns a degraded guard; D-Bus
implementation lands in the next commit.

cc
2026-05-07 08:57:31 -06:00
Afonso Jorge Ramos 88e072a4ac fix(macos): harden CoreAudio exclusive stream handling
Address review feedback from the macOS Exclusive Mode PR.

Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.

Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.

Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.

Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.

Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
2026-05-07 09:36:20 -05:00
Vudgekek 59b1ceec2e fix(macos): align exclusive device and hardware volume 2026-05-06 23:26:15 -05:00
Vudgekek e0151af6d9 feat(audio): add CoreAudio exclusive mode 2026-05-06 23:26:15 -05:00
vicrodh c70db4157a refactor(v2): migrate get_pipewire_sinks to v2
Path C: legacy CPAL output-sink enumeration was inlined directly in
commands/playback.rs (cfg-gated Linux + non-Linux variants), with
no equivalent in any domain crate. Extract a domain function
`qbz_audio::output_sinks::list_output_sinks` that returns the same
{name, description, volume, is_default} shape (mirrored as the new
public `OutputSinkInfo` struct) for both Linux and non-Linux
platforms, preserving the original logging cadence and the rule
that name == CPAL device name (so saved IDs reopen correctly).

Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into
qbz_audio directly — no `crate::commands::*` delegation. Updates the
two frontend call sites (AudioOutputBadges.loadStatus and
SettingsView.loadAudioDevices). Legacy command in
commands/playback.rs is preserved untouched; the audio-protected
files (pipewire_backend.rs, audio_settings.rs, player init_device)
are not modified.

cc
2026-04-26 12:03:28 -06:00
vicrodh 18d8bc3572 fix(alsa): gate hw fallback on card presence, not stream0 rates (#331)
The hw:CARD=…,DEV=… fallback added in 1.2.8 (commit 927fabbd7) only
fired when the helper chain
  extract_card_name_from_device(device_id)
      .and_then(|card| get_hw_supported_rates(&card))
      .is_some()
returned Some — but get_hw_supported_rates parses
/proc/asound/cardN/stream0, which is only emitted by the USB-audio
driver. I2S, PCI and built-in cards (HifiBerry HATs, intel-hda, etc.)
don't have a stream0 file at all, so the gate evaluated to false for
the very devices that needed the fallback the most. The reporter's
HifiBerry Digi2 Pro on Raspberry Pi OS hit this exactly: 1.2.8's
ALSA Direct path logged "Looking for device: iec958:CARD=…", failed
the lookup, and dropped straight to the proc_found=false error
branch without ever attempting hw:CARD=sndrpihifiberry,DEV=0.

Extract a small is_card_present_in_proc helper that uses
find_card_number_by_name (which reads /proc/asound/cards — present
for every registered card regardless of bus) and use it for both
gate checks: the fallback trigger and the proc_found error message
selector. get_hw_supported_rates keeps its three legitimate uses
(actual rate validation in create_output_stream, and the two public
device_supports_sample_rate / get_device_supported_rates queries).

Adds 8 unit tests covering build_hw_fallback_id, the alias-prefix
branches of extract_card_name_from_device, the short-circuit cases
of is_card_present_in_proc, and the existing is_known_pcm_id filter.
The /proc-touching branches stay covered by hardware testing on the
reporter's Pi when v1.2.9-rc ships.

cc
2026-04-24 19:17:59 -06:00
vicrodh ec1bf38791 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:17:40 -06:00
vicrodh 7ac5c7e8a0 perf(audio): skip CPAL probe of virtual ALSA PCMs during enumeration
build_cpal_device_map iterated every PCM that cpal's ALSA host exposes
(dmix:, route:, surround51:, pulse, null, …) but only five name shapes
are ever looked up downstream: default, sysdefault:CARD=*, and
{front,hdmi,iec958}:CARD=*,DEV=*. Dropping the rest avoids holding
Device handles we never query and, combined with the new error
handler, removes another source of spurious libasound probes on
systems where PipeWire owns the hardware exclusively.

cc
2026-04-24 13:40:42 -06:00
vicrodh 134808c880 feat(audio): route libasound errors through log::debug
libasound's default error handler prints directly to stderr. During
device enumeration probes, PCMs that wrap dmix or surround routes emit
benign errors ("unable to open slave", "no matching channel map") that
cluttered the app log without indicating a real problem.

Install a process-wide snd_lib_error_set_handler shim from
AudioBackendFactory::create_backend (idempotent via std::sync::Once)
that forwards the file/line/function/err/fmt tuple to log::debug. The
messages stay available with RUST_LOG=debug and disappear during
normal runs.

Adds alsa-sys 0.3 as a direct Linux-only dependency on qbz-audio
(previously transitive via alsa/cpal). The handler is declared
non-variadic and transmuted into libasound's variadic function pointer;
ignoring the unused varargs is ABI-compatible on every platform we
support.

cc
2026-04-24 13:40:09 -06:00
vicrodh 927fabbd78 fix(alsa): fall back to hw:CARD=<name>,DEV=<n> when aliased id can't open
Minimal distros (the reporter's Raspberry Pi OS case in issue #331) ship
a trimmed /etc/asound.conf that defines only `!default` and doesn't pull
in the broader alias set most desktop distros provide. A card like the
HifiBerry Digi2 Pro — S/PDIF-only, so its selectable PCM is
`iec958:CARD=sndrpihifiberry,DEV=0` — then fails to open even though
the card is present and usable via the raw `hw:` device.

When CPAL's primary lookup by id returns None AND /proc/asound confirms
the card exists, rebuild the id as `hw:CARD=<name>,DEV=<n>` and retry
the lookup. The raw `hw:` PCM is exposed by the kernel driver for every
registered card, so it's a safe last resort — same card, same channel
routing, just skipping whichever userspace alias layer was missing.

Log both the alias failure and the fallback at warn/info so users can
see what happened without needing to diagnose ALSA config by hand.
Original error path is preserved when no fallback is possible (unknown
id shape, card not in /proc/asound, or hw: equally unavailable).

cc
2026-04-20 20:12:39 -06:00
vicrodh 05f0d69fcb fix(alsa): recognise iec958:/hdmi: prefixes in extract_card_name_from_device
Device enumeration produces four "CARD=" alias shapes (front:, sysdefault:,
iec958:, hdmi:) but the extractor only handled the first two. When a user
selected an S/PDIF or HDMI device id, the downstream proc_found check
always returned false, so the device-not-found error message dropped to
the generic "disconnected, renamed, or handled by another app" wording
even when /proc/asound showed the card present.

Part of issue #331 — reporter's HifiBerry Digi2 Pro is S/PDIF-only, so
its id is iec958:CARD=sndrpihifiberry,DEV=0, and the missing prefix match
obscured the real cause of the open failure.

This commit is a pure parsing extension; behavior changes only in the
error message accuracy. The hw:/plughw: fallback attempt lands in the
next commit.

cc
2026-04-20 20:09:27 -06:00
vicrodh 9c28960600 fix(audio): clearer error when CPAL cannot open an enumerated device (#288)
The old 'Device not found' error fired for two very different
situations: the device truly missing (unplugged, renamed) vs. present
in /proc/asound but not loadable by CPAL at the requested sample rate
or by the stored name format. The latter sent users on wild goose
chases.

Now we distinguish the cases: if /proc/asound still reports the card,
the message points at the likely causes (rate/format mismatch or
ALSA name mismatch) and suggests the plughw plugin, which is the
workaround we already apply automatically in Fix 2.

cc
2026-04-13 19:20:35 -06:00
vicrodh cba5b76bb3 fix(audio): fallback to plughw instead of CPAL when DAC rate unsupported (#288)
When /proc/asound reports that the selected hw: device does not
natively support the track's sample rate (e.g., 96 kHz track on a
48 kHz-only Vanatoo Transparent Zero), the previous code returned
None from try_create_direct_stream. The caller then dispatched to
CPAL's create_output_stream, where the CPAL ALSA host could not
locate the device by its /proc/asound name and failed with the
misleading error 'Device not found'.

The same function already has a plughw: fallback path further down
that wraps the hw device with ALSA's plug plugin — the latter
resamples in ALSA space and is the correct behavior for rate
mismatches. This change makes the /proc/asound pre-check set a
local flag that skips the hw attempt, falling through to the
existing plughw branch instead of returning None.

Sound now comes out. Bit-perfect is lost (by necessity) and the
mode reported back to the player is PluginFallback, which lets the
UI surface the degradation (wired up in Fix 4).

cc
2026-04-13 19:17:59 -06:00
vicrodh c8a3a648f5 chore(alsa): lower device enumeration logs to debug, add 5th retry
Reduce log noise by demoting ALSA device enumeration from info to debug.
Add a 5th backoff retry at 800ms for both hw and plughw paths as a
safety net for any EBUSY cases that slip through the lifecycle fix.

cc
2026-04-07 11:53:23 -06:00
vicrodh 4439070032 fix(alsa): progressive backoff retry on device busy during track skip
When skipping tracks quickly, the previous PCM handle may not be
fully released by the kernel before the new track tries to open
the same ALSA device (snd_pcm_open fails with EBUSY).

Previous behavior: single retry after 200ms PipeWire suspend, then
immediate failure. This didn't cover our own PCM race condition.

New behavior: 4 retries with progressive backoff (50/100/200/400ms)
for both hw and plughw paths. PipeWire suspend still attempted once
upfront. Total worst-case wait: 750ms (vs 200ms before), but the
early retries at 50-100ms cover the common fast-skip case.

cc
2026-04-06 11:23:35 -06:00
vicrodh 1934d501ed feat(audio): make quality fallback opt-in via allow_quality_fallback setting
Off by default. When disabled, if the requested quality is unavailable
the download/stream fails instead of silently downgrading. Persisted
to audio_settings DB. Applied to offline cache downloads.

cc
2026-04-05 10:44:05 -06:00
vicrodh 83e43949b7 feat(audio): add skip_sink_switch option for JACK/DAW routing (#263)
When enabled, skips pactl set-default-sink on stream creation.
Preserves external audio routing (JACK, qjackctl, Reaper).
Mutually exclusive with DAC passthrough.

cc
2026-04-04 12:08:22 -06:00
Afonso Jorge Ramos 4c15a43200 feat(macos): enhance CpalDefaultBackend with device capabilities
On macOS, enumerate_devices() now populates supported_sample_rates,
max_sample_rate, device_bus, and is_hardware via CoreAudio probing.
create_output_stream() switches the device sample rate to match the
track before opening the CPAL stream.
2026-03-31 02:20:08 +02:00
Afonso Jorge Ramos c51f07fe65 feat(macos): add CoreAudio device probing and sample rate switching
Add coreaudio_direct module using coreaudio-rs safe wrappers for:
- Device capability probing (supported sample rates, transport type)
- Automatic sample rate switching per-track
- Device lookup by name for CoreAudio↔CPAL bridging

Uses a fork branch of coreaudio-rs that adds get_available_sample_rates
and get_device_transport_type helpers (PR RustAudio/coreaudio-rs#149).
2026-03-31 02:19:59 +02:00
Afonso Jorge Ramos dad062d61b fix(audio): add docs and fix param names for non-Linux stubs 2026-03-20 01:13:55 +01:00
Afonso Jorge Ramos f854e09661 feat(audio): add SystemDefault variant to AudioBackendType 2026-03-20 01:13:29 +01:00
Afonso Jorge Ramos 5ed549d46b feat(macos): gate Linux-only audio backends with cfg
Add #[cfg(target_os = "linux")] to pipewire_backend and pulse_backend
modules. Add CpalDefaultBackend for non-Linux platforms using CPAL
default host (CoreAudio on macOS). Gate reset_pipewire_clock() calls.
2026-03-20 00:45:03 +01:00
vicrodh 1f1e729db8 fix: HiFi Wizard now detects real DAC sample rates from /proc/asound
v2_query_dac_capabilities was returning hardcoded sample rates
(44.1-192kHz including 176.4kHz) instead of querying the actual
hardware. This caused incorrect configuration for DACs that don't
support all rates (e.g. DacMagic Plus doesn't support 176.4kHz).

Now queries /proc/asound/cardN/stream0 via PipeWire sink mapping
or ALSA device ID to get the real discrete rates from the USB
descriptor. Falls back to defaults only if detection fails.

cc
2026-03-19 12:41:24 -06:00
vicrodh 5488839e97 fix: increase PipeWire rate switch delay and verify after stream creation (#180)
USB hubs/docks (e.g. Razer USB4 Dock) may need longer than 300ms for
sample rate switching. When PipeWire hasn't finished reconfiguring,
96K/192K tracks play at wrong speed (half/quarter) and sound distorted.

Changes:
- Increase initial clock.force-rate delay from 300ms to 500ms
- After stream creation, query pw-metadata clock.rate to verify the
  actual graph rate matches what was requested
- If mismatch detected, retry with additional 500ms delay and re-force
- Log warnings when rate can't be verified so users can report

cc
2026-03-19 10:57:21 -06:00
vicrodh de29f1e2e0 chore(qconnect): checkpoint current poc state
cx
2026-03-13 17:28:05 -06:00