From fbc03f41b7214a987e222e9bfc20fac42849000a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 31 May 2026 12:36:37 -0600 Subject: [PATCH] =?UTF-8?q?feat(audio):=20JACK=20backend=20foundation=20(#?= =?UTF-8?q?263=20Tier=203=20=E2=80=94=20player=20wiring=20pending)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/Cargo.lock | 70 ++++++++++- crates/qbz-audio/Cargo.toml | 5 + crates/qbz-audio/src/backend.rs | 29 +++++ crates/qbz-audio/src/jack_backend.rs | 173 +++++++++++++++++++++++++++ crates/qbz-audio/src/lib.rs | 4 + crates/qbz-slint/src/settings.rs | 4 +- 6 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 crates/qbz-audio/src/jack_backend.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index f9a889c19..b00a064c6 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -1037,7 +1037,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1601,7 +1601,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -2328,7 +2328,7 @@ dependencies = [ "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "libloading", + "libloading 0.8.9", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", @@ -3260,6 +3260,33 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jack" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7811b07bcac5dafabf814ab52c4b0ca9b7948aa1e279f572f03aa6544d47d27" +dependencies = [ + "bitflags 2.11.0", + "jack-sys", + "lazy_static", + "libc", + "log", +] + +[[package]] +name = "jack-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "libloading 0.7.4", + "log", + "pkg-config", +] + [[package]] name = "jiff" version = "0.2.25" @@ -3460,6 +3487,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4991,6 +5028,7 @@ dependencies = [ "coreaudio-rs", "dirs 6.0.0", "ebur128", + "jack", "log", "objc2-core-audio", "qbz-models", @@ -7096,7 +7134,7 @@ checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", "ctor", - "libloading", + "libloading 0.8.9", "pkg-config", "tracing", ] @@ -8014,6 +8052,22 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -8023,6 +8077,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.61.3" @@ -8726,7 +8786,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", "rustix 1.1.4", "x11rb-protocol", diff --git a/crates/qbz-audio/Cargo.toml b/crates/qbz-audio/Cargo.toml index d3efc5450..315db8610 100644 --- a/crates/qbz-audio/Cargo.toml +++ b/crates/qbz-audio/Cargo.toml @@ -47,6 +47,11 @@ alsa-sys = "0.4" # Pinned to v4 to share the transitive copy already pulled in by # secret-service/souvlaki and avoid duplicate zbus compiles. zbus = { version = "4", default-features = false, features = ["tokio"] } +# JACK native backend (#263 Tier 3): QBZ as a first-class JACK client with stable +# ports. Links libjack (provided by pipewire-jack on the rig). TODO(distribution): +# feature-gate for Flatpak/distro builds that may not ship libjack — not blocking +# while Tauri isn't being released. +jack = "0.13" [target.'cfg(target_os = "macos")'.dependencies] # CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index 8abb776f6..c4076192c 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -33,6 +33,15 @@ pub enum AudioBackendType { /// - Fallback for systems without PipeWire Pulse, + /// JACK backend (#263 Tier 3 — pro-audio routing). Linux-only in practice. + /// - QBZ appears as a first-class JACK client with stable ports + /// (`qbz:out_FL` / `qbz:out_FR`), patchable in qjackctl/qpwgraph/Reaper + /// - Routing survives track changes (the client + ports live once) + /// - NOT bit-perfect: the JACK graph runs at ONE fixed rate (audio is + /// resampled) — an opt-in routing-freedom mode; never touches the + /// bit-perfect ALSA-exclusive / DAC-passthrough paths. + Jack, + /// System default backend (non-Linux platforms) /// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows) /// - Automatic device selection via OS audio system @@ -286,6 +295,12 @@ impl BackendManager { if Self::is_pulse_available() { backends.push(AudioBackendType::Pulse); } + + // JACK (#263 Tier 3): foundation in place (jack_backend.rs + the enum + + // the factory) but NOT yet offered in the selector — the player wiring + // (StreamType::Jack + the dispatch + PlaybackEngine::Jack feeder/resampler) + // is still pending. Re-enable this push once that lands (Tier-3 handoff). + // backends.push(AudioBackendType::Jack); } #[cfg(not(target_os = "linux"))] @@ -348,6 +363,20 @@ impl BackendManager { Err("PulseAudio backend only available on Linux".to_string()) } } + AudioBackendType::Jack => { + // JACK streams are created directly in the player dispatch + // (qbz-player), NOT via the MixerDeviceSink AudioBackend trait. + // This arm exists only so the factory stays exhaustive; the + // returned backend is never used to open a JACK stream. + #[cfg(target_os = "linux")] + { + Ok(Box::new(CpalDefaultBackend::new()?)) + } + #[cfg(not(target_os = "linux"))] + { + Err("JACK backend only available on Linux".to_string()) + } + } } } diff --git a/crates/qbz-audio/src/jack_backend.rs b/crates/qbz-audio/src/jack_backend.rs new file mode 100644 index 000000000..0f61bb05b --- /dev/null +++ b/crates/qbz-audio/src/jack_backend.rs @@ -0,0 +1,173 @@ +//! Native JACK output backend (#263 Tier 3). +//! +//! QBZ appears as a first-class JACK client (`qbz`) with stable output ports +//! `qbz:out_FL` / `qbz:out_FR`, patchable in qjackctl / qpwgraph / Reaper. The +//! routing survives track changes because the client + ports are created ONCE +//! and live for the whole session. +//! +//! **NOT bit-perfect.** A JACK graph runs at ONE fixed sample rate, so audio is +//! resampled to the graph rate (by the player's feeder) before it reaches us. +//! This is the documented opt-in trade: routing freedom over per-track +//! bit-perfect. The ALSA-exclusive / DAC-passthrough paths are untouched. +//! +//! Architecture: a lock-free SPSC ring buffer sits between the player's feeder +//! thread (push, via [`JackStream::write_f32`]) and the JACK `process` callback +//! (pop, runs in JACK's real-time thread). The callback never allocates or +//! locks beyond the lock-free ring read. + +use jack::{AudioOut, Client, ClientOptions, Control, Port, ProcessScope}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +/// Ring buffer headroom in stereo frames (~1.5 s at 44.1 kHz). Sized generously +/// so the feeder never blocks the audio decode under normal scheduling. +const RING_CAPACITY_FRAMES: usize = 1 << 16; // 65536 + +/// Max stereo frames a single `process` cycle will ever request; the reusable +/// de-interleave scratch is pre-sized to this so the RT callback never allocates. +const MAX_NFRAMES: usize = 16384; + +/// JACK `process` handler (runs in JACK's RT thread). Pops interleaved stereo +/// f32 from the ring buffer and de-interleaves into the two output ports. +struct JackProcess { + reader: jack::RingBufferReader, + out_l: Port, + out_r: Port, + /// Reusable interleaved scratch (pre-sized; no RT allocation). + scratch: Vec, + underruns: Arc, +} + +impl jack::ProcessHandler for JackProcess { + fn process(&mut self, _client: &Client, ps: &ProcessScope) -> Control { + let nframes = (ps.n_frames() as usize).min(MAX_NFRAMES); + let need_samples = nframes * 2; // stereo interleaved + + // Read interleaved f32 (as bytes) from the lock-free ring buffer into the + // pre-allocated scratch. No allocation: scratch is sized to MAX_NFRAMES*2. + let scratch_bytes: &mut [u8] = unsafe { + std::slice::from_raw_parts_mut( + self.scratch.as_mut_ptr() as *mut u8, + need_samples * std::mem::size_of::(), + ) + }; + let got_bytes = self.reader.read_buffer(scratch_bytes); + let got_samples = got_bytes / std::mem::size_of::(); + + let l = self.out_l.as_mut_slice(ps); + let r = self.out_r.as_mut_slice(ps); + for i in 0..nframes { + let li = i * 2; + let ri = li + 1; + l[i] = if li < got_samples { self.scratch[li] } else { 0.0 }; + r[i] = if ri < got_samples { self.scratch[ri] } else { 0.0 }; + } + + if got_samples < need_samples { + self.underruns.fetch_add(1, Ordering::Relaxed); + } + Control::Continue + } +} + +/// An active JACK client plus the producer side of the audio ring buffer. +/// +/// Mirrors the role of `AlsaDirectStream`: the player feeds interleaved f32 via +/// [`write_f32`](Self::write_f32); a long-lived feeder thread paces the writes. +/// Dropping this deactivates + closes the JACK client (ports disappear). +pub struct JackStream { + /// Activated async client; kept alive for the stream's lifetime. Its `Drop` + /// deactivates the client and unregisters the ports. + _async_client: jack::AsyncClient<(), JackProcess>, + writer: Mutex, + sample_rate: u32, + channels: u16, + underruns: Arc, +} + +impl JackStream { + /// Open the JACK client, register stable stereo ports, and activate. + /// + /// `channels` is the player's channel count; JACK output is stereo here + /// (FL/FR). The player resamples + downmixes to stereo at the graph rate + /// before feeding, so the ring carries interleaved stereo f32. + pub fn new(channels: u16) -> Result { + let (client, _status) = Client::new("qbz", ClientOptions::NO_START_SERVER) + .map_err(|e| format!("JACK client open failed (is a JACK/pipewire-jack server running?): {e}"))?; + + let sample_rate = client.sample_rate() as u32; + + let out_l = client + .register_port("out_FL", AudioOut::default()) + .map_err(|e| format!("JACK register out_FL failed: {e}"))?; + let out_r = client + .register_port("out_FR", AudioOut::default()) + .map_err(|e| format!("JACK register out_FR failed: {e}"))?; + + // Lock-free SPSC ring buffer (bytes). Holds interleaved stereo f32. + let rb = jack::RingBuffer::new(RING_CAPACITY_FRAMES * 2 * std::mem::size_of::()) + .map_err(|e| format!("JACK ring buffer alloc failed: {e}"))?; + let (reader, writer) = rb.into_reader_writer(); + + let underruns = Arc::new(AtomicU64::new(0)); + let process = JackProcess { + reader, + out_l, + out_r, + scratch: vec![0.0f32; MAX_NFRAMES * 2], + underruns: underruns.clone(), + }; + + let async_client = client + .activate_async((), process) + .map_err(|e| format!("JACK activate failed: {e}"))?; + + log::info!( + "[JACK] client 'qbz' active at {} Hz — ports qbz:out_FL / qbz:out_FR (NOT bit-perfect: resampled to the graph rate)", + sample_rate + ); + + Ok(Self { + _async_client: async_client, + writer: Mutex::new(writer), + sample_rate, + channels, + underruns, + }) + } + + /// The JACK graph sample rate. The player resamples each track to this rate + /// before feeding (the graph rate can change at runtime — the player should + /// re-read this on a `buffer_size`/`sample_rate` change; TODO Slice 3.2). + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u16 { + self.channels + } + + /// Push interleaved stereo f32 (already at the graph rate) into the ring + /// buffer. Called from the player's feeder thread. Returns the number of + /// *frames* accepted; fewer than requested means the ring is full and the + /// feeder should retry (it paces itself against real time). + pub fn write_f32(&self, samples: &[f32]) -> usize { + let bytes: &[u8] = unsafe { + std::slice::from_raw_parts( + samples.as_ptr() as *const u8, + std::mem::size_of_val(samples), + ) + }; + let written = { + let mut w = self.writer.lock().unwrap(); + w.write_buffer(bytes) + }; + (written / std::mem::size_of::()) / 2 + } + + /// Total underrun events since open (diagnostic). + #[allow(dead_code)] + pub fn underruns(&self) -> u64 { + self.underruns.load(Ordering::Relaxed) + } +} diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 895df4d8b..dd99ef74d 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -35,6 +35,8 @@ pub mod alsa_backend; pub mod alsa_error_handler; #[cfg(target_os = "linux")] pub mod pulse_backend; +#[cfg(target_os = "linux")] +pub mod jack_backend; pub mod alsa_direct; pub mod coreaudio_direct; pub mod analysis; @@ -57,6 +59,8 @@ pub use alsa_backend::{ resolve_stable_to_current_hw, }; pub use alsa_direct::AlsaDirectStream; +#[cfg(target_os = "linux")] +pub use jack_backend::JackStream; pub use analysis::SpectralAnalyzer; pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap}; pub use backend::{ diff --git a/crates/qbz-slint/src/settings.rs b/crates/qbz-slint/src/settings.rs index 1204e546a..9d5fca764 100644 --- a/crates/qbz-slint/src/settings.rs +++ b/crates/qbz-slint/src/settings.rs @@ -235,7 +235,8 @@ fn device_is_bit_perfect(backend: AudioBackendType, device: &qbz_audio::AudioDev alsa_section(&device.id, device.is_default, label) == AlsaSection::BitPerfect } AudioBackendType::PipeWire => device.is_hardware, - AudioBackendType::Pulse | AudioBackendType::SystemDefault => false, + // JACK never bit-perfect (resampled to the graph rate); no per-device concept. + AudioBackendType::Pulse | AudioBackendType::SystemDefault | AudioBackendType::Jack => false, } } @@ -245,6 +246,7 @@ fn backend_label(t: AudioBackendType) -> String { AudioBackendType::Alsa => "ALSA", AudioBackendType::Pulse => "PulseAudio", AudioBackendType::SystemDefault => "System default", + AudioBackendType::Jack => "JACK", } .to_string() }