fix(audio): encode S16 and S24 samples on exact integer scales
Use binary scale factors with explicit saturation for signed 16-bit and both 24-bit ALSA layouts. Cover every representable S16 and S24 value, non-finite input and byte-exact round trips through the real decoder. Keep the S32 conversion unchanged and isolate this adjustment for independent rollback. cx
This commit is contained in:
4 files changed
+148
-11
No files matched your search
@@ -623,10 +623,7 @@ impl AlsaDirectStream {
|
||||
// Clamp to 24-bit range: [-8388608, 8388607]
|
||||
let samples_i32: Vec<i32> = 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<u8> = 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<i16> =
|
||||
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()
|
||||
|
||||
@@ -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<Vec<u32>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod pcm_sample;
|
||||
@@ -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<u8> {
|
||||
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::<Vec<_>>()
|
||||
} 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::<f32>::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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user