diff --git a/crates/qbz-audio/src/alsa_direct.rs b/crates/qbz-audio/src/alsa_direct.rs index 22379eb3c..0f913341e 100644 --- a/crates/qbz-audio/src/alsa_direct.rs +++ b/crates/qbz-audio/src/alsa_direct.rs @@ -623,10 +623,7 @@ impl AlsaDirectStream { // Clamp to 24-bit range: [-8388608, 8388607] let samples_i32: Vec = samples_f32 .iter() - .map(|&s| { - let scaled = s * 8_388_607.0; - scaled.clamp(-8_388_608.0, 8_388_607.0) as i32 - }) + .map(|&s| crate::pcm_sample::s24(s)) .collect(); let io = pcm @@ -643,12 +640,7 @@ impl AlsaDirectStream { let mut bytes: Vec = Vec::with_capacity(samples_f32.len() * 3); for &sample in samples_f32 { - let scaled = sample * 8_388_607.0; - let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32; - // Pack as 3 bytes in little-endian order - bytes.push((s24 & 0xFF) as u8); // LSB - bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle - bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended) + bytes.extend_from_slice(&crate::pcm_sample::s24_packed(sample)); } let io = pcm.io_bytes(); @@ -660,7 +652,7 @@ impl AlsaDirectStream { Format::S16LE => { // f32 -> i16 let samples_i16: Vec = - samples_f32.iter().map(|&s| (s * 32_767.0) as i16).collect(); + samples_f32.iter().map(|&s| crate::pcm_sample::s16(s)).collect(); let io = pcm .io_i16() diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 6b6d6eab2..83c4fc8ce 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -127,3 +127,6 @@ pub fn device_supports_sample_rate(_device_id: &str, _sample_rate: u32) -> Optio pub fn get_device_supported_rates(_device_id: &str) -> Option> { None } + +#[cfg(target_os = "linux")] +mod pcm_sample; diff --git a/crates/qbz-audio/src/pcm_sample.rs b/crates/qbz-audio/src/pcm_sample.rs new file mode 100644 index 000000000..1f2969dd7 --- /dev/null +++ b/crates/qbz-audio/src/pcm_sample.rs @@ -0,0 +1,137 @@ +//! Integer PCM encoding for the normalized f32 decoder pipeline. +//! Scale by the signed range's power of two; positive full scale saturates. + +pub(crate) fn s16(sample: f32) -> i16 { + (sample * 32_768.0) as i16 +} + +pub(crate) fn s24(sample: f32) -> i32 { + (sample * 8_388_608.0).clamp(-8_388_608.0, 8_388_607.0) as i32 +} + +pub(crate) fn s24_packed(sample: f32) -> [u8; 3] { + let bytes = s24(sample).to_le_bytes(); + [bytes[0], bytes[1], bytes[2]] +} + +#[cfg(test)] +mod tests { + use super::*; + use symphonia::core::{audio::SampleBuffer, conv::FromSample, sample::i24}; + + #[test] + fn every_signed_16_and_24_bit_value_round_trips() { + for value in i16::MIN..=i16::MAX { + assert_eq!( + s16(f32::from_sample(value)).to_le_bytes(), + value.to_le_bytes() + ); + } + for value in -8_388_608i32..=8_388_607 { + let decoded = f32::from_sample(i24::from(value)); + assert_eq!(s24(decoded).to_le_bytes(), value.to_le_bytes()); + assert_eq!(s24_packed(decoded), value.to_le_bytes()[..3]); + } + } + + #[test] + fn nonfinite_and_out_of_range_samples_saturate_without_wrapping() { + for sample in [f32::NAN, 0.0, -0.0] { + assert_eq!(s16(sample), 0); + assert_eq!(s24(sample), 0); + } + for sample in [1.0, 2.0, f32::INFINITY] { + assert_eq!(s16(sample), i16::MAX); + assert_eq!(s24(sample), 8_388_607); + } + for sample in [-1.0, -2.0, f32::NEG_INFINITY] { + assert_eq!(s16(sample), i16::MIN); + assert_eq!(s24(sample), -8_388_608); + } + } + + fn wav(values: &[i32], bits: u16) -> Vec { + let width = usize::from(bits / 8); + let size = (values.len() * width) as u32; + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&(36 + size).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM + bytes.extend_from_slice(&2u16.to_le_bytes()); // stereo + bytes.extend_from_slice(&192_000u32.to_le_bytes()); + bytes.extend_from_slice(&(192_000u32 * 2 * width as u32).to_le_bytes()); + bytes.extend_from_slice(&(2u16 * width as u16).to_le_bytes()); + bytes.extend_from_slice(&bits.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&size.to_le_bytes()); + for value in values { + bytes.extend_from_slice(&value.to_le_bytes()[..width]); + } + bytes + } + + #[test] + fn real_decoder_preserves_192khz_stereo_pcm_bytes() { + use symphonia::core::{errors::Error, io::MediaSourceStream, probe::Hint}; + for bits in [16, 24] { + let values = if bits == 16 { + (i16::MIN..=i16::MAX).map(i32::from).collect::>() + } else { + let mut values = vec![-8_388_608, -8_388_607, -1, 0, 1, 8_388_606, 8_388_607, 0]; + let mut state = 0x5261_984du32; + for _ in 0..65_536 { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + values.push((state as i32) >> 8); + } + values + }; + let source = MediaSourceStream::new( + Box::new(std::io::Cursor::new(wav(&values, bits))), + Default::default(), + ); + let mut format = symphonia::default::get_probe() + .format( + &Hint::new(), + source, + &Default::default(), + &Default::default(), + ) + .unwrap() + .format; + let params = format.default_track().unwrap().codec_params.clone(); + assert_eq!(params.sample_rate, Some(192_000)); + assert_eq!(params.channels.unwrap().count(), 2); + let mut decoder = symphonia::default::get_codecs() + .make(¶ms, &Default::default()) + .unwrap(); + let mut offset = 0; + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(Error::IoError(error)) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + break + } + Err(error) => panic!("unexpected decoder error: {error}"), + }; + let decoded = decoder.decode(&packet).unwrap(); + let mut samples = + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); + samples.copy_interleaved_ref(decoded); + for sample in samples.samples() { + let expected = values[offset]; + if bits == 16 { + assert_eq!(s16(*sample).to_le_bytes(), (expected as i16).to_le_bytes()); + } else { + assert_eq!(s24(*sample).to_le_bytes(), expected.to_le_bytes()); + assert_eq!(s24_packed(*sample), expected.to_le_bytes()[..3]); + } + offset += 1; + } + } + assert_eq!(offset, values.len()); + } + } +} diff --git a/scripts/cargo-test.sh b/scripts/cargo-test.sh index 775397919..1f604a019 100755 --- a/scripts/cargo-test.sh +++ b/scripts/cargo-test.sh @@ -211,6 +211,11 @@ cargo test --manifest-path crates/Cargo.toml -p qconnect-app --lib -- handoff_ cargo test --manifest-path crates/Cargo.toml -p qbz-audio --lib -- pcm_write::tests:: cargo test --manifest-path crates/Cargo.toml -p qbz-qobuz --lib -- incremental_ +say "gate: exact integer PCM encoding and real decoder byte round trips" +n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-audio --lib -- --list pcm_sample::tests:: 2>/dev/null | grep -c ': test$' || true) +(( n >= 3 )) || { echo "PCM encoding suite has $n tests (expected >= 3)"; exit 1; } +cargo test --manifest-path crates/Cargo.toml -p qbz-audio --lib -- pcm_sample::tests:: + say "gate: 2026-09 static-review regressions present (image-cache LRU, loudness-cache degrade, log-rotation lock, URL-free CDN errors + signed-param redaction, link-resolver timeout)" n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-cache --lib -- --list image_cache::tests:: 2>/dev/null \ | grep -c ': test$' || true)