Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The audio path lives in [`src-tauri/crates/app/src/audio/`](../../src-tauri/crat
- **Native DSD via DoP** (DSD over PCM, #495, opt-in `profile_setting['audio.dsd_dop']` default OFF) — when the toggle is on AND the active output has exclusive device access AND the DAC accepts the format, a DSD track skips the FIR entirely: [`DsdToDop`](../../src-tauri/crates/core/src/audio_format/dsd/dop.rs) repackages the raw 1-bit stream into 24-bit DoP frames (marker `0x05`/`0xFA` alternating per frame, payload MSB-first — DFF verbatim, DSF bit-reversed) at `dsd_rate / 16` (DSD64 → 176.4 kHz, DSD128 → 352.8, DSD256 → 705.6), and the DAC reconstructs the 1-bit stream in hardware (truly bit-perfect, nothing on our side filters / resamples / gains it). **Per-platform exclusive backend** — DoP needs a mixer-free path to the DAC, so each OS has its own: **Windows** = WASAPI Exclusive ([`run_dop_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs)); **Linux** = a raw `hw:` ALSA device at `S32_LE`, marker MSB-justified ([`alsa_exclusive`](../../src-tauri/crates/app/src/audio/alsa_exclusive.rs)); **macOS** = CoreAudio **hog mode** + a forced physical stream format at the DoP rate in 32-bit int, fed through an `AudioUnit` render callback ([`coreaudio_exclusive`](../../src-tauri/crates/app/src/audio/coreaudio_exclusive.rs)); that format is a property of the **device**, so the previous one is saved and restored on teardown — otherwise the rest of the machine keeps talking to a DAC clocked for DoP — and device loss normally arrives through an `IsAlive` property listener, with a periodic `get_hogging_pid` query as the fallback when the listener can't be registered. All three ship the DoP words MSB-justified (marker in the top byte) and share the byte/idle packing in [`audio/dop_pack`](../../src-tauri/crates/app/src/audio/dop_pack.rs), which is also where the `0x05`/`0xFA` marker is **re-stamped** onto every outgoing frame from a single running counter: the encoder's own phase restarts on each seek and never sees the idle frames generated on pause / underrun, so letting both sides number frames independently would repeat or skip a marker exactly at those seams and drop the DAC out of DSD lock. On a cold `LoadAndPlay`, [`maybe_switch_dop_output`](../../src-tauri/crates/app/src/audio/decoder.rs) parses the DSD header for the DoP rate and asks the engine to re-open the exclusive output at that exact format ([`AudioEngine::switch_output_for_track`](../../src-tauri/crates/app/src/audio/engine.rs), which hands the fresh ring producer straight back rather than via the `SwapProducer` channel); the backend ships the words bit-exact and emits marker-carrying DoP idle frames (`0x69` payload) on pause/underrun so the DAC keeps DoP lock. **Linux: the card is asked for, not given up on.** PipeWire / PulseAudio hold every card from login, so the raw `hw:` open returned `EBUSY` on any desktop and DoP fell back silently — the toggle did nothing and said nothing. [`device_reservation`](../../src-tauri/crates/app/src/audio/device_reservation.rs) now takes the `org.freedesktop.ReserveDevice1.Audio<N>` name on the session bus, which is the protocol both servers watch in order to release a device, then the open is retried for up to a second while the server finishes letting go (releasing is asynchronous on its side). The reservation is bound to the stream and released with it. No session bus, or an owner that refuses to be replaced, lands exactly where the code was before. **Fully fail-soft**: a DAC that refuses the DoP rate, a non-exclusive output, or a platform without a DoP backend all fall back transparently to the DSD → PCM path above. The two load paths that never negotiate a format — remote files and HTTP streams — force the output back to PCM before opening, since a leftover DoP output would read their PCM samples as 24-bit words and ship them to the DAC as noise. On Windows DoP rides the separate WASAPI Exclusive opt-in; on Linux and macOS the DoP toggle itself engages the exclusive path (raw `hw:` / hog mode). DoP tracks never crossfade / gaplessly prefetch (the words can't be mixed), so they always transition through a cold load + output re-open; EQ / ReplayGain / normalize / mono / speed are bypassed (bit-perfect, volume is the DAC's job — and playback speed is pinned to 1× for the track, since the resampler that implements it isn't in the chain and only the reported position would move). Seeking is the one transport action that survives untouched, so **A-B loop works on DoP tracks** just like on PCM ones. The pipeline popover shows a "Native DSD" pill sourced from `player_get_state.dop_active` — what actually engaged, not just the opt-in. **Playing DoP to a non-DoP DAC produces white noise**, so the toggle is opt-in and default OFF for users who know their DAC supports it.
- **Output** — [`cpal 0.17`](https://crates.io/crates/cpal) on a dedicated thread because `cpal::Stream` is `!Send` on Windows. Samples cross the thread via an [`rtrb 0.4`](https://crates.io/crates/rtrb) SPSC ring (`RING_CAPACITY = 96 000` `f32`s ≈ 1 s @ 48 kHz stereo).
- **Hot-path rules** — the cpal callback never allocates, locks or logs. It only reads the `rtrb::Consumer` and `Atomic*` fields in `SharedPlayback`.
- **Integer output and dither** — the exclusive backends turn the `f32` stream back into integer PCM through [`audio/pcm_scale`](../../src-tauri/crates/app/src/audio/pcm_scale.rs), which multiplies by the same power of two symphonia divided by, so an untouched source reaches the device bit for bit. **16-bit output is dithered** (TPDF, one step peak, `Dither16`) wherever the chain moved a sample off a 16-bit step — volume, ReplayGain, the EQ, a fade. Rounding alone leaves an error that follows the signal and is heard as distortion on quiet passages and fade-outs; the noise turns it into a steady hiss at about −96 dBFS. A sample that already is a 16-bit value is left alone, so a 16-bit track nothing altered stays bit-perfect (every one of the 65 536 values is tested). 24- and 32-bit output is not dithered: its step sits below any converter's own noise. The shared-mode output is converted by cpal and the system mixer, not by us.

## Spectrum visualizer

Expand Down Expand Up @@ -185,6 +186,12 @@ The total is bounded to [−30, +12] dB regardless, which is what stands between

The scalar is recomputed **per decoded buffer** rather than baked into the stream at load time, so moving the pre-amp is audible immediately instead of at the next track. One `powf` per buffer, on the decoder thread — never in the cpal callback.

## Equalizer

[`audio/eq.rs`](../../src-tauri/crates/app/src/audio/eq.rs): six peaking biquads in series (60 / 150 / 400 / 1000 / 2400 / 15 000 Hz, Q 0.707, ±12 dB per band, RBJ cookbook), on the decoder thread next to ReplayGain. Gains are atomics the sliders write; the coefficients are recomputed on the decoder thread when a band moves or the sample rate changes. Off, the stage is skipped entirely.

**Headroom.** A boost raises a loud master past full scale, and the decoder's last stage clamps the signal to ±1.0, so +12 dB of bass used to be heard as hard clipping. The chain is now preceded by an automatic preamp equal to the peak of its own response, measured on the cascade (1 024 log-spaced points from 20 Hz to 20 kHz, plus each band's centre) rather than taken from the largest slider: adjacent bands overlap and their boosts add up, so the Bass booster preset (+6 / +5 / +3 dB) peaks near +8 dB. A boosting setting also takes 1 dB more for transients: the response peak bounds a steady signal, and a sharp full-scale pattern through +12 dB at 15 kHz overshoots it by 0.9 dB (measured). The strict bound, the sum of the impulse response, would cost every boosting setting 2 to 4 dB more for patterns music does not contain, so the margin covers the measured worst case and the final clamp stays the backstop. Bass booster thus plays about 9 dB lower. A setting that only cuts, or a flat one, keeps a preamp of exactly 1.0. The preamp drops at once when a setting asks for more attenuation, since the new boost acts from the next sample, and comes back up over one buffer when less is needed, so a slider moved during playback never clips and never clicks. Recomputing the response costs about 60 µs (release build), once per change, on the decoder thread. The cost is that a boosting setting plays quieter than before; that is the level at which it no longer distorts.

## Seek

`format.seek()` + `decoder.reset()` + `resampler.flush()`. The cpal callback enters `drain_silent` mode, which (since 70c1968) drains the ring in **one bulk `while consumer.pop()` pass** instead of one sample per output slot — total perceived gap on seek dropped from ~270 ms (one full ring at 44.1 kHz × 8 ch) to ~10-15 ms (one cpal callback period).
Expand Down
67 changes: 55 additions & 12 deletions src-tauri/crates/app/src/audio/alsa_exclusive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,15 +783,22 @@ fn set_period_and_buffer(hwp: &HwParams<'_>) -> AppResult<()> {
/// The integer formats go through [`super::pcm_scale`], which inverts the
/// decoder's own float conversion exactly, so an untouched stream reaches
/// the device bit for bit, and a sample past full scale saturates at the
/// endpoint rather than wrapping. Gain (volume, normalize, the mono mix) is applied
/// endpoint rather than wrapping. 16-bit output is dithered where the
/// chain moved a sample off a 16-bit step, and only there
/// ([`pcm_scale::Dither16`]). Gain (volume, normalize, the mono mix) is applied
/// upstream in [`super::output::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]) {
fn pack_samples(
format: AlsaSampleFormat,
samples: &[f32],
bytes: &mut [u8],
dither: &mut pcm_scale::Dither16,
) {
match format {
AlsaSampleFormat::F32 => {
for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) {
Expand Down Expand Up @@ -823,7 +830,7 @@ fn pack_samples(format: AlsaSampleFormat, samples: &[f32], bytes: &mut [u8]) {
}
AlsaSampleFormat::S16 => {
for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(2)) {
let v = pcm_scale::to_i16(*sample);
let v = dither.quantize(*sample);
chunk.copy_from_slice(&v.to_le_bytes());
}
}
Expand Down Expand Up @@ -1129,6 +1136,7 @@ fn pcm_output_thread_main(
// 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<u8> = vec![0u8; period_frames * frame_bytes];
let mut dither = pcm_scale::Dither16::new();

let exit = 'run: loop {
if shutdown_rx.try_recv().is_ok() {
Expand All @@ -1148,7 +1156,7 @@ fn pcm_output_thread_main(
} else {
let written =
super::output::fill_pcm_period(&shared, &mut consumer, &mut samples, channels);
pack_samples(format, &samples, &mut wire);
pack_samples(format, &samples, &mut wire, &mut dither);
if written > 0 {
shared.samples_played.fetch_add(written, Ordering::Relaxed);
}
Expand Down Expand Up @@ -1208,7 +1216,7 @@ mod tests {
assert!(!is_system_route("PCH"));
}

use super::{pack_samples, AlsaSampleFormat};
use super::{pack_samples, pcm_scale, AlsaSampleFormat};

#[test]
fn an_unopened_engine_asks_for_stereo_not_mono() {
Expand All @@ -1234,7 +1242,12 @@ mod tests {
#[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);
pack_samples(
AlsaSampleFormat::S24In32,
&[1.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
// 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].
Expand All @@ -1244,7 +1257,12 @@ mod tests {
#[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);
pack_samples(
AlsaSampleFormat::S24In32,
&[-1.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
// -8_388_608 as i32 = 0xFF_80_00_00. The driver reads the low
// three bytes; the 0xFF above them is the sign extension a plain
// i32 store produces and is ignored.
Expand All @@ -1254,14 +1272,24 @@ mod tests {
#[test]
fn the_packed_form_spends_three_bytes_and_no_container() {
let mut bytes = [0u8; 3];
pack_samples(AlsaSampleFormat::S24Packed, &[1.0], &mut bytes);
pack_samples(
AlsaSampleFormat::S24Packed,
&[1.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
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);
pack_samples(
AlsaSampleFormat::S16,
&[1.0, -1.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
assert_eq!(i16::from_le_bytes([bytes[0], bytes[1]]), 32_767);
assert_eq!(i16::from_le_bytes([bytes[2], bytes[3]]), -32_768);
}
Expand All @@ -1272,16 +1300,31 @@ mod tests {
// 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);
pack_samples(
AlsaSampleFormat::S32,
&[9.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
assert_eq!(i32::from_le_bytes(bytes), 2_147_483_647);
pack_samples(AlsaSampleFormat::S32, &[-9.0], &mut bytes);
pack_samples(
AlsaSampleFormat::S32,
&[-9.0],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
assert_eq!(i32::from_le_bytes(bytes), -2_147_483_648);
}

#[test]
fn float_output_is_a_byte_copy() {
let mut bytes = [0u8; 4];
pack_samples(AlsaSampleFormat::F32, &[0.25], &mut bytes);
pack_samples(
AlsaSampleFormat::F32,
&[0.25],
&mut bytes,
&mut pcm_scale::Dither16::new(),
);
assert_eq!(f32::from_le_bytes(bytes), 0.25);
}

Expand Down
Loading
Loading