From 28e86e81fe3cfcdd1e78297c6d417f3fdad91f86 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 18:47:20 +0200 Subject: [PATCH 1/9] feat(audio): let linux own the sound card for ordinary playback, not just dsd The exclusive backends already existed on Linux and macOS, but both only carried DoP: a DSD file could take the DAC outright while a FLAC could not, and the dispatcher said so plainly, gating the whole preference behind cfg(windows) and discarding the flag everywhere else. The ALSA backend now carries PCM as well. It negotiates a format down a chain the hardware itself has to accept, since there is no plug layer under a raw hw: device, and it reuses what the DoP path already worked out: the reservation protocol that asks PipeWire for the card rather than reading EBUSY as an answer, and the partial-write handling, now shared instead of copied. The rate is where the two streams differ. DoP demands its exact rate because nothing may resample a marker cadence; PCM only prefers one, and takes whatever the device lands on so the decoder can meet it. So this is the mixer's absence, not the source rate honoured end to end, and the copy no longer says bit-perfect. The preference outgrew its name and is now exclusive_output. The stored key follows, with the boot read still accepting the old audio.wasapi_exclusive row so a Windows opt-in survives the rename. Two things reported wrong before: the ALSA and CoreAudio handles denied owning a device they had taken exclusively, which WASAPI has always reported honestly. And read literally, an engine that had never opened an output asks for zero channels, which a card that accepts mono would have granted. --- .../crates/app/src/audio/alsa_exclusive.rs | 885 ++++++++++++++++-- .../app/src/audio/coreaudio_exclusive.rs | 6 +- src-tauri/crates/app/src/audio/engine.rs | 109 +-- src-tauri/crates/app/src/audio/output.rs | 59 +- .../crates/app/src/audio/wasapi_exclusive.rs | 2 +- src-tauri/crates/app/src/commands/player.rs | 52 +- src-tauri/crates/app/src/lib.rs | 19 +- src/components/views/SettingsView.tsx | 4 +- .../views/settings/ExclusiveModeCard.tsx | 47 +- src/i18n/locales/ar.json | 4 +- src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/hi.json | 4 +- src/i18n/locales/id.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/nl.json | 4 +- src/i18n/locales/pt-BR.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/tr.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- src/lib/tauri/player.ts | 26 +- 27 files changed, 1025 insertions(+), 252 deletions(-) diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index 5fcf186f..c24ae62b 100644 --- a/src-tauri/crates/app/src/audio/alsa_exclusive.rs +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -1,17 +1,34 @@ -//! Linux-only ALSA hardware-exclusive DoP output backend (#495). +//! Linux-only ALSA hardware-exclusive output backend (#495 for DoP, then +//! ordinary PCM). //! //! The Linux equivalent of [`super::wasapi_exclusive`]: it opens the DAC //! as a **raw `hw:` device** (never `default` / `plughw:` / a Pulse or //! PipeWire alias), which bypasses the system mixer + resampler and gives -//! us exclusive, bit-perfect access — the only way a DoP marker cadence -//! survives to the DAC. The stream is opened at the exact DoP rate -//! (`dsd_rate / 16`) in **`S32_LE`**, and each 24-bit DoP word is placed -//! MSB-justified in the 32-bit sample (marker in the top byte) via -//! [`super::dop_pack::fill_dop_period_i32`]. +//! us exclusive access to the hardware. //! -//! If the DAC won't accept `S32_LE` at the DoP rate the open fails and -//! the engine falls back to the ordinary DSD → PCM path (through cpal -//! shared). +//! It carries two kinds of stream, and the difference between them is +//! the rate: +//! +//! - **DoP** (`Some(DopFormat)`) — opened at the exact DoP rate +//! (`dsd_rate / 16`) in **`S32_LE`**, each 24-bit DoP word placed +//! MSB-justified in the 32-bit sample (marker in the top byte) via +//! [`super::dop_pack::fill_dop_period_i32`]. The rate is a *demand*: +//! the marker cadence only survives if nothing resamples it, so a +//! device that can't do this exact rate fails the open and the engine +//! falls back to ordinary DSD → PCM through cpal shared. +//! - **PCM** (`None`) — the audiophile path for every other track. Here +//! the rate is a *preference*: the decoder's rubato stage converts to +//! whatever the device lands on, exactly as it does for cpal shared, +//! so we take the device's answer and publish it. What exclusive buys +//! is the mixer's absence, not the source rate — see the note on +//! source-rate negotiation below. +//! +//! **Scope, same as WASAPI's:** this is "bypass the system mixer", +//! not yet "honor the source rate exactly". `hw:` guarantees no +//! resampling *below* us; the resampling that remains is our own, and +//! moving it out means re-opening the device per track. That's a +//! separate phase, and it is the one that would make the word +//! bit-perfect true end to end. //! //! A device held by *another client* is a different story, and used to //! end the same way: on a desktop the holder is PipeWire or PulseAudio, @@ -29,7 +46,7 @@ use std::sync::Arc; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use alsa::pcm::{Access, Format, HwParams, State, PCM}; +use alsa::pcm::{Access, Format, HwParams, State, IO, PCM}; use alsa::{Direction, ValueOr}; use crossbeam_channel::{bounded, Receiver, Sender}; use rtrb::{Consumer, Producer, RingBuffer}; @@ -39,16 +56,17 @@ use super::output::{DopFormat, OutputHandle, RING_CAPACITY}; use super::state::SharedPlayback; use crate::error::{AppError, AppResult}; -/// Spawn the ALSA DoP output thread. Mirrors +/// Spawn the ALSA exclusive output thread. Mirrors /// [`super::wasapi_exclusive::spawn_exclusive_output_thread`]'s contract: /// returns the decoder-side `Producer` and an [`OutputHandle`], or an /// error (device busy / format unsupported / no such device) surfaced -/// synchronously so the caller can fall back to DSD → PCM. -pub fn spawn_alsa_dop_output_thread( +/// synchronously so the caller can fall back — to DSD → PCM for a DoP +/// request, to cpal shared mode for a PCM one. +pub fn spawn_alsa_exclusive_output_thread( shared: Arc, app: AppHandle, device_name: Option, - dop: DopFormat, + dop: Option, ) -> AppResult<(Producer, OutputHandle)> { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); let (shutdown_tx, shutdown_rx) = bounded::<()>(1); @@ -58,9 +76,12 @@ pub fn spawn_alsa_dop_output_thread( let thread_app = app.clone(); let thread_device = device_name.clone(); let join: JoinHandle<()> = std::thread::Builder::new() - .name("waveflow-alsa-dop".into()) - .spawn(move || { - output_thread_main( + .name(match dop { + Some(_) => "waveflow-alsa-dop".into(), + None => "waveflow-alsa-exclusive".to_string(), + }) + .spawn(move || match dop { + Some(dop) => output_thread_main( thread_shared, consumer, shutdown_rx, @@ -68,9 +89,17 @@ pub fn spawn_alsa_dop_output_thread( thread_app, thread_device, dop, - ) + ), + None => pcm_output_thread_main( + thread_shared, + consumer, + shutdown_rx, + init_tx, + thread_app, + thread_device, + ), }) - .map_err(|e| AppError::Audio(format!("spawn alsa dop thread: {e}")))?; + .map_err(|e| AppError::Audio(format!("spawn alsa exclusive thread: {e}")))?; match init_rx.recv() { Ok(Ok(())) => Ok(( @@ -79,8 +108,14 @@ pub fn spawn_alsa_dop_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, - dop: Some(dop), + // We hold the card through a raw `hw:` handle: nothing + // else can mix into it while this thread lives. That is + // true of the DoP stream too — it used to report `false` + // here, which made the pipeline panel deny an exclusive + // grab that had in fact happened (WASAPI has always + // reported `true` for both). + exclusive: true, + dop, }, )), Ok(Err(err)) => { @@ -88,7 +123,7 @@ pub fn spawn_alsa_dop_output_thread( Err(err) } Err(_) => Err(AppError::Audio( - "alsa dop thread died before reporting init result".into(), + "alsa exclusive thread died before reporting init result".into(), )), } } @@ -122,41 +157,19 @@ fn output_thread_main( // The reservation is bound alongside the PCM and dropped with it: // holding a card we are no longer playing on would keep the sound // server locked out of it. - let (_reservation, pcm, period_frames) = match open_pcm(&dev, dop) { - Ok((pcm, period_frames)) => (None, pcm, period_frames), - Err(failure) if failure.busy => { - // Someone else owns the card. On any desktop that is the - // sound server, and the protocol below is how you ask it to - // step aside; before this, the answer was always to give up. - let reservation = - hw_card_index(&dev).and_then(super::device_reservation::Reservation::acquire); - let Some(reservation) = reservation else { + let (_reservation, (pcm, period_frames)) = + match open_reserving_the_card(&dev, "dop", || open_dop_pcm(&dev, dop)) { + Ok(opened) => opened, + Err(err) => { tracing::warn!( + %err, device = %dev, - "alsa dop: the card is busy and could not be reserved; falling back to DSD -> PCM" + "alsa dop init failed; falling back to DSD -> PCM" ); - let _ = init_tx.send(Err(failure.err)); + let _ = init_tx.send(Err(err)); return; - }; - match open_after_release(&dev, dop) { - Ok((pcm, period_frames)) => (Some(reservation), pcm, period_frames), - Err(failure) => { - tracing::warn!( - err = %failure.err, - device = %dev, - "alsa dop: the card stayed busy after the reservation" - ); - let _ = init_tx.send(Err(failure.err)); - return; - } } - } - Err(failure) => { - tracing::warn!(err = %failure.err, device = %dev, "alsa dop init failed"); - let _ = init_tx.send(Err(failure.err)); - return; - } - }; + }; // `io_i32` borrows the PCM, so it lives in this frame alongside it. let io = match pcm.io_i32() { @@ -215,38 +228,14 @@ fn output_thread_main( } } - // Blocking write (the PCM was opened blocking), but `writei` is - // still allowed to accept fewer frames than we offered — on a - // signal, or after a recovery that swallowed part of the period. - // The tail has to be re-offered rather than dropped: a hole in - // the stream shifts every following frame against the DoP marker - // cadence the DAC is locked onto. Only the *unwritten* remainder - // is re-sent, never the frames the device already took. - let mut frames_done = 0usize; - while frames_done < period_frames { - match io.writei(&buf[frames_done * channels..]) { - Ok(0) => { - // A blocking device that reports no progress and no - // error has nothing left to recover from. - break 'run ExitReason::DeviceLost( - "alsa accepted 0 frames on a blocking write".into(), - ); - } - Ok(n) => frames_done += n, - Err(err) => { - if shutdown_rx.try_recv().is_ok() { - break 'run ExitReason::Shutdown; - } - if let Err(rec) = pcm.try_recover(err, true) { - tracing::warn!(?rec, "alsa dop write failed and recovery failed"); - break 'run ExitReason::DeviceLost(format!("alsa write failed: {rec}")); - } - // Recovered — re-prepare if needed, then retry the tail. - if pcm.state() == State::Setup { - let _ = pcm.prepare(); - } - } - } + // One `i32` per channel per frame, so the item stride of a frame + // is the channel count. Re-offering a partial write matters most + // here: a hole shifts every following frame against the DoP + // marker cadence the DAC is locked onto. + match write_full_period(&pcm, &io, &buf, channels, period_frames, &shutdown_rx) { + PeriodOutcome::Written => {} + PeriodOutcome::Shutdown => break 'run ExitReason::Shutdown, + PeriodOutcome::DeviceLost(reason) => break 'run ExitReason::DeviceLost(reason), } if shutdown_rx.try_recv().is_ok() { @@ -348,19 +337,71 @@ impl From for PcmOpenError { } } +/// Take the card, asking the sound server to step aside if it holds it. +/// +/// Shared by both streams because the obstacle is the same one: on any +/// desktop the holder of a `hw:` device is PipeWire or PulseAudio, which +/// grabbed the card at login. Before the reservation protocol existed the +/// answer to that was always to give up. +/// +/// The returned reservation must be kept alive alongside the PCM and +/// dropped with it — holding a card we no longer play on would keep the +/// sound server locked out of it. `None` means the card was free and +/// nothing had to be asked. +/// +/// `what` only labels the log lines; the caller still reports the +/// failure in its own terms, because what happens next differs (a DoP +/// stream falls back to DSD → PCM, a PCM stream to cpal shared). +fn open_reserving_the_card( + dev: &str, + what: &str, + mut attempt: impl FnMut() -> Result, +) -> Result<(Option, T), AppError> { + match attempt() { + Ok(opened) => Ok((None, opened)), + Err(failure) if failure.busy => { + let Some(reservation) = + hw_card_index(dev).and_then(super::device_reservation::Reservation::acquire) + else { + tracing::warn!( + device = %dev, + %what, + "alsa: the card is busy and could not be reserved" + ); + return Err(failure.err); + }; + match open_after_release(attempt) { + Ok(opened) => Ok((Some(reservation), opened)), + Err(failure) => { + tracing::warn!( + err = %failure.err, + device = %dev, + %what, + "alsa: the card stayed busy after the reservation" + ); + Err(failure.err) + } + } + } + Err(failure) => Err(failure.err), + } +} + /// Retry the open while the sound server finishes letting go. /// /// Releasing is asynchronous on its side — it sees `NameLost`, plays /// out what it has buffered and only then closes the device — so the /// first open after the reservation lands still returns `EBUSY`. -fn open_after_release(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { +fn open_after_release( + mut attempt: impl FnMut() -> Result, +) -> Result { const STEP: Duration = Duration::from_millis(50); let deadline = Instant::now() + super::device_reservation::RELEASE_GRACE; loop { // Try before waiting: a server that let go promptly costs - // nothing, and a device that simply cannot do this DoP rate + // nothing, and a device that simply cannot do what we're asking // says so on the first attempt. - match open_pcm(dev, dop) { + match attempt() { Ok(opened) => return Ok(opened), Err(failure) if failure.busy && Instant::now() < deadline => { std::thread::sleep(STEP); @@ -374,7 +415,7 @@ fn open_after_release(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpen /// negotiated period size (frames). A rejection (busy device, rate / /// format unsupported) is an error → the caller either reserves the /// card and retries, or falls back to DSD → PCM. -fn open_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { +fn open_dop_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { let pcm = PCM::new(dev, Direction::Playback, false).map_err(|e| PcmOpenError { busy: e.errno() == libc::EBUSY, err: AppError::Audio(format!("alsa open {dev}: {e}")), @@ -426,10 +467,678 @@ fn open_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { Ok((pcm, period_frames)) } +/// Rate asked for when the engine has not settled on one yet (nothing +/// has played since launch). 48 kHz rather than 44.1 because it is the +/// rate a modern DAC is most likely to run natively. +const DEFAULT_PCM_RATE: u32 = 48_000; + +/// The wire formats the PCM path will accept from a `hw:` device, best +/// first. Falling all the way through means the device speaks nothing we +/// can write, and the caller drops to cpal shared mode. +/// +/// This mirrors [`super::wasapi_exclusive`]'s chain in intent but **not** +/// in layout — see [`AlsaSampleFormat::S24In32`] for the one place where +/// copying the other backend's packer would be a 48 dB mistake. +const FORMAT_FALLBACK_CHAIN: [AlsaSampleFormat; 5] = [ + AlsaSampleFormat::F32, + AlsaSampleFormat::S32, + AlsaSampleFormat::S24Packed, + AlsaSampleFormat::S24In32, + AlsaSampleFormat::S16, +]; + +/// A sample format a raw `hw:` device can be opened with. +/// +/// There is no plug layer underneath us — that is the entire point of +/// `hw:` — so every one of these is a format the hardware itself +/// accepts, and the conversion from the ring's `f32` is ours to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AlsaSampleFormat { + /// `SND_PCM_FORMAT_FLOAT_LE`. Exactly what the ring already holds, + /// so the "conversion" is a byte copy. Uncommon on USB DACs (the + /// converter is integer), routine on HDMI and on some onboard + /// codecs — worth one attempt for the devices that do take it. + F32, + /// `SND_PCM_FORMAT_S32_LE`. The whole 32-bit container is + /// significant. What most audiophile USB DACs advertise even when + /// the converter behind it stops at 24 bits, which costs us + /// nothing: an `f32` carries 24 bits of mantissa anyway. + S32, + /// `SND_PCM_FORMAT_S24_3LE` — 24 bits, three bytes, no container + /// and so no alignment question. Preferred over [`Self::S24In32`] + /// for exactly that reason. + S24Packed, + /// `SND_PCM_FORMAT_S24_LE`: 24 bits **right-aligned in the low three + /// bytes** of a 32-bit word. + /// + /// This is the exact opposite of WASAPI's `Pcm24Padded`, where + /// `WAVEFORMATEXTENSIBLE` left-aligns the valid bits and zero-pads + /// the bottom. The same 24 bits, shifted by 8 in opposite + /// directions: apply that backend's `<< 8` here and every sample is + /// multiplied by 256 into permanent clipping; apply this one's + /// layout there and the signal comes out 48 dB down. Two APIs, two + /// conventions, one standing temptation to copy the other's packer. + /// + /// The top byte carries the sign extension a plain `i32` store + /// produces. ALSA defines the format as "the low three bytes", so + /// the driver reads those and what sits above them is ignored. + S24In32, + /// `SND_PCM_FORMAT_S16_LE`. Universal last resort. + S16, +} + +impl AlsaSampleFormat { + /// Bytes this format occupies on the wire, per channel per frame. + fn bytes_per_sample(self) -> usize { + match self { + Self::F32 | Self::S32 | Self::S24In32 => 4, + Self::S24Packed => 3, + Self::S16 => 2, + } + } + + fn to_alsa(self) -> Format { + match self { + Self::F32 => Format::FloatLE, + Self::S32 => Format::S32LE, + Self::S24Packed => Format::S243LE, + Self::S24In32 => Format::S24LE, + Self::S16 => Format::S16LE, + } + } + + /// Label for the diagnostics line, matching ALSA's own spelling so + /// it can be grepped against `aplay --dump-hw-params`. + fn label(self) -> &'static str { + match self { + Self::F32 => "FLOAT_LE", + Self::S32 => "S32_LE", + Self::S24Packed => "S24_3LE", + Self::S24In32 => "S24_LE", + Self::S16 => "S16_LE", + } + } +} + +/// Pack the mixed `f32` samples into the byte image the negotiated +/// format expects, little-endian throughout. +/// +/// Saturation is done by clamping to `[-1.0, 1.0]` before scaling rather +/// than by checking the result: `as` casts on floats saturate in Rust, +/// so an out-of-range sample would land on `i32::MAX` silently instead +/// of at full scale. Gain (volume, normalize, the mono mix) is applied +/// upstream in [`fill_pcm_period`], so a sample that clips here is one +/// the chain genuinely pushed past 0 dBFS. +/// +/// Every path is bounded by both slice lengths, so a short `samples` +/// leaves the tail of `bytes` at whatever it held — the caller keeps one +/// buffer for the life of the stream and rewrites it whole each period. +fn pack_samples(format: AlsaSampleFormat, samples: &[f32], bytes: &mut [u8]) { + match format { + AlsaSampleFormat::F32 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&sample.to_le_bytes()); + } + } + AlsaSampleFormat::S32 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + // Scaled in f64 because an f32 cannot hold the scale + // factor: `2_147_483_647.0f32` rounds to 2^31, so every + // sample would be scaled by a hair too much and full + // scale would only land right because the cast saturates. + let v = (f64::from(sample.clamp(-1.0, 1.0)) * 2_147_483_647.0) as i32; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + AlsaSampleFormat::S24Packed => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(3)) { + let v = (sample.clamp(-1.0, 1.0) * 8_388_607.0) as i32; + chunk[0] = (v & 0xFF) as u8; + chunk[1] = ((v >> 8) & 0xFF) as u8; + chunk[2] = ((v >> 16) & 0xFF) as u8; + } + } + AlsaSampleFormat::S24In32 => { + // Right-aligned: the 24-bit value sits in the low three + // bytes, unshifted. See the variant's doc — this is where + // WASAPI's `<< 8` does not belong. + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + let v = (sample.clamp(-1.0, 1.0) * 8_388_607.0) as i32; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + AlsaSampleFormat::S16 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(2)) { + let v = (sample.clamp(-1.0, 1.0) * 32_767.0) as i16; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + } +} + +/// Drain one period out of the ring into `samples`, applying the same +/// per-sample chain the cpal callback and the WASAPI backend apply: +/// volume, the normalize attenuation, and the optional mono downmix. +/// +/// Returns how many samples were actually pulled. Silence written +/// because the ring ran dry is deliberately NOT counted — every backend +/// agrees on that, because `samples_played` is the only clock the +/// progress bar, the lyrics sync and play-event crediting have, and +/// crediting an underrun would make the track run ahead of itself. +fn fill_pcm_period( + shared: &SharedPlayback, + consumer: &mut Consumer, + samples: &mut [f32], + channels: usize, +) -> u64 { + let volume = shared.volume(); + let normalize = shared.normalize_enabled.load(Ordering::Relaxed); + let mono = shared.mono_enabled.load(Ordering::Relaxed); + // Normalization applies a -3 dB reduction to leave headroom. + let norm_gain: f32 = if normalize { 0.707 } else { 1.0 }; + let mut written: u64 = 0; + + if mono && channels >= 2 { + for frame in samples.chunks_mut(channels) { + let mut sum = 0.0_f32; + let mut got = 0usize; + for _ in 0..frame.len() { + if let Ok(s) = consumer.pop() { + sum += s; + got += 1; + } + } + let value = if got > 0 { + written += got as u64; + (sum / channels as f32) * volume * norm_gain + } else { + 0.0 + }; + for slot in frame.iter_mut() { + *slot = value; + } + } + } else { + for slot in samples.iter_mut() { + *slot = match consumer.pop() { + Ok(s) => { + written += 1; + s * volume * norm_gain + } + Err(_) => 0.0, + }; + } + } + + written +} + +/// A device opened and negotiated, with the terms it agreed to. +struct OpenPcm { + pcm: PCM, + format: AlsaSampleFormat, + /// What the device landed on, which is not necessarily what we + /// asked for — the caller publishes this so the decoder resamples + /// to it. + sample_rate: u32, + channels: u16, + period_frames: usize, +} + +/// Open `dev` for ordinary PCM, walking the format chain until one +/// sticks. +/// +/// The rate is a preference here, not a demand ([`ValueOr::Nearest`]): +/// a device that only does 44.1 kHz is still a device we can drive, we +/// just publish 44.1 and let the decoder's resampler meet it. That is +/// the one substantive difference from [`open_dop_pcm`], where a rate +/// the device can't do exactly has to fail the open. +/// +/// Both preferences arrive from `SharedPlayback`, where **zero means +/// "no output has ever opened"** rather than a real value — the state +/// starts at 0 and is only filled in by whichever backend opened last. +/// Taken literally, a zero channel count would ask the card for mono +/// on the first launch with exclusive already on, and a card that +/// accepts mono would get it: every track downmixed, for as long as +/// the setting stayed on. +fn open_pcm_negotiated( + dev: &str, + preferred_rate: u32, + preferred_channels: u16, +) -> Result { + let rate = match preferred_rate { + 0 => DEFAULT_PCM_RATE, + rate => rate, + }; + let mut last: Option = None; + for channels in channel_candidates(preferred_channels) { + for format in FORMAT_FALLBACK_CHAIN { + match try_open_pcm(dev, channels, rate, format) { + Ok(opened) => return Ok(opened), + // A card another client holds gives the same answer to + // every format in the list. Stop and let the caller ask + // for it through the reservation protocol instead of + // knocking nine more times. + Err(failure) if failure.busy => return Err(failure), + Err(failure) => last = Some(failure), + } + } + } + + Err(last.unwrap_or_else(|| { + AppError::Audio(format!( + "alsa: {dev} accepted none of the formats we know how to write" + )) + .into() + })) +} + +/// Channel counts to try, in order. +/// +/// Stereo closes the list because it is the layout every DAC takes, +/// while asking for the engine's current count first keeps a +/// multichannel output from being quietly folded down to two. Zero is +/// the "nothing has opened an output yet" reading, not a request for +/// no channels — see [`open_pcm_negotiated`]. +/// +/// Split out from the open so that reading can be checked without a +/// sound card, which is the only place it can be checked at all. +fn channel_candidates(preferred: u16) -> Vec { + let mut out = vec![match preferred { + 0 => 2, + channels => channels, + }]; + if !out.contains(&2) { + out.push(2); + } + out +} + +/// One attempt at one (channels, rate, format) triple. A fresh handle +/// per attempt: a rejected `hw_params` leaves the PCM in a state we would +/// have to reason about, and re-opening a `hw:` device costs microseconds. +fn try_open_pcm( + dev: &str, + channels: u16, + rate: u32, + format: AlsaSampleFormat, +) -> Result { + let pcm = PCM::new(dev, Direction::Playback, false).map_err(|e| PcmOpenError { + busy: e.errno() == libc::EBUSY, + err: AppError::Audio(format!("alsa open {dev}: {e}")), + })?; + + { + let hwp = + HwParams::any(&pcm).map_err(|e| AppError::Audio(format!("alsa hwparams: {e}")))?; + hwp.set_channels(u32::from(channels)) + .map_err(|e| AppError::Audio(format!("alsa set_channels {channels}: {e}")))?; + hwp.set_rate(rate, ValueOr::Nearest) + .map_err(|e| AppError::Audio(format!("alsa set_rate {rate}: {e}")))?; + hwp.set_format(format.to_alsa()) + .map_err(|e| AppError::Audio(format!("alsa set_format {}: {e}", format.label())))?; + hwp.set_access(Access::RWInterleaved) + .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + pcm.hw_params(&hwp) + .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; + } + + // Scoped: `hw_params_current` borrows the PCM, and the borrow would + // otherwise still be live at the `Ok(OpenPcm { pcm, .. })` move. + let (sample_rate, channels, period_frames) = { + let hwp = pcm + .hw_params_current() + .map_err(|e| AppError::Audio(format!("alsa hw_params_current: {e}")))?; + ( + hwp.get_rate() + .map_err(|e| AppError::Audio(format!("alsa get_rate: {e}")))?, + hwp.get_channels() + .map_err(|e| AppError::Audio(format!("alsa get_channels: {e}")))?, + hwp.get_period_size() + .map_err(|e| AppError::Audio(format!("alsa get_period_size: {e}")))? + as usize, + ) + }; + if period_frames == 0 { + return Err(AppError::Audio("alsa reported a zero period size".into()).into()); + } + if channels == 0 { + return Err(AppError::Audio("alsa reported zero channels".into()).into()); + } + + pcm.prepare() + .map_err(|e| AppError::Audio(format!("alsa prepare: {e}")))?; + + Ok(OpenPcm { + pcm, + format, + sample_rate, + channels: channels as u16, + period_frames, + }) +} + +/// Why [`write_full_period`] came back. +enum PeriodOutcome { + /// The whole period reached the device. + Written, + /// The engine asked us to stop while we were writing. + Shutdown, + /// The device is gone; the string is what to report. + DeviceLost(String), +} + +/// Hand one whole period to the device, re-offering whatever `writei` +/// declined to take. +/// +/// The PCM is opened blocking, but `writei` is still allowed to accept +/// fewer frames than offered — on a signal, or after a recovery that +/// swallowed part of the period. The tail has to be re-offered rather +/// than dropped, and only the *unwritten* remainder is re-sent, never +/// frames the device already took. +/// +/// `items_per_frame` is the stride of one frame in `buf`'s own unit: +/// the channel count for the DoP path's `i32` words, the frame's byte +/// width for the PCM path's packed bytes. +fn write_full_period( + pcm: &PCM, + io: &IO<'_, S>, + buf: &[S], + items_per_frame: usize, + frames: usize, + shutdown_rx: &Receiver<()>, +) -> PeriodOutcome { + let mut frames_done = 0usize; + while frames_done < frames { + match io.writei(&buf[frames_done * items_per_frame..]) { + Ok(0) => { + // A blocking device that reports no progress and no + // error has nothing left to recover from. + return PeriodOutcome::DeviceLost( + "alsa accepted 0 frames on a blocking write".into(), + ); + } + Ok(n) => frames_done += n, + Err(err) => { + if shutdown_rx.try_recv().is_ok() { + return PeriodOutcome::Shutdown; + } + if let Err(rec) = pcm.try_recover(err, true) { + tracing::warn!(?rec, "alsa write failed and recovery failed"); + return PeriodOutcome::DeviceLost(format!("alsa write failed: {rec}")); + } + // Recovered - re-prepare if needed, then retry the tail. + if pcm.state() == State::Setup { + let _ = pcm.prepare(); + } + } + } + } + PeriodOutcome::Written +} + +/// The ordinary-PCM half of the backend: the audiophile path for every +/// track that isn't DSD. +fn pcm_output_thread_main( + shared: Arc, + mut consumer: Consumer, + shutdown_rx: Receiver<()>, + init_tx: Sender>, + app: AppHandle, + device_name: Option, +) { + let dev = match resolve_hw_device(&device_name) { + Ok(dev) => dev, + Err(err) => { + tracing::warn!(%err, "alsa exclusive: can't map the selected output to a hw: device"); + let _ = init_tx.send(Err(err)); + return; + } + }; + + // Ask for what the engine is already running at — the cpal default, + // or whatever a previous output negotiated — so that turning + // exclusive on doesn't also silently change the resampler's target. + // Both can still be zero here, meaning nothing has opened an output + // yet; `open_pcm_negotiated` owns that reading. + let preferred_rate = shared.sample_rate.load(Ordering::Acquire); + let preferred_channels = shared.channels.load(Ordering::Acquire); + + let (_reservation, opened) = match open_reserving_the_card(&dev, "pcm", || { + open_pcm_negotiated(&dev, preferred_rate, preferred_channels) + }) { + Ok(opened) => opened, + Err(err) => { + tracing::warn!( + %err, + device = %dev, + "alsa exclusive init failed; falling back to shared mode" + ); + let _ = init_tx.send(Err(err)); + return; + } + }; + let OpenPcm { + pcm, + format, + sample_rate, + channels, + period_frames, + } = opened; + + // `io_bytes` rather than a typed `io_*`: the packed image is bytes + // whatever the negotiated format is, and ALSA derives the frame + // count from the buffer's byte length against the format it agreed + // to — so one write path covers all five. + let io = pcm.io_bytes(); + + shared.sample_rate.store(sample_rate, Ordering::Release); + shared.channels.store(channels, Ordering::Release); + let _ = init_tx.send(Ok(())); + + let channels = channels as usize; + let frame_bytes = channels * format.bytes_per_sample(); + + tracing::info!( + device = %dev, + sample_rate, + channels, + format = format.label(), + period_frames, + "alsa exclusive stream opened" + ); + + let mut samples: Vec = vec![0.0; period_frames * channels]; + let mut wire: Vec = vec![0u8; period_frames * frame_bytes]; + // Every format in the chain is signed and little-endian, and silence + // in all of them is an all-zero byte pattern — so one pre-zeroed + // buffer serves the paused and drain paths whichever we landed on. + let silence: Vec = vec![0u8; period_frames * frame_bytes]; + + let exit = 'run: loop { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + + let out: &[u8] = if shared.paused_output.load(Ordering::Acquire) { + // Hard pause: write silence so the device doesn't underrun + // and click, and so the pause is heard now rather than after + // the pre-buffer drains. + &silence + } else if shared.drain_silent.load(Ordering::Acquire) { + // Drop whatever is queued and emit silence, so the decoder's + // spin-wait on an empty ring completes within one period. + while consumer.pop().is_ok() {} + &silence + } else { + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, channels); + pack_samples(format, &samples, &mut wire); + if written > 0 { + shared.samples_played.fetch_add(written, Ordering::Relaxed); + } + &wire + }; + + match write_full_period(&pcm, &io, out, frame_bytes, period_frames, &shutdown_rx) { + PeriodOutcome::Written => {} + PeriodOutcome::Shutdown => break 'run ExitReason::Shutdown, + PeriodOutcome::DeviceLost(reason) => break 'run ExitReason::DeviceLost(reason), + } + + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + }; + + // `io` borrows `pcm`; both drop here. Dropping the PCM calls + // `snd_pcm_close`, which releases the exclusive `hw:` handle for the + // next opener — the sound server included. + drop(io); + + match exit { + ExitReason::Shutdown => { + tracing::debug!("alsa exclusive output thread exiting"); + } + ExitReason::DeviceLost(reason) => { + tracing::warn!( + %reason, + "alsa exclusive output thread lost the device; requesting rebuild" + ); + super::output::notify_device_lost( + &app, + &shared, + format!("audio device error: {reason}"), + ); + super::output::schedule_device_rebuild(&app, super::output::RebuildTarget::Resolve); + } + } +} + #[cfg(test)] mod tests { use super::hw_card_index; + use super::{fill_pcm_period, pack_samples, AlsaSampleFormat}; + use crate::audio::state::SharedPlayback; + use rtrb::RingBuffer; + + #[test] + fn an_unopened_engine_asks_for_stereo_not_mono() { + // `SharedPlayback` starts at zero channels and only fills in + // when a backend opens. Read literally, the first launch with + // exclusive already on would ask the card for one channel — and + // a card that grants it would downmix every track from then on. + assert_eq!(super::channel_candidates(0), vec![2]); + } + + #[test] + fn a_known_layout_is_tried_before_stereo_and_stereo_closes_the_list() { + assert_eq!(super::channel_candidates(6), vec![6, 2]); + assert_eq!(super::channel_candidates(2), vec![2]); + // A genuinely mono device keeps its own layout first. + assert_eq!(super::channel_candidates(1), vec![1, 2]); + } + + /// The trap this whole variant exists to document. ALSA puts the 24 + /// bits in the LOW three bytes; WASAPI's 24-in-32 puts them in the + /// HIGH three. Copying that backend's `<< 8` over here would send + /// every sample out 256x too large. + #[test] + fn twenty_four_in_thirty_two_is_right_aligned_not_left() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S24In32, &[1.0], &mut bytes); + // 8_388_607 = 0x7F_FF_FF, little-endian, top byte untouched. + assert_eq!(bytes, [0xFF, 0xFF, 0x7F, 0x00]); + // The left-aligned layout would have been [0x00, 0xFF, 0xFF, 0x7F]. + assert_ne!(bytes, [0x00, 0xFF, 0xFF, 0x7F]); + } + + #[test] + fn a_negative_twenty_four_bit_sample_keeps_its_sign_extension() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S24In32, &[-1.0], &mut bytes); + // -8_388_607 as i32 = 0xFF_80_00_01. The driver reads the low + // three bytes; the 0xFF above them is the sign extension a plain + // i32 store produces and is ignored. + assert_eq!(bytes, [0x01, 0x00, 0x80, 0xFF]); + } + + #[test] + fn the_packed_form_spends_three_bytes_and_no_container() { + let mut bytes = [0u8; 3]; + pack_samples(AlsaSampleFormat::S24Packed, &[1.0], &mut bytes); + assert_eq!(bytes, [0xFF, 0xFF, 0x7F]); + } + + #[test] + fn sixteen_bit_full_scale_lands_on_the_endpoints() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S16, &[1.0, -1.0], &mut bytes); + assert_eq!(i16::from_le_bytes([bytes[0], bytes[1]]), 32_767); + assert_eq!(i16::from_le_bytes([bytes[2], bytes[3]]), -32_767); + } + + #[test] + fn a_sample_past_full_scale_saturates_instead_of_wrapping() { + // Anything above 0 dBFS is clamped before the scale, so it comes + // out at the endpoint. Wrapping here would turn a loud passage + // into a burst of full-scale noise of the opposite sign. + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S32, &[9.0], &mut bytes); + assert_eq!(i32::from_le_bytes(bytes), 2_147_483_647); + pack_samples(AlsaSampleFormat::S32, &[-9.0], &mut bytes); + assert_eq!(i32::from_le_bytes(bytes), -2_147_483_647); + } + + #[test] + fn float_output_is_a_byte_copy() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::F32, &[0.25], &mut bytes); + assert_eq!(f32::from_le_bytes(bytes), 0.25); + } + + #[test] + fn a_dry_ring_yields_silence_that_is_not_credited() { + // The counter drives the progress bar and play crediting: an + // underrun must leave the track where it was, not advance it. + let shared = SharedPlayback::new(); + let (_producer, mut consumer) = RingBuffer::::new(8); + let mut samples = [1.0_f32; 4]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 0); + assert_eq!(samples, [0.0; 4]); + } + + #[test] + fn volume_is_applied_here_because_a_raw_device_has_no_mixer() { + let shared = SharedPlayback::new(); + shared.set_volume(0.5); + let (mut producer, mut consumer) = RingBuffer::::new(8); + for _ in 0..4 { + producer.push(1.0).expect("ring has room"); + } + let mut samples = [0.0_f32; 4]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 4); + assert_eq!(samples, [0.5; 4]); + } + + #[test] + fn the_mono_downmix_averages_the_frame_across_every_channel() { + let shared = SharedPlayback::new(); + shared + .mono_enabled + .store(true, std::sync::atomic::Ordering::Relaxed); + let (mut producer, mut consumer) = RingBuffer::::new(8); + // One stereo frame, hard-panned left. + producer.push(1.0).expect("ring has room"); + producer.push(0.0).expect("ring has room"); + let mut samples = [0.0_f32; 2]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 2); + assert_eq!(samples, [0.5, 0.5]); + } + #[test] fn a_numeric_hw_name_yields_its_index() { assert_eq!(hw_card_index("hw:0,0"), Some(0)); diff --git a/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs index b13505c6..d9dc032d 100644 --- a/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs +++ b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs @@ -96,7 +96,11 @@ pub fn spawn_coreaudio_dop_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, + // Hog mode means the system stops mixing anything + // else into this device — the same ownership WASAPI + // and ALSA report. It used to say `false` here, which + // had the pipeline panel deny a grab that had happened. + exclusive: true, dop: Some(dop), }, )), diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index e44f8c79..e1c88331 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -339,15 +339,18 @@ pub struct AudioEngine { /// `set_output_device` without plumbing the handle through every /// Tauri command call site. app: AppHandle, - /// Windows-only opt-in: WASAPI Exclusive Mode preference. Read - /// at boot from `profile_setting['audio.wasapi_exclusive']`, - /// flipped by `set_wasapi_exclusive`. Used by `set_output_device` - /// to preserve the mode across hot-swaps. - wasapi_exclusive: std::sync::atomic::AtomicBool, - /// Whether the current output stream is actually running in - /// WASAPI Exclusive Mode. This can differ from the preference - /// when init falls back to cpal shared mode. - wasapi_exclusive_active: std::sync::atomic::AtomicBool, + /// Opt-in: own the output device outright rather than share it + /// with the system mixer — WASAPI Exclusive Mode on Windows, a raw + /// `hw:` device on Linux. Read at boot from + /// `profile_setting['audio.exclusive_output']`, flipped by + /// `set_exclusive_output`. Used by `set_output_device` to preserve + /// the mode across hot-swaps. Still a no-op on macOS, whose + /// exclusive backend carries DoP only. + exclusive_output: std::sync::atomic::AtomicBool, + /// Whether the current output stream really owns its device. This + /// can differ from the preference when init falls back to cpal + /// shared mode. + exclusive_output_active: std::sync::atomic::AtomicBool, /// Debounce guard for [`Self::try_rebuild_after_device_error`] /// (#175). Windows session resets and USB DAC flaps fire the /// cpal `DeviceNotAvailable` callback on a random thread; the @@ -355,9 +358,9 @@ pub struct AudioEngine { /// flap would otherwise queue two concurrent rebuilds that /// each interrupt the same track. rebuild_in_progress: std::sync::atomic::AtomicBool, - /// Session-only kill switch for WASAPI Exclusive after a flap storm + /// Session-only kill switch for exclusive output after a flap storm /// (#322). Once tripped, every rebuild / hot-swap stays on cpal - /// shared regardless of the `wasapi_exclusive` preference, so a + /// shared regardless of the `exclusive_output` preference, so a /// device that resets on every exclusive grab (Realtek onboard) /// stops thrashing and playback survives. Reset when the user /// re-toggles exclusive or picks a device. Does NOT touch the @@ -375,7 +378,7 @@ pub struct AudioEngine { rebuild_gate: Mutex, /// Last non-library source captured at the boundary of [`Self::send`] /// (#230). The three output-rebuild paths - /// ([`Self::set_output_device`], [`Self::set_wasapi_exclusive`], + /// ([`Self::set_output_device`], [`Self::set_exclusive_output`], /// [`Self::force_rebuild_output`]) snapshot /// `shared.current_track_id`; for radio and remote queues that id is a /// negative sentinel from @@ -488,14 +491,14 @@ impl AudioEngine { /// startup once the persisted `audio.output_device` profile setting /// is known. `None` means "use the OS default". /// - /// `wasapi_exclusive` is the persisted opt-in for Windows - /// Exclusive Mode (silently no-op on Linux/macOS). On a failing - /// init the engine falls back to cpal shared mode automatically; - /// see [`spawn_output_with_mode`] for the contract. + /// `exclusive_output` is the persisted opt-in for owning the + /// device (silently no-op where no exclusive PCM backend exists). + /// On a failing init the engine falls back to cpal shared mode + /// automatically; see [`spawn_output_with_mode`] for the contract. pub fn new_with_device( app: AppHandle, device_name: Option, - wasapi_exclusive: bool, + exclusive_output: bool, ) -> Arc { let (cmd_tx, cmd_rx) = unbounded::(); let shared = Arc::new(SharedPlayback::new()); @@ -505,15 +508,15 @@ impl AudioEngine { // rows and self-send the next `LoadAndPlay`. let (analytics_tx, analytics_rx) = unbounded_channel::(); - let (output, decoder, wasapi_exclusive_active) = match spawn_output_with_mode( + let (output, decoder, exclusive_output_active) = match spawn_output_with_mode( shared.clone(), app.clone(), device_name, - wasapi_exclusive, + exclusive_output, None, ) { Ok((producer, handle)) => { - let active = handle.wasapi_exclusive; + let active = handle.exclusive; // `spawn_output_thread` returns only after the cpal // stream has opened, so `shared.sample_rate` / // `shared.channels` are already populated by the time @@ -548,8 +551,8 @@ impl AudioEngine { output: Mutex::new(output), decoder: Mutex::new(decoder), app, - wasapi_exclusive: std::sync::atomic::AtomicBool::new(wasapi_exclusive), - wasapi_exclusive_active: std::sync::atomic::AtomicBool::new(wasapi_exclusive_active), + exclusive_output: std::sync::atomic::AtomicBool::new(exclusive_output), + exclusive_output_active: std::sync::atomic::AtomicBool::new(exclusive_output_active), rebuild_in_progress: std::sync::atomic::AtomicBool::new(false), exclusive_suppressed: std::sync::atomic::AtomicBool::new(false), exclusive_flaps: Mutex::new(FlapWindow::default()), @@ -740,7 +743,7 @@ impl AudioEngine { super::output::RebuildTarget::Resolve => self.current_output_device(), super::output::RebuildTarget::Device(device) => device, }; - let pref_exclusive = self.wasapi_exclusive.load(Ordering::Relaxed); + let pref_exclusive = self.exclusive_output.load(Ordering::Relaxed); // #322: an exclusive-mode flap storm. A device that resets on every // exclusive grab fires DeviceNotAvailable ~300 ms after each @@ -812,7 +815,7 @@ impl AudioEngine { /// stream was given up and the flag is still accurate. fn publish_output_lost_if_gone(&self, guard: &Option) { if guard.is_none() { - self.wasapi_exclusive_active + self.exclusive_output_active .store(false, std::sync::atomic::Ordering::Release); let _ = self.app.emit("player:audio-mode-changed", ()); } @@ -894,7 +897,7 @@ impl AudioEngine { // engages the exclusive path (raw `hw:` / hog mode), so nothing // else gates it. On any other platform DoP can't run at all. #[cfg(target_os = "windows")] - let exclusive_available = self.wasapi_exclusive.load(Ordering::Relaxed); + let exclusive_available = self.exclusive_output.load(Ordering::Relaxed); // Linux (raw `hw:`) and macOS (CoreAudio hog mode) engage the // exclusive path from the DoP toggle itself — no separate opt-in. #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -932,7 +935,7 @@ impl AudioEngine { // previous exclusive client still holds the device, so release it // first (#322 reasoning) — this path always replaces the stream. let device = guard.as_ref().and_then(|h| h.device_name.clone()); - let pref_exclusive = self.wasapi_exclusive.load(Ordering::Relaxed); + let pref_exclusive = self.exclusive_output.load(Ordering::Relaxed); if let Some(old) = guard.take() { old.stop(); } @@ -947,8 +950,8 @@ impl AudioEngine { Some(dop_fmt), ) { Ok((producer, handle)) => { - self.wasapi_exclusive_active - .store(handle.wasapi_exclusive, Ordering::Release); + self.exclusive_output_active + .store(handle.exclusive, Ordering::Release); *guard = Some(handle); let _ = self.app.emit("player:audio-mode-changed", ()); tracing::info!( @@ -978,8 +981,8 @@ impl AudioEngine { None, ) { Ok((producer, handle)) => { - self.wasapi_exclusive_active - .store(handle.wasapi_exclusive, Ordering::Release); + self.exclusive_output_active + .store(handle.exclusive, Ordering::Release); *guard = Some(handle); let _ = self.app.emit("player:audio-mode-changed", ()); Ok((Some(producer), false)) @@ -1025,7 +1028,7 @@ impl AudioEngine { // is already dead — there's no working state to roll back to. When // the old stream is shared we keep the spawn-first order so a failed // spawn can still roll back. - let pre_release = guard.as_ref().is_some_and(|h| h.wasapi_exclusive); + let pre_release = guard.as_ref().is_some_and(|h| h.exclusive); if pre_release { if was_playing { self.cmd_tx @@ -1081,8 +1084,8 @@ impl AudioEngine { return Err(err); } *guard = Some(handle); - self.wasapi_exclusive_active.store( - guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false), + self.exclusive_output_active.store( + guard.as_ref().map(|h| h.exclusive).unwrap_or(false), std::sync::atomic::Ordering::Release, ); // Settings' exclusive-mode toggle only re-reads its state on @@ -1092,7 +1095,7 @@ impl AudioEngine { let _ = self.app.emit("player:audio-mode-changed", ()); // Resume best-effort. Same async pattern as - // `set_output_device` and `set_wasapi_exclusive` — pull the + // `set_output_device` and `set_exclusive_output` — pull the // track row off the synchronous path so a slow DB doesn't // hold the audio recovery up. Radio sessions resume by // re-dispatching the cached `LoadUrlAndPlay` instead of @@ -1126,7 +1129,7 @@ impl AudioEngine { // Fetch ReplayGain at resume time so a user who // enabled the toggle keeps their analysed gain // across an unintended device flap — matches - // set_output_device and set_wasapi_exclusive. + // set_output_device and set_exclusive_output. let replay_gain = crate::commands::player::fetch_replay_gain(&pool, track_id).await; let _ = cmd_tx.send(AudioCmd::LoadAndPlay { @@ -1197,7 +1200,7 @@ impl AudioEngine { self.shared.clone(), self.app.clone(), device_name, - self.wasapi_exclusive + self.exclusive_output .load(std::sync::atomic::Ordering::Relaxed), None, )?; @@ -1232,7 +1235,7 @@ impl AudioEngine { })(); if let Err(err) = send_result { handle.stop(); - // Mirror force_rebuild_output / set_wasapi_exclusive: if the + // Mirror force_rebuild_output / set_exclusive_output: if the // SwapProducer send failed the closure had already run // `guard.take()`, so no output thread remains — the exclusive // flag must stop claiming one and Settings must re-read (#405). @@ -1243,8 +1246,8 @@ impl AudioEngine { } *guard = Some(handle); - self.wasapi_exclusive_active.store( - guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false), + self.exclusive_output_active.store( + guard.as_ref().map(|h| h.exclusive).unwrap_or(false), std::sync::atomic::Ordering::Release, ); // See force_rebuild_output's comment (issue #405) — a device @@ -1306,9 +1309,9 @@ impl AudioEngine { /// Flip the WASAPI Exclusive Mode preference and re-open the /// output stream using the new mode. No-ops on non-Windows. /// Re-uses the active device name so the user keeps their pick. - pub fn set_wasapi_exclusive(&self, enabled: bool) -> AppResult<()> { + pub fn set_exclusive_output(&self, enabled: bool) -> AppResult<()> { let previous = self - .wasapi_exclusive + .exclusive_output .swap(enabled, std::sync::atomic::Ordering::Relaxed); if previous == enabled { return Ok(()); @@ -1345,11 +1348,11 @@ impl AudioEngine { // order that's correct for a device *switch*, where the two streams // target different endpoints — therefore fails every time here. The // command returned `Err`, so the preference was never persisted and - // `wasapi_exclusive_active` kept reporting the old mode: the toggle + // `exclusive_output_active` kept reporting the old mode: the toggle // sat latched on the very mode the user was trying to leave, with a // restart as the only way out. Release the old exclusive stream // FIRST so the new open finds a free device. - let pre_release = guard.as_ref().is_some_and(|h| h.wasapi_exclusive); + let pre_release = guard.as_ref().is_some_and(|h| h.exclusive); if pre_release { if was_playing { if let Err(e) = self.cmd_tx.send(AudioCmd::Stop) { @@ -1362,7 +1365,7 @@ impl AudioEngine { // apply. (The spawn / send_result failure paths below // deliberately keep the new pref instead, because by // then the old stream is already gone.) - self.wasapi_exclusive + self.exclusive_output .store(previous, std::sync::atomic::Ordering::Relaxed); return Err(AppError::Audio(format!( "audio command channel closed: {e}" @@ -1408,7 +1411,7 @@ impl AudioEngine { // handle, so there is no output thread at all. Tell // Settings the flag is stale (#405), then schedule a // rebuild that re-opens in the mode now recorded in - // `wasapi_exclusive`. Pass `active` explicitly: the + // `exclusive_output`. Pass `active` explicitly: the // teardown emptied `self.output`, so a self-resolve // would reopen the OS default instead of the user's // device. @@ -1424,13 +1427,13 @@ impl AudioEngine { // as the failed-Stop path — otherwise a later // device-error rebuild would read the new pref and flip // to the exclusive mode this toggle never applied. - self.wasapi_exclusive + self.exclusive_output .store(previous, std::sync::atomic::Ordering::Relaxed); } return Err(err); } }; - let active_mode = handle.wasapi_exclusive; + let active_mode = handle.exclusive; // Group the whole hand-off so ANY failing step still runs the // `handle.stop()` below. `handle` owns a live output thread on a @@ -1464,7 +1467,7 @@ impl AudioEngine { return Err(err); } *guard = Some(handle); - self.wasapi_exclusive_active + self.exclusive_output_active .store(active_mode, std::sync::atomic::Ordering::Release); // Redundant with the caller's own re-read after a manual toggle // (ExclusiveModeCard.tsx), but kept for consistency with the @@ -1521,11 +1524,11 @@ impl AudioEngine { Ok(()) } - /// Whether the current output stream is actually running in - /// WASAPI Exclusive Mode. Always `false` on Linux / macOS and - /// also `false` after a Windows fallback to cpal shared mode. - pub fn wasapi_exclusive(&self) -> bool { - self.wasapi_exclusive_active + /// Whether the current output stream really owns its device — + /// `false` after a fallback to cpal shared mode, and on any + /// platform with no exclusive PCM backend. + pub fn exclusive_output(&self) -> bool { + self.exclusive_output_active .load(std::sync::atomic::Ordering::Acquire) } } diff --git a/src-tauri/crates/app/src/audio/output.rs b/src-tauri/crates/app/src/audio/output.rs index e8b96711..b7893009 100644 --- a/src-tauri/crates/app/src/audio/output.rs +++ b/src-tauri/crates/app/src/audio/output.rs @@ -240,10 +240,12 @@ pub struct DopFormat { } /// Pick the right output backend based on the runtime preference. -/// On Windows + `exclusive=true`, tries the WASAPI Exclusive backend -/// first and falls back to cpal shared if init fails (device busy, no -/// exclusive format support, COM apartment conflict, …). On other -/// platforms or with `exclusive=false`, always uses cpal. +/// With `exclusive=true`, tries the platform's exclusive backend first +/// and falls back to cpal shared if init fails (device busy, no +/// supported format, COM apartment conflict, …): WASAPI Exclusive on +/// Windows, a raw `hw:` device on Linux. macOS has an exclusive backend +/// for DoP only so far, so it still goes to cpal here. With +/// `exclusive=false`, always cpal. /// /// The fallback is silent at the caller level — the warning is logged /// so the user can see in `waveflow.log` why exclusive didn't engage. @@ -271,7 +273,12 @@ pub fn spawn_output_with_mode( Some(dop), ); #[cfg(target_os = "linux")] - return super::alsa_exclusive::spawn_alsa_dop_output_thread(shared, app, device_name, dop); + return super::alsa_exclusive::spawn_alsa_exclusive_output_thread( + shared, + app, + device_name, + Some(dop), + ); #[cfg(target_os = "macos")] return super::coreaudio_exclusive::spawn_coreaudio_dop_output_thread( shared, @@ -308,8 +315,33 @@ pub fn spawn_output_with_mode( } } } - #[cfg(not(target_os = "windows"))] - let _ = exclusive; // unused on non-Windows targets + // Linux: the same bargain through a raw `hw:` device. The card is + // usually held by PipeWire or PulseAudio at this point, so the + // backend asks for it through the reservation protocol before + // concluding it can't be had. + #[cfg(target_os = "linux")] + if exclusive { + match super::alsa_exclusive::spawn_alsa_exclusive_output_thread( + shared.clone(), + app.clone(), + device_name.clone(), + None, + ) { + Ok(pair) => { + tracing::info!("audio output: ALSA exclusive (raw hw:) engaged"); + return Ok(pair); + } + Err(err) => { + tracing::warn!( + %err, + "ALSA exclusive init failed, falling back to shared mode" + ); + } + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + let _ = exclusive; // no exclusive PCM backend on this target yet spawn_output_thread(shared, app, device_name) } @@ -340,7 +372,7 @@ pub(super) fn notify_device_lost(app: &AppHandle, shared: &Arc, /// - the cpal error callback and the WASAPI-exclusive `DeviceLost` exit /// both fire while the (now-dead) handle is still parked in /// `self.output`, so its `device_name` is still readable → [`Resolve`]; -/// - the `set_wasapi_exclusive` failure path has already `take()`n the +/// - the `set_exclusive_output` failure path has already `take()`n the /// old handle, emptying `self.output`, so a self-resolve would return /// `None` and reopen the OS default instead of the user's pick (#405) /// → [`Device`] carries the device captured before the teardown. @@ -417,10 +449,11 @@ pub struct OutputHandle { /// `None` means the OS default device. Saved so a hot-swap can /// no-op when the user picks the same device again. pub device_name: Option, - /// Whether this handle is really using WASAPI Exclusive Mode. - /// The user preference can request exclusive mode, but startup may - /// fall back to cpal shared mode when the device rejects it. - pub wasapi_exclusive: bool, + /// Whether this handle really owns its device — WASAPI Exclusive + /// Mode on Windows, a raw `hw:` handle on Linux. The user + /// preference can request it, but startup may fall back to cpal + /// shared mode when the device rejects it. + pub exclusive: bool, /// `Some(fmt)` when this output is carrying a DoP (DSD over PCM) /// stream, opened at exactly that rate (`dsd_rate / 16`) and channel /// count, #495. `None` for every ordinary PCM output. The engine @@ -492,7 +525,7 @@ pub fn spawn_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, + exclusive: false, dop: None, }, )), diff --git a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs index eb3ce0e8..e9392772 100644 --- a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs +++ b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs @@ -93,7 +93,7 @@ pub fn spawn_exclusive_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: true, + exclusive: true, dop, }, )), diff --git a/src-tauri/crates/app/src/commands/player.rs b/src-tauri/crates/app/src/commands/player.rs index 5c4203cc..4322954e 100644 --- a/src-tauri/crates/app/src/commands/player.rs +++ b/src-tauri/crates/app/src/commands/player.rs @@ -726,7 +726,7 @@ pub async fn player_get_state( repeat_mode, current_track, engine.current_output_is_dop(), - engine.wasapi_exclusive(), + engine.exclusive_output(), ); // When the engine is Idle but we resolved a resume point, use the // persisted position instead of the (zero) live counter. @@ -1819,23 +1819,32 @@ pub async fn player_set_output_device( Ok(()) } -/// Toggle WASAPI Exclusive Mode (Windows-only audiophile path). +/// Toggle exclusive output — the audiophile path where the app owns +/// the device instead of sharing it with the system mixer. /// -/// Re-opens the active output stream in exclusive event-driven mode, -/// negotiating the layout from the endpoint's own format -/// (`PKEY_AudioEngine_DeviceFormat`) first and only then the -/// shared-mode mix format (#409). Bypasses the Windows audio -/// engine so no other app can mix in / DSP / resample our audio. -/// Falls back silently to cpal shared mode if init fails (device -/// busy, unsupported format, no exclusive support on the driver) — -/// see `audio/wasapi_exclusive.rs` for the contract. +/// Re-opens the active output stream through the platform's exclusive +/// backend: /// -/// No-op on Linux / macOS; the persisted setting is still written so -/// the value follows the user across platforms. +/// - **Windows**, WASAPI Exclusive Mode, event-driven, negotiating the +/// layout from the endpoint's own format +/// (`PKEY_AudioEngine_DeviceFormat`) first and only then the +/// shared-mode mix format (#409) — see `audio/wasapi_exclusive.rs`. +/// - **Linux**, a raw `hw:` ALSA device, asking the sound server to +/// release the card first — see `audio/alsa_exclusive.rs`. /// -/// Persisted in `profile_setting['audio.wasapi_exclusive']`. +/// Either way no other app can mix in, DSP or resample our audio. Falls +/// back silently to cpal shared mode if init fails (device busy, +/// unsupported format, no exclusive support in the driver). +/// +/// Still a no-op on macOS, whose exclusive backend carries DoP only; +/// the persisted setting is written on every platform so the value +/// follows the user across machines. +/// +/// Persisted in `profile_setting['audio.exclusive_output']`. The boot +/// read in `lib.rs` also accepts the legacy `audio.wasapi_exclusive` +/// row, from when this was a Windows-only setting. #[tauri::command] -pub async fn player_set_wasapi_exclusive( +pub async fn player_set_exclusive_output( state: tauri::State<'_, AppState>, engine: tauri::State<'_, Arc>, enabled: bool, @@ -1843,7 +1852,7 @@ pub async fn player_set_wasapi_exclusive( let engine_clone: Arc = engine.inner().clone(); // Same rationale as `player_set_output_device`: opening / tearing // down a WASAPI stream blocks for a few hundred ms. - tokio::task::spawn_blocking(move || engine_clone.set_wasapi_exclusive(enabled)) + tokio::task::spawn_blocking(move || engine_clone.set_exclusive_output(enabled)) .await .map_err(|e| AppError::Audio(format!("set wasapi exclusive task: {e}")))??; if let Ok(pool) = state.require_profile_pool().await { @@ -1851,7 +1860,7 @@ pub async fn player_set_wasapi_exclusive( let stored = if enabled { "1" } else { "0" }; let _ = sqlx::query( "INSERT INTO profile_setting (key, value, value_type, updated_at) - VALUES ('audio.wasapi_exclusive', ?, 'bool', ?) + VALUES ('audio.exclusive_output', ?, 'bool', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", ) .bind(stored) @@ -1862,13 +1871,12 @@ pub async fn player_set_wasapi_exclusive( Ok(()) } -/// Read the current WASAPI Exclusive Mode state from the audio engine. -/// Always `false` on Linux / macOS. Used by the Settings card to -/// reflect whether the engine actually engaged exclusive mode (the -/// init could have silently fallen back to shared). +/// Read from the audio engine whether the output really owns its +/// device. Used by the Settings card to reflect what actually engaged, +/// since init can have silently fallen back to shared mode. #[tauri::command] -pub fn player_get_wasapi_exclusive(engine: tauri::State<'_, Arc>) -> bool { - engine.inner().wasapi_exclusive() +pub fn player_get_exclusive_output(engine: tauri::State<'_, Arc>) -> bool { + engine.inner().exclusive_output() } /// Replace the queue with the given track list and start playing at diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index d54fc267..f0f2a336 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -254,7 +254,7 @@ pub fn run() { // last picked instead of the OS default. Empty string in // the row means "follow the OS default" — see // `player_set_output_device`. - let (persisted_device, persisted_wasapi_exclusive) = + let (persisted_device, persisted_exclusive_output) = tauri::async_runtime::block_on(async { let state = app.state::(); let Ok(pool) = state.require_profile_pool().await else { @@ -268,8 +268,17 @@ pub fn run() { .ok() .flatten() .filter(|s: &String| !s.is_empty()); + // Two keys because the setting outgrew its name: it + // was `audio.wasapi_exclusive` while exclusive output + // existed only on Windows. Reading both carries a + // Windows user's opt-in across the rename, and the + // current name wins when both rows exist — the legacy + // one is then whatever they had before the last toggle. let exclusive: bool = sqlx::query_scalar::<_, String>( - "SELECT value FROM profile_setting WHERE key = 'audio.wasapi_exclusive'", + "SELECT value FROM profile_setting + WHERE key IN ('audio.exclusive_output', 'audio.wasapi_exclusive') + ORDER BY key = 'audio.exclusive_output' DESC + LIMIT 1", ) .fetch_optional(&*pool) .await @@ -282,7 +291,7 @@ pub fn run() { let engine: Arc = AudioEngine::new_with_device( engine_handle, persisted_device, - persisted_wasapi_exclusive, + persisted_exclusive_output, ); app.manage(engine); @@ -974,8 +983,8 @@ pub fn run() { commands::player::player_get_audio_settings, commands::player::player_list_output_devices, commands::player::player_set_output_device, - commands::player::player_set_wasapi_exclusive, - commands::player::player_get_wasapi_exclusive, + commands::player::player_set_exclusive_output, + commands::player::player_get_exclusive_output, commands::stats::stats_overview, commands::stats::stats_top_tracks, commands::stats::stats_top_artists, diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 6458e54f..e2a082e0 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -2561,8 +2561,8 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { - {/* WASAPI Exclusive Mode — Windows-only, the card hides - itself on other platforms via UA sniff. */} + {/* Exclusive output — Windows and Linux; the card hides + itself where there's no backend for it, via UA sniff. */} {/* Audio mono */} diff --git a/src/components/views/settings/ExclusiveModeCard.tsx b/src/components/views/settings/ExclusiveModeCard.tsx index 2739ff6a..da567aa7 100644 --- a/src/components/views/settings/ExclusiveModeCard.tsx +++ b/src/components/views/settings/ExclusiveModeCard.tsx @@ -4,17 +4,20 @@ import { Lock } from "lucide-react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { - playerGetWasapiExclusive, - playerSetWasapiExclusive, + playerGetExclusiveOutput, + playerSetExclusiveOutput, } from "../../../lib/tauri/player"; import { ToggleSwitch } from "../../common/ToggleSwitch"; /** - * WASAPI Exclusive Mode card — Windows-only audiophile path. + * Exclusive output card — the audiophile path where the app owns the + * device instead of sharing it with the system mixer. * - * Detection: we check `navigator.userAgent` for "Windows" since the - * setting is silently no-op on Linux / macOS and showing it there - * would mislead users. + * Detection: we check `navigator.userAgent` for the platforms that + * have a backend for it — Windows (WASAPI Exclusive) and Linux (a raw + * ALSA `hw:` device). The setting is still a silent no-op on macOS, + * whose exclusive backend carries DoP only, and showing a switch that + * does nothing would mislead. * * The toggle calls the backend which: * 1. Persists the preference in `profile_setting`. @@ -29,22 +32,24 @@ export function ExclusiveModeCard() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - // Windows-only gate. Sniffing UA is fine here — Tauri's WebView is - // platform-pinned, so the result is stable for the lifetime of the - // process. - const isWindows = + // Sniffing UA is fine here — Tauri's WebView is platform-pinned, so + // the result is stable for the lifetime of the process. macOS reports + // "Macintosh", so it falls out of this test on its own. + const supported = typeof navigator !== "undefined" && - navigator.userAgent.toLowerCase().includes("windows"); + ["windows", "linux"].some((os) => + navigator.userAgent.toLowerCase().includes(os), + ); useEffect(() => { - if (!isWindows) return; - playerGetWasapiExclusive() + if (!supported) return; + playerGetExclusiveOutput() .then(setEnabled) .catch((err) => { console.error("[ExclusiveModeCard] get failed", err); setEnabled(false); }); - }, [isWindows]); + }, [supported]); // The engine can rebuild the output stream on its own — a device // flap (issue #405), a device switch from the output-device picker — @@ -55,7 +60,7 @@ export function ExclusiveModeCard() { // carries no payload; a re-fetch here mirrors the one `toggle()` // already does after a manual click. useEffect(() => { - if (!isWindows) return; + if (!supported) return; let unlisten: UnlistenFn | null = null; // `listen()` is async, so the effect can unmount before it resolves. // Without this flag the cleanup below runs while `unlisten` is still @@ -66,7 +71,7 @@ export function ExclusiveModeCard() { (async () => { try { const stop = await listen("player:audio-mode-changed", () => { - playerGetWasapiExclusive() + playerGetExclusiveOutput() .then(setEnabled) .catch((err) => { console.error( @@ -88,18 +93,18 @@ export function ExclusiveModeCard() { cancelled = true; if (unlisten) unlisten(); }; - }, [isWindows]); + }, [supported]); - if (!isWindows) return null; + if (!supported) return null; const toggle = async (next: boolean) => { setBusy(true); setError(null); try { - await playerSetWasapiExclusive(next); + await playerSetExclusiveOutput(next); // Re-read so the displayed state reflects the engine's actual // mode after fallback. - const actual = await playerGetWasapiExclusive(); + const actual = await playerGetExclusiveOutput(); setEnabled(actual); if (next && !actual) { setError(t("settings.exclusive.fallback")); @@ -113,7 +118,7 @@ export function ExclusiveModeCard() { // otherwise the switch keeps showing the mode the user just tried // to leave and looks stuck. try { - setEnabled(await playerGetWasapiExclusive()); + setEnabled(await playerGetExclusiveOutput()); } catch (refreshErr) { console.error( "[ExclusiveModeCard] refresh after failed toggle", diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 140df274..e45eedd5 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1658,8 +1658,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "وضع WASAPI الحصري (Windows)", - "subtitle": "Bit-perfect: يتجاوز مازج Windows لإخراج بدون تداخل. يعود إلى الوضع المشترك إذا رفض الجهاز الوصول الحصري.", + "title": "إخراج حصري", + "subtitle": "يستحوذ على الجهاز لهذا التطبيق وحده بدلاً من مشاركته مع خالط النظام: WASAPI على Windows، وجهاز ALSA «hw:» الخام على Linux. يعود إلى الوضع المشترك إذا رفض الجهاز.", "fallback": "الجهاز لا يقبل الوضع الحصري. الرجوع إلى الوضع المشترك." }, "mono": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6c307ba1..5a1f512b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI-Exklusivmodus (Windows)", - "subtitle": "Bit-genau: umgeht den Windows-Mixer für störungsfreie Ausgabe. Fällt auf den Shared-Modus zurück, wenn das Gerät den Exklusivzugriff ablehnt.", + "title": "Exklusive Ausgabe", + "subtitle": "Übernimmt das Gerät allein für diese App, statt es mit dem System-Mixer zu teilen: WASAPI unter Windows, rohes ALSA-Gerät „hw:“ unter Linux. Fällt auf den gemeinsamen Modus zurück, wenn das Gerät ablehnt.", "fallback": "Das Gerät akzeptiert den Exklusivmodus nicht. Es wird auf den Shared-Modus ausgewichen." }, "mono": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6b07a68a..94b6c930 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI exclusive mode (Windows)", - "subtitle": "Bit-perfect: bypass the Windows mixer for interference-free output. Falls back to shared mode if the device rejects exclusive access.", + "title": "Exclusive output", + "subtitle": "Takes the device for this app alone instead of sharing it with the system mixer: WASAPI on Windows, a raw ALSA “hw:” device on Linux. Falls back to shared mode if the device refuses.", "fallback": "The device doesn't accept exclusive mode. Falling back to shared mode." }, "mono": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 19e22c63..afc40360 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Modo exclusivo WASAPI (Windows)", - "subtitle": "Bit-perfect: omite el mezclador de Windows para una salida sin interferencias. Recurre al modo compartido si el dispositivo rechaza el acceso exclusivo.", + "title": "Salida exclusiva", + "subtitle": "Toma el dispositivo solo para esta aplicación en lugar de compartirlo con el mezclador del sistema: WASAPI en Windows, dispositivo ALSA «hw:» sin procesar en Linux. Vuelve al modo compartido si el dispositivo lo rechaza.", "fallback": "El dispositivo no acepta el modo exclusivo. Cambiando al modo compartido." }, "mono": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5893b61e..b57ce4ba 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1573,8 +1573,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Mode exclusif WASAPI (Windows)", - "subtitle": "Bit-perfect : court-circuite le mixeur Windows pour une sortie audio sans interférence. Bascule en mode partagé si le périphérique refuse l'exclusivité.", + "title": "Sortie exclusive", + "subtitle": "Prend le périphérique pour cette application seule au lieu de le partager avec le mixeur du système : WASAPI sous Windows, périphérique ALSA « hw: » brut sous Linux. Revient au mode partagé si le périphérique refuse.", "fallback": "Le périphérique n'accepte pas le mode exclusif. Retour en mode partagé." }, "mono": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index e4041cca..74d23301 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1528,8 +1528,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI एक्सक्लूसिव मोड (Windows)", - "subtitle": "Bit-perfect: हस्तक्षेप-मुक्त आउटपुट के लिए Windows मिक्सर को बायपास करता है। यदि डिवाइस एक्सक्लूसिव एक्सेस अस्वीकार करता है तो शेयर्ड मोड में वापस आ जाता है।", + "title": "अनन्य आउटपुट", + "subtitle": "डिवाइस को सिस्टम मिक्सर के साथ साझा करने के बजाय केवल इस ऐप के लिए लेता है: Windows पर WASAPI, Linux पर कच्चा ALSA “hw:” डिवाइस। डिवाइस मना करे तो साझा मोड पर लौट आता है।", "fallback": "डिवाइस एक्सक्लूसिव मोड स्वीकार नहीं करता। शेयर्ड मोड पर वापस आ रहे हैं।" }, "mono": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 8455189d..d36104c2 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Mode eksklusif WASAPI (Windows)", - "subtitle": "Bit-perfect: melewati mixer Windows untuk keluaran tanpa gangguan. Kembali ke mode bersama jika perangkat menolak akses eksklusif.", + "title": "Keluaran eksklusif", + "subtitle": "Mengambil perangkat hanya untuk aplikasi ini alih-alih membaginya dengan mixer sistem: WASAPI di Windows, perangkat ALSA “hw:” mentah di Linux. Kembali ke mode berbagi jika perangkat menolak.", "fallback": "Perangkat tidak menerima mode eksklusif. Kembali ke mode bersama." }, "mono": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 2d02c9c7..121bfc7b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Modalità esclusiva WASAPI (Windows)", - "subtitle": "Bit-perfect: bypassa il mixer di Windows per un'uscita senza interferenze. Ricade sulla modalità condivisa se il dispositivo rifiuta l'accesso esclusivo.", + "title": "Uscita esclusiva", + "subtitle": "Prende il dispositivo solo per questa app invece di condividerlo con il mixer di sistema: WASAPI su Windows, dispositivo ALSA «hw:» grezzo su Linux. Torna alla modalità condivisa se il dispositivo rifiuta.", "fallback": "Il dispositivo non accetta la modalità esclusiva. Ricade sulla modalità condivisa." }, "mono": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7e91985e..ac9ee965 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1499,8 +1499,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI 排他モード (Windows)", - "subtitle": "ビットパーフェクト:Windowsミキサーをバイパスし、干渉のない出力を実現します。デバイスが排他アクセスを拒否した場合は共有モードにフォールバックします。", + "title": "排他出力", + "subtitle": "システムミキサーと共有せず、デバイスをこのアプリだけで占有します(Windows は WASAPI、Linux は生の ALSA「hw:」デバイス)。デバイスが拒否した場合は共有モードに戻ります。", "fallback": "デバイスが排他モードを受け付けません。共有モードに戻ります。" }, "mono": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 1443bd31..3620982a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1528,8 +1528,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI 독점 모드 (Windows)", - "subtitle": "비트 퍼펙트: Windows 믹서를 우회하여 간섭 없는 출력을 제공합니다. 장치가 독점 액세스를 거부하면 공유 모드로 돌아갑니다.", + "title": "독점 출력", + "subtitle": "장치를 시스템 믹서와 공유하지 않고 이 앱만 사용합니다. Windows는 WASAPI, Linux는 원시 ALSA ‘hw:’ 장치를 씁니다. 장치가 거부하면 공유 모드로 돌아갑니다.", "fallback": "장치가 독점 모드를 허용하지 않습니다. 공유 모드로 돌아갑니다." }, "mono": { diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 366134bc..6343c779 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI exclusive-modus (Windows)", - "subtitle": "Bit-perfect: omzeilt de Windows-mixer voor een storingsvrije uitvoer. Valt terug op shared-modus als het apparaat exclusive-toegang weigert.", + "title": "Exclusieve uitvoer", + "subtitle": "Neemt het apparaat alleen voor deze app in plaats van het te delen met de systeemmixer: WASAPI op Windows, een ruw ALSA-apparaat ‘hw:’ op Linux. Valt terug op de gedeelde modus als het apparaat weigert.", "fallback": "Het apparaat accepteert geen exclusive-modus. Terugvallen op shared-modus." }, "mono": { diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 775ee929..3306e84f 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1528,8 +1528,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Modo exclusivo WASAPI (Windows)", - "subtitle": "Bit-perfect: ignora o mixer do Windows para uma saída sem interferências. Volta ao modo compartilhado se o dispositivo recusar a exclusividade.", + "title": "Saída exclusiva", + "subtitle": "Assume o dispositivo só para este aplicativo em vez de compartilhá-lo com o mixer do sistema: WASAPI no Windows, dispositivo ALSA “hw:” bruto no Linux. Volta ao modo compartilhado se o dispositivo recusar.", "fallback": "O dispositivo não aceita o modo exclusivo. Voltando ao modo compartilhado." }, "mono": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index d8d446ea..cca9df11 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1528,8 +1528,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "Modo exclusivo WASAPI (Windows)", - "subtitle": "Bit-perfect: ignora o mixer do Windows para uma saída sem interferências. Volta ao modo partilhado se o dispositivo recusar a exclusividade.", + "title": "Saída exclusiva", + "subtitle": "Assume o dispositivo só para esta aplicação em vez de o partilhar com o misturador do sistema: WASAPI no Windows, dispositivo ALSA «hw:» em bruto no Linux. Volta ao modo partilhado se o dispositivo recusar.", "fallback": "O dispositivo não aceita o modo exclusivo. A voltar ao modo partilhado." }, "mono": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 0f95e444..6fbbdc61 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1608,8 +1608,8 @@ "decibels": "{{value}} дБ" }, "exclusive": { - "title": "WASAPI Exclusive (Windows)", - "subtitle": "Bit-perfect: обходит микшер Windows для вывода без помех. Откатывается на общий режим, если устройство отклоняет монопольный доступ.", + "title": "Эксклюзивный вывод", + "subtitle": "Занимает устройство только для этого приложения вместо совместного использования с системным микшером: WASAPI в Windows, сырое устройство ALSA «hw:» в Linux. Возвращается к общему режиму, если устройство отказывает.", "fallback": "Устройство не поддерживает Exclusive. Откат на общий режим." }, "mono": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 11d81454..1d1bca75 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1520,8 +1520,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI özel modu (Windows)", - "subtitle": "Bit-perfect: parazitsiz çıkış için Windows mikserini atlar. Cihaz özel erişimi reddederse paylaşımlı moda geri döner.", + "title": "Özel çıkış", + "subtitle": "Aygıtı sistem karıştırıcısıyla paylaşmak yerine yalnızca bu uygulama için alır: Windows’ta WASAPI, Linux’ta ham ALSA “hw:” aygıtı. Aygıt reddederse paylaşımlı moda döner.", "fallback": "Cihaz özel modu kabul etmiyor. Paylaşımlı moda geri dönülüyor." }, "mono": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d2addf81..84153922 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1499,8 +1499,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI 独占模式(Windows)", - "subtitle": "比特完美:绕过 Windows 混音器以提供无干扰输出。如果设备拒绝独占访问,则回退到共享模式。", + "title": "独占输出", + "subtitle": "让本应用独占设备,而不是与系统混音器共享:Windows 使用 WASAPI,Linux 使用原始 ALSA“hw:”设备。设备拒绝时回退到共享模式。", "fallback": "设备不接受独占模式,正在回退到共享模式。" }, "mono": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8c217689..7e23fd1a 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1499,8 +1499,8 @@ "decibels": "{{value}} dB" }, "exclusive": { - "title": "WASAPI 獨佔模式(Windows)", - "subtitle": "位元完美:繞過 Windows 混音器以提供無干擾輸出。如果裝置拒絕獨佔存取,則退回共用模式。", + "title": "獨佔輸出", + "subtitle": "讓本應用程式獨佔裝置,而不是與系統混音器共用:Windows 使用 WASAPI,Linux 使用原始 ALSA「hw:」裝置。裝置拒絕時會回到共用模式。", "fallback": "裝置不接受獨佔模式,正在退回共用模式。" }, "mono": { diff --git a/src/lib/tauri/player.ts b/src/lib/tauri/player.ts index 35423698..15dd4b19 100644 --- a/src/lib/tauri/player.ts +++ b/src/lib/tauri/player.ts @@ -461,21 +461,23 @@ export function playerSetOutputDevice(deviceId: string | null): Promise { } /** - * Toggle WASAPI Exclusive Mode (Windows only). The backend persists - * the value across platforms but only re-opens the output stream on - * Windows. Falls back to cpal shared if exclusive init fails (device - * busy, no exclusive format support). + * Toggle exclusive output: own the device rather than share it with + * the system mixer — WASAPI Exclusive on Windows, a raw ALSA `hw:` + * device on Linux. The backend persists the value on every platform + * but only re-opens the stream where a backend exists (macOS still + * has one for DoP only). Falls back to cpal shared if exclusive init + * fails (device busy, no supported format). */ -export function playerSetWasapiExclusive(enabled: boolean): Promise { - return invoke("player_set_wasapi_exclusive", { enabled }); +export function playerSetExclusiveOutput(enabled: boolean): Promise { + return invoke("player_set_exclusive_output", { enabled }); } /** - * Read whether WASAPI Exclusive Mode is currently engaged. Always - * `false` on Linux / macOS. Useful for the Settings card to show - * what's actually active (a failed exclusive init silently falls - * back to shared, so the toggle could be on but the mode off). + * Read whether the output really owns its device right now. Useful for + * the Settings card to show what's actually active: a failed exclusive + * init silently falls back to shared, so the toggle can be on while + * the mode is off. */ -export function playerGetWasapiExclusive(): Promise { - return invoke("player_get_wasapi_exclusive"); +export function playerGetExclusiveOutput(): Promise { + return invoke("player_get_exclusive_output"); } From cc481d87ce54aab9ada0e8c620d6a59964d1a1f8 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 19:37:41 +0200 Subject: [PATCH 2/9] fix(audio): ask alsa for a period the ring can feed, and a shallow buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing asked for a size, so HwParams::any left both at what the driver offered and snd_pcm_hw_params took its maximum. On a snd-dummy card that was a 16384-frame period, and starting a track, seeking or changing track each took about ten seconds — the wait was the buffer draining. The period matters twice over. One period is drained from the ring in a single pass and whatever the ring cannot supply is written as silence, so at 16384 frames a period was two thirds of the whole ring: an underrun became the normal case rather than the exception. Now a 1024-frame period and four of them, on the DoP path too, which had the same omission and would have paid for it the same way. The negotiated buffer depth is logged, since it is the number that says how long a pause takes to be heard. The switch also lost its width: a flex item shrinks past an explicit one, and this row's subtitle is longer than its neighbours', so the control came out visibly narrower than the identical switches above and below it. --- .../crates/app/src/audio/alsa_exclusive.rs | 47 ++++++++++++++++++- src/components/common/ToggleSwitch.tsx | 7 ++- .../views/settings/ExclusiveModeCard.tsx | 10 ++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index c24ae62b..ec5db77f 100644 --- a/src-tauri/crates/app/src/audio/alsa_exclusive.rs +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -46,7 +46,7 @@ use std::sync::Arc; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use alsa::pcm::{Access, Format, HwParams, State, IO, PCM}; +use alsa::pcm::{Access, Format, Frames, HwParams, State, IO, PCM}; use alsa::{Direction, ValueOr}; use crossbeam_channel::{bounded, Receiver, Sender}; use rtrb::{Consumer, Producer, RingBuffer}; @@ -434,6 +434,7 @@ fn open_dop_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> .map_err(|e| AppError::Audio(format!("alsa set_format S32_LE: {e}")))?; hwp.set_access(Access::RWInterleaved) .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + set_period_and_buffer(&hwp)?; pcm.hw_params(&hwp) .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; } @@ -560,6 +561,38 @@ impl AlsaSampleFormat { } } +/// Frames per period we ask ALSA for — about 23 ms at 44.1 kHz. +const TARGET_PERIOD_FRAMES: Frames = 1024; + +/// Periods per buffer. Four is the usual choice: deep enough to absorb a +/// scheduling hiccup on a thread that is not realtime, shallow enough +/// that a pause or a seek is heard now rather than after the buffer +/// plays out. +const TARGET_PERIODS: Frames = 4; + +/// Ask for a period the ring can feed and a buffer only a few periods +/// deep, on both streams. +/// +/// Without this, `HwParams::any` leaves the sizes at whatever the driver +/// offers, and `snd_pcm_hw_params` then takes its maximum. Measured on a +/// `snd-dummy` card: a 16 384-frame period, and a buffer deep enough that +/// starting a track, seeking and changing track each took about ten +/// seconds — the wait was the buffer draining. +/// +/// The period also has to stay small against +/// [`super::output::RING_CAPACITY`], because one period is drained from +/// the ring in a single pass and whatever the ring cannot supply is +/// written as silence. At 16 384 frames a period was two thirds of the +/// whole ring, which makes an underrun the normal case rather than the +/// exception. +fn set_period_and_buffer(hwp: &HwParams<'_>) -> AppResult<()> { + hwp.set_period_size_near(TARGET_PERIOD_FRAMES, ValueOr::Nearest) + .map_err(|e| AppError::Audio(format!("alsa set_period_size: {e}")))?; + hwp.set_buffer_size_near(TARGET_PERIOD_FRAMES * TARGET_PERIODS) + .map_err(|e| AppError::Audio(format!("alsa set_buffer_size: {e}")))?; + Ok(()) +} + /// Pack the mixed `f32` samples into the byte image the negotiated /// format expects, little-endian throughout. /// @@ -683,6 +716,10 @@ struct OpenPcm { sample_rate: u32, channels: u16, period_frames: usize, + /// Logged, not used: it is the number that says how long a pause or + /// a seek takes to be heard, so a latency report can be read without + /// asking for another run. + buffer_frames: Frames, } /// Open `dev` for ordinary PCM, walking the format chain until one @@ -779,13 +816,14 @@ fn try_open_pcm( .map_err(|e| AppError::Audio(format!("alsa set_format {}: {e}", format.label())))?; hwp.set_access(Access::RWInterleaved) .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + set_period_and_buffer(&hwp)?; pcm.hw_params(&hwp) .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; } // Scoped: `hw_params_current` borrows the PCM, and the borrow would // otherwise still be live at the `Ok(OpenPcm { pcm, .. })` move. - let (sample_rate, channels, period_frames) = { + let (sample_rate, channels, period_frames, buffer_frames) = { let hwp = pcm .hw_params_current() .map_err(|e| AppError::Audio(format!("alsa hw_params_current: {e}")))?; @@ -797,6 +835,8 @@ fn try_open_pcm( hwp.get_period_size() .map_err(|e| AppError::Audio(format!("alsa get_period_size: {e}")))? as usize, + hwp.get_buffer_size() + .map_err(|e| AppError::Audio(format!("alsa get_buffer_size: {e}")))?, ) }; if period_frames == 0 { @@ -815,6 +855,7 @@ fn try_open_pcm( sample_rate, channels: channels as u16, period_frames, + buffer_frames, }) } @@ -924,6 +965,7 @@ fn pcm_output_thread_main( sample_rate, channels, period_frames, + buffer_frames, } = opened; // `io_bytes` rather than a typed `io_*`: the packed image is bytes @@ -945,6 +987,7 @@ fn pcm_output_thread_main( channels, format = format.label(), period_frames, + buffer_frames, "alsa exclusive stream opened" ); diff --git a/src/components/common/ToggleSwitch.tsx b/src/components/common/ToggleSwitch.tsx index 68ff410c..270281c4 100644 --- a/src/components/common/ToggleSwitch.tsx +++ b/src/components/common/ToggleSwitch.tsx @@ -9,6 +9,11 @@ interface ToggleSwitchProps { * copies in `SettingsView` and `EqualizerCard`; new cards should * prefer this shared export and the older inline copies can be * collapsed in a follow-up. + * + * `shrink-0` because a flex item shrinks past an explicit width: every + * row here is a flex line whose other half is a label, so a long enough + * subtitle squeezed the switch narrower than the ones above and below + * it. The width is the control's size, not a suggestion. */ export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { return ( @@ -18,7 +23,7 @@ export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { role="switch" aria-checked={enabled} aria-label={label} - className={`relative w-12 h-7 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${ + className={`relative w-12 h-7 shrink-0 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${ enabled ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-600" }`} > diff --git a/src/components/views/settings/ExclusiveModeCard.tsx b/src/components/views/settings/ExclusiveModeCard.tsx index da567aa7..ad23988a 100644 --- a/src/components/views/settings/ExclusiveModeCard.tsx +++ b/src/components/views/settings/ExclusiveModeCard.tsx @@ -133,9 +133,13 @@ export function ExclusiveModeCard() { return (
-
- -
+
+