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]
|
// Clamp to 24-bit range: [-8388608, 8388607]
|
||||||
let samples_i32: Vec<i32> = samples_f32
|
let samples_i32: Vec<i32> = samples_f32
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&s| {
|
.map(|&s| crate::pcm_sample::s24(s))
|
||||||
let scaled = s * 8_388_607.0;
|
|
||||||
scaled.clamp(-8_388_608.0, 8_388_607.0) as i32
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let io = pcm
|
let io = pcm
|
||||||
@@ -643,12 +640,7 @@ impl AlsaDirectStream {
|
|||||||
let mut bytes: Vec<u8> = Vec::with_capacity(samples_f32.len() * 3);
|
let mut bytes: Vec<u8> = Vec::with_capacity(samples_f32.len() * 3);
|
||||||
|
|
||||||
for &sample in samples_f32 {
|
for &sample in samples_f32 {
|
||||||
let scaled = sample * 8_388_607.0;
|
bytes.extend_from_slice(&crate::pcm_sample::s24_packed(sample));
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let io = pcm.io_bytes();
|
let io = pcm.io_bytes();
|
||||||
@@ -660,7 +652,7 @@ impl AlsaDirectStream {
|
|||||||
Format::S16LE => {
|
Format::S16LE => {
|
||||||
// f32 -> i16
|
// f32 -> i16
|
||||||
let samples_i16: Vec<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
|
let io = pcm
|
||||||
.io_i16()
|
.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>> {
|
pub fn get_device_supported_rates(_device_id: &str) -> Option<Vec<u32>> {
|
||||||
None
|
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-audio --lib -- pcm_write::tests::
|
||||||
cargo test --manifest-path crates/Cargo.toml -p qbz-qobuz --lib -- incremental_
|
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)"
|
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 \
|
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-cache --lib -- --list image_cache::tests:: 2>/dev/null \
|
||||||
| grep -c ': test$' || true)
|
| grep -c ': test$' || true)
|
||||||
|
|||||||
Reference in new issue
Block a user