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
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
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
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
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
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
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
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
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
/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
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
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.
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.
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
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
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
- 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
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
- 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
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
- 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
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
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.
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
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
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
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
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
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
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
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
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
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
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
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
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.
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).
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.
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
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