From 3eb9894ba908b8f695b50db36d878c0dceab303c Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 16:32:24 -0400 Subject: [PATCH 1/6] Add packed 24-bit sample types I24LE3, I24BE3, U24LE3 and U24BE3 The existing I24 and U24 store a 24-bit value in an i32, so they are four bytes wide with an alignment of four. That is the S24_LE layout. A great many interfaces instead carry 24-bit audio as exactly three bytes -- ALSA calls it S24_3LE -- and some, such as the Behringer WING, support nothing else. Those buffers cannot be reinterpreted as a slice of I24: the stride is wrong and the alignment is not satisfied. These four types have that layout instead. Each is repr(transparent) over [u8; 3], so size_of is exactly 3 and align_of is exactly 1, and every one of the 2^24 byte patterns is a valid value. A buffer of packed 24-bit PCM can therefore be reinterpreted as a slice of them without a copy. They are storage types and deliberately have no arithmetic of their own: operating on three bytes in place would mean a decode and an encode per operation hidden behind an operator. Ord is written by hand rather than derived, because deriving it would compare [u8; 3] lexicographically -- which orders the least significant byte first for the little-endian types and reads the sign byte as unsigned for the signed ones. Having no spare byte to put it in, an out-of-range value wraps modulo 2^24, which is what impl From for I24 already does. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- dasp_sample/src/lib.rs | 2 +- dasp_sample/src/types.rs | 269 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 268 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b486dced..f667ef2a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ Use the **Sample** trait to convert between and remain generic over any bit-depth in an optimal, performance-sensitive manner. Implementations are provided for all signed integer, unsigned integer and floating point primitive types along with some custom types including 11, 20, 24 and 48-bit signed and -unsigned unpacked integers. For example: +unsigned unpacked integers, and 24-bit signed and unsigned integers packed into +exactly three bytes in either byte order. For example: ```rust assert_eq!((-1.0).to_sample::(), 0); diff --git a/dasp_sample/src/lib.rs b/dasp_sample/src/lib.rs index 0c82ebe6..a1577520 100644 --- a/dasp_sample/src/lib.rs +++ b/dasp_sample/src/lib.rs @@ -10,7 +10,7 @@ extern crate alloc; pub use conv::{Duplex, FromSample, ToSample}; -pub use types::{I24, I48, U24, U48}; +pub use types::{I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; pub mod conv; mod ops; diff --git a/dasp_sample/src/types.rs b/dasp_sample/src/types.rs index 6c48b821..74eda132 100644 --- a/dasp_sample/src/types.rs +++ b/dasp_sample/src/types.rs @@ -1,12 +1,58 @@ //! A collection of custom, non-std **Sample** types. +//! +//! Most types here are "padded", i.e. they store their value in the smallest primitive integer +//! that can contain it. `I24`, for example, is a newtype around an `i32` and is therefore four +//! bytes wide with an alignment of four. +//! +//! The `*LE3` and `*BE3` types are the "packed" counterparts: a 24-bit value stored in exactly +//! three bytes with an alignment of one, in the given byte order regardless of the byte order of +//! the host. These correspond to the `S24_3LE`, `S24_3BE`, `U24_3LE` and `U24_3BE` formats as +//! named by ALSA. +//! +//! # Packed types +//! +//! All four are `#[repr(transparent)]` over `[u8; 3]`, with `size_of` exactly 3, `align_of` +//! exactly 1, and every one of the 2^24 byte patterns a valid value. That is what they exist +//! for: a buffer of packed 24-bit PCM can be reinterpreted as a slice of them without a copy, +//! which a four-byte container cannot do. rustdoc does not render `repr(transparent)` for a type +//! with a private field, so the guarantee is stated here rather than being visible on each type. +//! +//! ``` +//! use dasp_sample::I24LE3; +//! +//! let raw: [u8; 9] = [0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x7F]; +//! // Sound because the alignment is 1, every byte pattern is valid, and the length is divided +//! // by the stride rather than assumed. +//! let samples: &[I24LE3] = unsafe { +//! core::slice::from_raw_parts(raw.as_ptr() as *const I24LE3, raw.len() / 3) +//! }; +//! assert_eq!(samples[0].inner(), -8_388_608); +//! assert_eq!(samples[2].inner(), 8_388_607); +//! ``` +//! +//! They are storage types and provide no arithmetic of their own. Operating on three bytes in +//! place would mean a decode and an encode per operation, hidden behind an operator; instead +//! `Sample::Signed` and `Sample::Float` are `I24`/`i32` and `f32` as for the padded types, so a +//! caller unpacks once and operates many times. +//! +//! Two behaviours differ from the padded types and are easy to trip over: +//! +//! - Out-of-range values wrap modulo 2^24, because there is no spare byte to hold them. See the +//! `conv` module docs. +//! - `Default` is all-zero bytes, matching `I24`/`U24`. For the signed types that is equilibrium, +//! but for the unsigned ones it is `MIN`, the negative rail. Fill silence with `EQUILIBRIUM`. pub use self::i11::I11; pub use self::i20::I20; pub use self::i24::I24; +pub use self::i24_be3::I24BE3; +pub use self::i24_le3::I24LE3; pub use self::i48::I48; pub use self::u11::U11; pub use self::u20::U20; pub use self::u24::U24; +pub use self::u24_be3::U24BE3; +pub use self::u24_le3::U24LE3; pub use self::u48::U48; macro_rules! impl_from { @@ -58,6 +104,59 @@ macro_rules! impl_neg { }; } +// The packed counterparts of `impl_from!`/`impl_froms!`/`impl_neg!`. Separate because the value +// has to be encoded into three bytes rather than stored directly. +macro_rules! impl_packed_from { + ($T:ident from {$U:ident}) => { + impl From<$U> for $T { + #[inline] + fn from(other: $U) -> Self { + $T::new_unchecked(other.inner() as i32) + } + } + }; + ($T:ident from $U:ident) => { + impl From<$U> for $T { + #[inline] + fn from(other: $U) -> Self { + $T::new_unchecked(other as i32) + } + } + }; +} + +macro_rules! impl_packed_froms { + ($T:ident, {$U:ident}, $($rest:tt)*) => { + impl_packed_from!($T from {$U}); + impl_packed_froms!($T, $($rest)*); + }; + ($T:ident, {$U:ident}) => { + impl_packed_from!($T from {$U}); + }; + ($T:ident, $U:ident, $($rest:tt)*) => { + impl_packed_from!($T from $U); + impl_packed_froms!($T, $($rest)*); + }; + ($T:ident, $U:ident) => { + impl_packed_from!($T from $U); + }; + ($T:ident,) => {}; +} + +macro_rules! impl_packed_neg { + ($T:ident) => { + impl ::core::ops::Neg for $T { + type Output = $T; + /// Negation wraps for `MIN`, which has no positive counterpart in 24 bits, in + /// keeping with the rest of these types' out-of-range behaviour. + #[inline] + fn neg(self) -> $T { + $T::new_unchecked(-self.inner()) + } + } + }; +} + macro_rules! new_sample_type { ($T:ident: $Rep:ident, eq: $EQ:expr, min: $MIN:expr, max: $MAX:expr, total: $TOTAL:expr, from: $($rest:tt)*) => { pub const MIN: $T = $T($MIN); @@ -234,6 +333,128 @@ macro_rules! new_sample_type { }; } +// Expands to a packed 24-bit sample type; see the module docs. `$decode` and `$encode` carry the +// byte order and signedness, and are the only things that differ between the four types. +macro_rules! new_packed_sample_type { + ($T:ident: $Unpacked:ident, eq: $EQ:expr, min: $MIN:expr, max: $MAX:expr, + endian: $ENDIAN:expr, alsa: $ALSA:expr, + decode: |$b:ident| $decode:expr, encode: |$v:ident| $encode:expr, + from: $($rest:tt)*) => { + pub const MIN: $T = $T::new_unchecked($MIN); + pub const MAX: $T = $T::new_unchecked($MAX); + pub const EQUILIBRIUM: $T = $T::new_unchecked($EQ); + const MIN_VALUE: i32 = $MIN; + const MAX_VALUE: i32 = $MAX; + + #[doc = concat!("A 24-bit sample packed into exactly three ", $ENDIAN, " bytes.")] + /// + #[doc = concat!( + "Valid range `", stringify!($MIN), "..=", stringify!($MAX), + "`, with equilibrium at `", stringify!($EQ), "`. Known to ALSA as `", $ALSA, "`." + )] + /// + #[doc = concat!( + "This type is ", $ENDIAN, " on every target; see the [`types`](crate::types) module docs for the \ + layout guarantees, which are what make the zero-copy reinterpretation possible." + )] + #[derive(Copy, Clone, PartialEq, Eq, Default)] + #[repr(transparent)] + pub struct $T([u8; 3]); + + impl $T { + #[doc = concat!( + "Construct a sample from its three packed bytes, read as ", $ENDIAN, ". Every byte \ + triple is a valid sample, so this cannot fail." + )] + #[inline] + pub const fn from_bytes(bytes: [u8; 3]) -> Self { + $T(bytes) + } + + #[doc = concat!("The three packed bytes representing the sample, in ", $ENDIAN, " order.")] + #[inline] + pub const fn to_bytes(self) -> [u8; 3] { + self.0 + } + + /// Construct a new sample if the given value is within range. + /// + /// Returns `None` if `val` is out of range. + #[inline] + pub fn new(val: i32) -> Option { + if (MIN_VALUE..=MAX_VALUE).contains(&val) { + Some($T::new_unchecked(val)) + } else { + None + } + } + + /// Constructs a new sample without checking for overflowing. + /// + /// Unlike `I24::new_unchecked`, which stores an out-of-range value verbatim, there is + /// nowhere to put the overshoot here, so `val` wraps modulo 2^24. Use [`Self::new`] + /// where the value is not known to be in range. + /// + /// ``` + /// use dasp_sample::{I24, I24LE3}; + /// + /// assert_eq!(I24::new_unchecked(8_388_608).inner(), 8_388_608); // stored verbatim + /// assert_eq!(I24LE3::new_unchecked(8_388_608).inner(), -8_388_608); // wrapped + /// ``` + #[inline] + pub const fn new_unchecked($v: i32) -> Self { + $T($encode) + } + + /// The value represented by the sample, unpacked into an `i32`. + #[inline] + pub const fn inner(self) -> i32 { + let $b = self.0; + $decode + } + } + + impl From<$Unpacked> for $T { + #[inline] + fn from(other: $Unpacked) -> Self { + $T::new_unchecked(other.inner()) + } + } + + impl From<$T> for $Unpacked { + #[inline] + fn from(other: $T) -> Self { + $Unpacked::new_unchecked(other.inner()) + } + } + + // Derived comparison would compare the bytes in storage order, which is neither the + // numeric order nor even consistent between the `LE3` and `BE3` types, so we decode. + impl ::core::cmp::Ord for $T { + #[inline] + fn cmp(&self, other: &Self) -> ::core::cmp::Ordering { + self.inner().cmp(&other.inner()) + } + } + + impl ::core::cmp::PartialOrd for $T { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option<::core::cmp::Ordering> { + Some(self.cmp(other)) + } + } + + // Show the value rather than the storage, to match the padded sample types. + impl ::core::fmt::Debug for $T { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + f.debug_tuple(stringify!($T)).field(&self.inner()).finish() + } + } + + impl_packed_froms!($T, $($rest)*); + }; +} + pub mod i11 { new_sample_type!(I11: i16, eq: 0, min: -1024, max: 1023, total: 2048, from: i8, u8); @@ -253,10 +474,32 @@ pub mod i24 { impl_neg!(I24); } +pub mod i24_le3 { + use super::{I20, I24, U20}; + new_packed_sample_type!(I24LE3: I24, eq: 0, min: -8_388_608, max: 8_388_607, + endian: "little-endian", alsa: "S24_3LE", + // The most significant byte is cast via `i8` to sign extend. + decode: |b| (b[0] as i32) | ((b[1] as i32) << 8) | ((b[2] as i8 as i32) << 16), + encode: |v| [v as u8, (v >> 8) as u8, (v >> 16) as u8], + from: i8, i16, {I20}, i32, u8, u16, {U20}); + impl_packed_neg!(I24LE3); +} + +pub mod i24_be3 { + use super::{I20, I24, U20}; + new_packed_sample_type!(I24BE3: I24, eq: 0, min: -8_388_608, max: 8_388_607, + endian: "big-endian", alsa: "S24_3BE", + decode: |b| (b[2] as i32) | ((b[1] as i32) << 8) | ((b[0] as i8 as i32) << 16), + encode: |v| [(v >> 16) as u8, (v >> 8) as u8, v as u8], + from: i8, i16, {I20}, i32, u8, u16, {U20}); + impl_packed_neg!(I24BE3); +} + pub mod i48 { - use super::{I20, I24, U20, U24}; + use super::{I20, I24, I24BE3, I24LE3, U20, U24, U24BE3, U24LE3}; new_sample_type!(I48: i64, eq: 0, min: -140_737_488_355_328, max: 140_737_488_355_327, total: 281_474_976_710_656, - from: i8, i16, {I20:i32}, {I24:i32}, i32, u8, u16, {U20:i32}, {U24:i32}, u32); + from: i8, i16, {I20:i32}, {I24:i32}, {I24LE3:i32}, {I24BE3:i32}, i32, + u8, u16, {U20:i32}, {U24:i32}, {U24LE3:i32}, {U24BE3:i32}, u32); impl_neg!(I48); } @@ -277,8 +520,26 @@ pub mod u24 { from: u8, u16, {U20:i32}); } -pub mod u48 { +pub mod u24_le3 { use super::{U20, U24}; + new_packed_sample_type!(U24LE3: U24, eq: 8_388_608, min: 0, max: 16_777_215, + endian: "little-endian", alsa: "U24_3LE", + decode: |b| (b[0] as i32) | ((b[1] as i32) << 8) | ((b[2] as i32) << 16), + encode: |v| [v as u8, (v >> 8) as u8, (v >> 16) as u8], + from: i32, u8, u16, {U20}); +} + +pub mod u24_be3 { + use super::{U20, U24}; + new_packed_sample_type!(U24BE3: U24, eq: 8_388_608, min: 0, max: 16_777_215, + endian: "big-endian", alsa: "U24_3BE", + decode: |b| (b[2] as i32) | ((b[1] as i32) << 8) | ((b[0] as i32) << 16), + encode: |v| [(v >> 16) as u8, (v >> 8) as u8, v as u8], + from: i32, u8, u16, {U20}); +} + +pub mod u48 { + use super::{U20, U24, U24BE3, U24LE3}; new_sample_type!(U48: i64, eq: 140_737_488_355_328, min: 0, max: 281_474_976_710_655, total: 281_474_976_710_656, - from: u8, u16, {U20:i32}, {U24:i32}, u32); + from: u8, u16, {U20:i32}, {U24:i32}, {U24LE3:i32}, {U24BE3:i32}, u32); } From 4fb33dced6472e0e5a69fb08f1b1e5c9103016ee Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 16:32:39 -0400 Subject: [PATCH 2/6] Add conversions between the packed types and every other sample type Every ordered pair of the eighteen sample types now converts, so the four packed types are as usable as any other. The conversions are defined in terms of the padded equivalents, which is not a detour: packing is a pure re-arrangement of the same 24 bits, so there is no shorter path to any other width. For any in-range value a packed conversion and its padded counterpart agree exactly. They differ only for input that is already out of range, and only from a floating point source, because every integer conversion into 24 bits is a shift that fits by construction: i8 << 16 and i16 << 8 widen into range, and i32 >> 8, i64 >> 40 and I48 >> 24 land exactly on -8_388_608..=8_388_607. A float outside the documented -1.0 <= v < 1.0 range can overflow. I24 and U24 absorb the overshoot in the spare byte of their container and hold a numerically out-of-range value; a packed type has no spare byte, so it wraps. That tolerance is a property of their storage rather than a deliberate policy -- see #73, which reports the same overflow against the primitive types and notes in passing that I24 and I48 happen not to be affected. No attempt is made here to settle the wider question of overflow behaviour raised by #39 and #73; the packed types wrap, matching impl From for I24, and the conv module documents it. Co-Authored-By: Claude Opus 5 (1M context) --- dasp_sample/src/conv.rs | 249 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 6 deletions(-) diff --git a/dasp_sample/src/conv.rs b/dasp_sample/src/conv.rs index f1ed3f90..65b3cb59 100644 --- a/dasp_sample/src/conv.rs +++ b/dasp_sample/src/conv.rs @@ -1,19 +1,48 @@ -//! Pure functions and traits for converting between i8, i16, I24, i32, I48, i64, u8, u16, U24, -//! u32, U48, u64, f32 and f64. +//! Pure functions and traits for converting between i8, i16, I24, I24LE3, I24BE3, i32, I48, i64, +//! u8, u16, U24, U24LE3, U24BE3, u32, U48, u64, f32 and f64. //! //! Each conversion function is performance focused, memory-sensitive and expects that the user has //! validated their input prior to the function call. //! //! No conversion function will ever cast to a type with a size in bytes larger than the largest -//! between the source and target sample types. +//! between the source and target sample types, with one exception: the packed 24-bit types are +//! three bytes wide but convert via their four-byte padded equivalents, since packing is a +//! re-arrangement of the same 24 bits and there is no shorter path to any other width. //! //! The conversion functions do *not* check the range of incoming values for floating point values -//! or any of the custom `I24`, `U24`, `I48` and `U48` types. +//! or any of the custom `I24`, `U24`, `I48`, `U48`, `I24LE3`, `I24BE3`, `U24LE3` and `U24BE3` +//! types. //! //! Note that floating point conversions use the range -1.0 <= v < 1.0: //! `(1.0 as f64).to_sample::()` will overflow! +//! +//! # Out-of-range input and the packed types +//! +//! For every value within the documented input range, a conversion to a packed 24-bit type +//! (`I24LE3`, `I24BE3`, `U24LE3`, `U24BE3`) and one to its padded equivalent (`I24`, `U24`) +//! agree exactly. They differ only for input that is already out of range, and only from a +//! floating point source: +//! +//! - Every *integer* conversion into 24 bits is a shift that fits by construction — `i8 << 16` +//! and `i16 << 8` widen into range, `i32 >> 8` yields exactly `-8_388_608..=8_388_607`, and +//! `i64 >> 40` and `I48 >> 24` likewise — so no integer source can overflow. +//! - A *floating point* source outside `-1.0 <= v < 1.0` can. `I24` and `U24` absorb the +//! overshoot in the spare byte of their `i32` container and hold a numerically out-of-range +//! value; a packed type has no spare byte, so the value wraps modulo 2^24. `1.0f32` becomes +//! `I24(8_388_608)` but `I24LE3(-8_388_608)`. +//! +//! The padded types' tolerance here is a property of their storage rather than a deliberate +//! policy: see [#73], which reports the same overflow against the primitive types and notes in +//! passing that `I24` and `I48` happen not to be affected. Overflow behaviour across this crate +//! is a longstanding open question — see also [#39] on saturating arithmetic — and the packed +//! types deliberately do not attempt to settle it. They wrap, which is what +//! `impl From for I24` already does, and callers who need out-of-range input handled must +//! clamp before converting. +//! +//! [#39]: https://github.com/RustAudio/dasp/issues/39 +//! [#73]: https://github.com/RustAudio/dasp/issues/73 -use crate::types::{I24, I48, U24, U48}; +use crate::types::{I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; macro_rules! conversion_fn { ($Rep:ty, $s:ident to_i8 { $body:expr }) => { @@ -37,6 +66,20 @@ macro_rules! conversion_fn { } }; + ($Rep:ty, $s:ident to_i24_le3 { $body:expr }) => { + #[inline] + pub fn to_i24_le3($s: $Rep) -> I24LE3 { + $body + } + }; + + ($Rep:ty, $s:ident to_i24_be3 { $body:expr }) => { + #[inline] + pub fn to_i24_be3($s: $Rep) -> I24BE3 { + $body + } + }; + ($Rep:ty, $s:ident to_i32 { $body:expr }) => { #[inline] pub fn to_i32($s: $Rep) -> i32 { @@ -79,6 +122,20 @@ macro_rules! conversion_fn { } }; + ($Rep:ty, $s:ident to_u24_le3 { $body:expr }) => { + #[inline] + pub fn to_u24_le3($s: $Rep) -> U24LE3 { + $body + } + }; + + ($Rep:ty, $s:ident to_u24_be3 { $body:expr }) => { + #[inline] + pub fn to_u24_be3($s: $Rep) -> U24BE3 { + $body + } + }; + ($Rep:ty, $s:ident to_u32 { $body:expr }) => { #[inline] pub fn to_u32($s: $Rep) -> u32 { @@ -126,7 +183,7 @@ macro_rules! conversion_fns { macro_rules! conversions { ($T:ident, $mod_name:ident { $($rest:tt)* }) => { pub mod $mod_name { - use $crate::types::{I24, U24, I48, U48}; + use $crate::types::{I24, I24BE3, I24LE3, U24, U24BE3, U24LE3, I48, U48}; conversion_fns!($T, $($rest)*); } }; @@ -135,6 +192,10 @@ macro_rules! conversions { conversions!(i8, i8 { s to_i16 { (s as i16) << 8 } s to_i24 { I24::new_unchecked((s as i32) << 16) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s as i32) << 24 } s to_i48 { I48::new_unchecked((s as i64) << 40) } s to_i64 { (s as i64) << 56 } @@ -184,6 +245,10 @@ conversions!(i8, i8 { conversions!(i16, i16 { s to_i8 { (s >> 8) as i8 } s to_i24 { I24::new_unchecked((s as i32) << 8) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s as i32) << 16 } s to_i48 { I48::new_unchecked((s as i64) << 32) } s to_i64 { (s as i64) << 48 } @@ -237,6 +302,10 @@ conversions!(i16, i16 { conversions!(I24, i24 { s to_i8 { (s.inner() >> 16) as i8 } s to_i16 { (s.inner() >> 8) as i16 } + s to_i24_le3 { I24LE3::from(s) } + s to_i24_be3 { I24BE3::from(s) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { s.inner() << 8 } s to_i48 { I48::new_unchecked((s.inner() as i64) << 24) } s to_i64 { (s.inner() as i64) << 40 } @@ -266,10 +335,56 @@ conversions!(I24, i24 { } }); +// The packed 24-bit conversions all unpack to `I24` first. This is not a detour: packing is a +// pure re-arrangement of the same 24 bits, so there is no shorter path to any other width. +conversions!(I24LE3, i24_le3 { + s to_i8 { super::i24::to_i8(to_i24(s)) } + s to_i16 { super::i24::to_i16(to_i24(s)) } + s to_i24 { I24::from(s) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } + s to_i32 { super::i24::to_i32(to_i24(s)) } + s to_i48 { super::i24::to_i48(to_i24(s)) } + s to_i64 { super::i24::to_i64(to_i24(s)) } + s to_u8 { super::i24::to_u8(to_i24(s)) } + s to_u16 { super::i24::to_u16(to_i24(s)) } + s to_u24 { super::i24::to_u24(to_i24(s)) } + s to_u32 { super::i24::to_u32(to_i24(s)) } + s to_u48 { super::i24::to_u48(to_i24(s)) } + s to_u64 { super::i24::to_u64(to_i24(s)) } + s to_f32 { super::i24::to_f32(to_i24(s)) } + s to_f64 { super::i24::to_f64(to_i24(s)) } +}); + +conversions!(I24BE3, i24_be3 { + s to_i8 { super::i24::to_i8(to_i24(s)) } + s to_i16 { super::i24::to_i16(to_i24(s)) } + s to_i24 { I24::from(s) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } + s to_i32 { super::i24::to_i32(to_i24(s)) } + s to_i48 { super::i24::to_i48(to_i24(s)) } + s to_i64 { super::i24::to_i64(to_i24(s)) } + s to_u8 { super::i24::to_u8(to_i24(s)) } + s to_u16 { super::i24::to_u16(to_i24(s)) } + s to_u24 { super::i24::to_u24(to_i24(s)) } + s to_u32 { super::i24::to_u32(to_i24(s)) } + s to_u48 { super::i24::to_u48(to_i24(s)) } + s to_u64 { super::i24::to_u64(to_i24(s)) } + s to_f32 { super::i24::to_f32(to_i24(s)) } + s to_f64 { super::i24::to_f64(to_i24(s)) } +}); + conversions!(i32, i32 { s to_i8 { (s >> 24) as i8 } s to_i16 { (s >> 16) as i16 } s to_i24 { I24::new_unchecked(s >> 8) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i48 { I48::new_unchecked((s as i64) << 16) } s to_i64 { (s as i64) << 32 } s to_u8 { @@ -310,6 +425,10 @@ conversions!(I48, i48 { s to_i8 { (s.inner() >> 40) as i8 } s to_i16 { (s.inner() >> 32) as i16 } s to_i24 { I24::new_unchecked((s.inner() >> 24) as i32) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s.inner() >> 16) as i32 } s to_i64 { s.inner() << 16 } s to_u8 { @@ -342,6 +461,10 @@ conversions!(i64, i64 { s to_i8 { (s >> 56) as i8 } s to_i16 { (s >> 48) as i16 } s to_i24 { I24::new_unchecked((s >> 40) as i32) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s >> 32) as i32 } s to_i48 { I48::new_unchecked(s >> 16) } s to_u8 { @@ -388,6 +511,10 @@ conversions!(u8, u8 { s to_i24 { I24::new_unchecked((s as i32 - 128) << 16) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s as i32 - 128) << 24 } @@ -418,6 +545,10 @@ conversions!(u16, u16 { s to_i24 { I24::new_unchecked((s as i32 - 32_768) << 8) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s as i32 - 32_768) << 16 } @@ -442,6 +573,10 @@ conversions!(U24, u24 { s to_i24 { I24::new_unchecked(s.inner() - 8_388_608) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(s) } + s to_u24_be3 { U24BE3::from(s) } s to_i32 { (s.inner() - 8_388_608) << 8 } @@ -460,10 +595,54 @@ conversions!(U24, u24 { s to_f64 { super::i24::to_f64(to_i24(s)) } }); +conversions!(U24LE3, u24_le3 { + s to_i8 { super::u24::to_i8(to_u24(s)) } + s to_i16 { super::u24::to_i16(to_u24(s)) } + s to_i24 { super::u24::to_i24(to_u24(s)) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } + s to_i32 { super::u24::to_i32(to_u24(s)) } + s to_i48 { super::u24::to_i48(to_u24(s)) } + s to_i64 { super::u24::to_i64(to_u24(s)) } + s to_u8 { super::u24::to_u8(to_u24(s)) } + s to_u16 { super::u24::to_u16(to_u24(s)) } + s to_u24 { U24::from(s) } + s to_u32 { super::u24::to_u32(to_u24(s)) } + s to_u48 { super::u24::to_u48(to_u24(s)) } + s to_u64 { super::u24::to_u64(to_u24(s)) } + s to_f32 { super::u24::to_f32(to_u24(s)) } + s to_f64 { super::u24::to_f64(to_u24(s)) } +}); + +conversions!(U24BE3, u24_be3 { + s to_i8 { super::u24::to_i8(to_u24(s)) } + s to_i16 { super::u24::to_i16(to_u24(s)) } + s to_i24 { super::u24::to_i24(to_u24(s)) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_i32 { super::u24::to_i32(to_u24(s)) } + s to_i48 { super::u24::to_i48(to_u24(s)) } + s to_i64 { super::u24::to_i64(to_u24(s)) } + s to_u8 { super::u24::to_u8(to_u24(s)) } + s to_u16 { super::u24::to_u16(to_u24(s)) } + s to_u24 { U24::from(s) } + s to_u32 { super::u24::to_u32(to_u24(s)) } + s to_u48 { super::u24::to_u48(to_u24(s)) } + s to_u64 { super::u24::to_u64(to_u24(s)) } + s to_f32 { super::u24::to_f32(to_u24(s)) } + s to_f64 { super::u24::to_f64(to_u24(s)) } +}); + conversions!(u32, u32 { s to_i8 { super::u8::to_i8(to_u8(s)) } s to_i16 { super::u16::to_i16(to_u16(s)) } s to_i24 { super::u24::to_i24(to_u24(s)) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { if s < 2_147_483_648 { s as i32 - 2_147_483_647 - 1 @@ -490,6 +669,10 @@ conversions!(U48, u48 { s to_i8 { super::u8::to_i8(to_u8(s)) } s to_i16 { super::u16::to_i16(to_u16(s)) } s to_i24 { super::u24::to_i24(to_u24(s)) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { super::u32::to_i32(to_u32(s)) } s to_i48 { I48::new_unchecked(s.inner() - 140_737_488_355_328) @@ -510,6 +693,10 @@ conversions!(u64, u64 { s to_i8 { super::u8::to_i8(to_u8(s)) } s to_i16 { super::u16::to_i16(to_u16(s)) } s to_i24 { super::u24::to_i24(to_u24(s)) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { super::u32::to_i32(to_u32(s)) } s to_i48 { super::u48::to_i48(to_u48(s)) } s to_i64 { @@ -534,6 +721,10 @@ conversions!(f32, f32 { s to_i8 { (s * 128.0) as i8 } s to_i16 { (s * 32_768.0) as i16 } s to_i24 { I24::new_unchecked((s * 8_388_608.0) as i32) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s * 2_147_483_648.0) as i32 } s to_i48 { I48::new_unchecked((s * 140_737_488_355_328.0) as i64) } s to_i64 { (s * 9_223_372_036_854_775_808.0) as i64 } @@ -552,6 +743,10 @@ conversions!(f64, f64 { s to_i8 { (s * 128.0) as i8 } s to_i16 { (s * 32_768.0) as i16 } s to_i24 { I24::new_unchecked((s * 8_388_608.0) as i32) } + s to_i24_le3 { I24LE3::from(to_i24(s)) } + s to_i24_be3 { I24BE3::from(to_i24(s)) } + s to_u24_le3 { U24LE3::from(to_u24(s)) } + s to_u24_be3 { U24BE3::from(to_u24(s)) } s to_i32 { (s * 2_147_483_648.0) as i32 } s to_i48 { I48::new_unchecked((s * 140_737_488_355_328.0) as i64) } s to_i64 { (s * 9_223_372_036_854_775_808.0) as i64 } @@ -595,87 +790,129 @@ macro_rules! impl_from_sample { impl_from_sample! {i8, to_i8 from {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {i16, to_i16 from {i8:i8} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {I24, to_i24 from {i8:i8} {i16:i16} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {i32, to_i32 from {i8:i8} {i16:i16} {I24:i24} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {I48, to_i48 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {i64, to_i64 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {u8, to_u8 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {u16, to_u16 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {U24, to_u24 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {u32, to_u32 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {U48, to_u48 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {u64, to_u64 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} {f64:f64} } impl_from_sample! {f32, to_f32 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f64:f64} } impl_from_sample! {f64, to_f64 from {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} {f32:f32} } +impl_from_sample! {I24LE3, to_i24_le3 from + {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} + {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24BE3:i24_be3} {U24LE3:u24_le3} {U24BE3:u24_be3} + {f32:f32} {f64:f64} +} + +impl_from_sample! {I24BE3, to_i24_be3 from + {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} + {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {U24LE3:u24_le3} {U24BE3:u24_be3} + {f32:f32} {f64:f64} +} + +impl_from_sample! {U24LE3, to_u24_le3 from + {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} + {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24BE3:u24_be3} + {f32:f32} {f64:f64} +} + +impl_from_sample! {U24BE3, to_u24_be3 from + {i8:i8} {i16:i16} {I24:i24} {i32:i32} {I48:i48} {i64:i64} + {u8:u8} {u16:u16} {U24:u24} {u32:u32} {U48:u48} {u64:u64} + {I24LE3:i24_le3} {I24BE3:i24_be3} {U24LE3:u24_le3} + {f32:f32} {f64:f64} +} + /// Similar to the std `Into` trait, but specifically for converting between sample types. /// /// This trait has a blanket implementation for all types that implement From b9b2b1aaceb5c8c98579e2eb53122f406ae30521 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 16:32:51 -0400 Subject: [PATCH 3/6] Implement Sample and Frame for the packed types Signed and Float are taken from the padded equivalents -- I24 and f32 for the signed types, i32 and f32 for the unsigned ones -- so add_amp and mul_amp unpack, operate and repack rather than working three bytes at a time. The packed types are deliberately not SignedSample: arithmetic goes through Signed, which keeps the unpacking explicit and lets a caller unpack once and operate many times. dasp_frame gains the corresponding one-channel Frame impls. Both crates go to 0.11.1, and dasp_frame's requirement on dasp_sample is raised from "0.11" to "0.11.1". dasp_frame now names types::I24LE3, and a caret requirement of "0.11" permits the published 0.11.0, which does not have it. Inside the workspace the path dependency masks that, so no test run or CI job can catch it; a downstream crate resolving 0.11.0 would fail to compile dasp_frame. Happy to drop the version changes if they are better made at release time. Co-Authored-By: Claude Opus 5 (1M context) --- dasp_frame/Cargo.toml | 4 ++-- dasp_frame/src/lib.rs | 6 ++++++ dasp_sample/Cargo.toml | 2 +- dasp_sample/src/lib.rs | 6 ++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dasp_frame/Cargo.toml b/dasp_frame/Cargo.toml index ce3ff5a7..0b2a639b 100644 --- a/dasp_frame/Cargo.toml +++ b/dasp_frame/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dasp_frame" description = "An abstraction for audio PCM DSP frames, along with useful conversions and operations." -version = "0.11.0" +version = "0.11.1" authors = ["mitchmindtree "] readme = "../README.md" keywords = ["dsp", "frame", "channel", "pcm", "audio"] @@ -11,7 +11,7 @@ homepage = "https://github.com/rustaudio/dasp" edition = "2018" [dependencies] -dasp_sample = { version = "0.11", path = "../dasp_sample", default-features = false } +dasp_sample = { version = "0.11.1", path = "../dasp_sample", default-features = false } [features] default = ["std"] diff --git a/dasp_frame/src/lib.rs b/dasp_frame/src/lib.rs index 104a8501..4952e194 100644 --- a/dasp_frame/src/lib.rs +++ b/dasp_frame/src/lib.rs @@ -569,6 +569,12 @@ impl_frame_for_sample! { dasp_sample::types::U24 dasp_sample::types::U48 } +impl_frame_for_sample! { + dasp_sample::types::I24LE3 + dasp_sample::types::I24BE3 + dasp_sample::types::U24LE3 + dasp_sample::types::U24BE3 +} impl Iterator for Channels where diff --git a/dasp_sample/Cargo.toml b/dasp_sample/Cargo.toml index 1849abe7..aef0ad2d 100644 --- a/dasp_sample/Cargo.toml +++ b/dasp_sample/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dasp_sample" description = "An abstraction for audio PCM DSP samples, along with useful conversions and operations." -version = "0.11.0" +version = "0.11.1" authors = ["mitchmindtree "] readme = "../README.md" keywords = ["dsp", "bit-depth", "sample", "pcm", "audio"] diff --git a/dasp_sample/src/lib.rs b/dasp_sample/src/lib.rs index a1577520..a549322d 100644 --- a/dasp_sample/src/lib.rs +++ b/dasp_sample/src/lib.rs @@ -261,12 +261,18 @@ impl_sample! { i8: Signed: i8, Float: f32, EQUILIBRIUM: 0, i16: Signed: i16, Float: f32, EQUILIBRIUM: 0, I24: Signed: I24, Float: f32, EQUILIBRIUM: types::i24::EQUILIBRIUM, + // The packed types share the `Signed` and `Float` types of their padded equivalents, so all + // arithmetic on them unpacks first rather than operating three bytes at a time. + I24LE3: Signed: I24, Float: f32, EQUILIBRIUM: types::i24_le3::EQUILIBRIUM, + I24BE3: Signed: I24, Float: f32, EQUILIBRIUM: types::i24_be3::EQUILIBRIUM, i32: Signed: i32, Float: f32, EQUILIBRIUM: 0, I48: Signed: I48, Float: f64, EQUILIBRIUM: types::i48::EQUILIBRIUM, i64: Signed: i64, Float: f64, EQUILIBRIUM: 0, u8: Signed: i8, Float: f32, EQUILIBRIUM: 128, u16: Signed: i16, Float: f32, EQUILIBRIUM: 32_768, U24: Signed: i32, Float: f32, EQUILIBRIUM: types::u24::EQUILIBRIUM, + U24LE3: Signed: i32, Float: f32, EQUILIBRIUM: types::u24_le3::EQUILIBRIUM, + U24BE3: Signed: i32, Float: f32, EQUILIBRIUM: types::u24_be3::EQUILIBRIUM, u32: Signed: i32, Float: f32, EQUILIBRIUM: 2_147_483_648, U48: Signed: i64, Float: f64, EQUILIBRIUM: types::u48::EQUILIBRIUM, u64: Signed: i64, Float: f64, EQUILIBRIUM: 9_223_372_036_854_775_808, From 477789001ae3417f368a5121667f6880abe90382 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 16:33:14 -0400 Subject: [PATCH 4/6] Test the packed types and close three gaps in the existing coverage tests/packed.rs covers what is specific to these types being packed: the memory layout, byte order and sign extension, the ordering that a derived Ord would get wrong, the wrapping on out-of-range input, and the zero-copy reinterpretation the types exist for. Two of its tests are exhaustive, over all 2^24 values and all 2^24 byte patterns. Three weaknesses in the surrounding tests are fixed, each confirmed by mutating the source and watching the suite fail: - The conversion matrix test wrote its target types out a second time and compared the pair count against types * types, which only proved the two lists were the same length. Swapping one type for another in just one list passed, leaving that type untested and its replacement tested twice. The list is now given once and expanded along both dimensions. - Sample::Signed was unasserted for I24BE3 and U24LE3: changing I24BE3's from I24 to i32 compiled and passed the whole workspace suite, despite making add_amp read its argument on a scale 256x off. All four are now pinned, and that mutation is a compile error. - conv_cmp! built expected values with new_unchecked, which wraps for the packed types, so an out-of-range expectation quietly became the value the assertion then agreed with. It now goes through new, and a wrong expectation fails loudly. All existing expectations were already in range. The ordering test asserts that each case would fail under a derived Ord, so a case that proves nothing cannot be added by accident. U24BE3 has no such case and can have none: for a big-endian unsigned value, lexicographic byte order and numeric order are the same relation. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 22 +++ dasp_sample/tests/conv.rs | 285 ++++++++++++++++++++++++++++- dasp_sample/tests/packed.rs | 355 ++++++++++++++++++++++++++++++++++++ 3 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 dasp_sample/tests/packed.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb66e2d..2ba2749d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Unreleased +- Add packed 24-bit sample types `I24LE3`, `I24BE3`, `U24LE3` and `U24BE3` to + `dasp_sample::types`, corresponding to the `S24_3LE`, `S24_3BE`, `U24_3LE` + and `U24_3BE` formats. Unlike `I24`/`U24`, which store their value in a + four-byte container, these store it in exactly three bytes with an alignment + of one, so a buffer of packed 24-bit PCM can be reinterpreted as a slice of + samples without a copy. They implement `Sample`, `Frame` and the full set of + `FromSample`/`ToSample` conversions, but deliberately provide no arithmetic + of their own: operations go via `Sample::Signed` and `Sample::Float`, which + are `I24`/`i32` and `f32` as for the padded equivalents. They also offer the + same `From` conversions and `Neg` their padded equivalents do, and `I48`/`U48` + accept them as sources, so a call site can be retargeted from `I24` to + `I24LE3` by changing the type name. + + Having no spare byte to store one, they wrap out-of-range values modulo 2^24 + where `I24`/`U24` keep them. Only conversions from `f32`/`f64` outside the + documented `-1.0 <= v < 1.0` range can reach this: every integer conversion + into 24 bits is a shift that fits by construction. This is the same wrapping + `impl From for I24` already does, and no attempt is made here to settle + the wider question of overflow behaviour raised in #39 and #73. +- Bump `dasp_sample` and `dasp_frame` to 0.11.1, and raise `dasp_frame`'s + requirement on `dasp_sample` to `0.11.1`, as it now references the packed + types by name and would not build against 0.11.0. - Renamed `window-hanning` to `window-hann` - Made `IntoInterleavedSamples` and `IntoInterleavedSamplesIterator` stop yielding samples when the underlying signal gets exhausted. This is a breaking diff --git a/dasp_sample/tests/conv.rs b/dasp_sample/tests/conv.rs index b442d246..5f7ca0df 100644 --- a/dasp_sample/tests/conv.rs +++ b/dasp_sample/tests/conv.rs @@ -6,21 +6,33 @@ /// Expands to an `assert_eq` for each pre-conversion and post-conversion pair. /// -/// Literals that must be wrapped by a custom sample type are wrapped using $T/$U::new_unchecked. +/// Literals that must be wrapped by a custom sample type go through `new`, not `new_unchecked`, +/// so that an out-of-range literal fails the test rather than being silently mangled into a +/// value the assertion then agrees with. That distinction only became load-bearing with the +/// packed types: `new_unchecked` stores out-of-range values verbatim on the padded types but +/// wraps modulo 2^24 on the packed ones, which would make +/// `I24LE3::new_unchecked(8_388_608) == I24LE3::new_unchecked(-8_388_608)` and let a wrong +/// expectation pass. macro_rules! conv_cmp { ($fn_name:ident, $pre_conv:expr, $post_conv:expr) => { assert_eq!($fn_name($pre_conv), $post_conv); }; ($fn_name:ident: $U:ident, $pre_conv:expr, $post_conv:expr) => { - assert_eq!($fn_name($pre_conv), $U::new_unchecked($post_conv)); + assert_eq!( + $fn_name($pre_conv), + $U::new($post_conv).expect("expected value is out of range for the target type") + ); }; ($T:ident; $fn_name:ident, $pre_conv:expr, $post_conv:expr) => { - assert_eq!($fn_name($T::new_unchecked($pre_conv)), $post_conv); + assert_eq!( + $fn_name($T::new($pre_conv).expect("input value is out of range for the source type")), + $post_conv + ); }; ($T:ident; $fn_name:ident: $U:ident, $pre_conv:expr, $post_conv:expr) => { assert_eq!( - $fn_name($T::new_unchecked($pre_conv)), - $U::new_unchecked($post_conv) + $fn_name($T::new($pre_conv).expect("input value is out of range for the source type")), + $U::new($post_conv).expect("expected value is out of range for the target type") ); }; } @@ -78,6 +90,34 @@ macro_rules! test_fn { } }; + (to_i24_le3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_i24_le3() { + conv_cmps!(to_i24_le3: I24LE3, $($conv_cmps)*); + } + }; + + (to_i24_be3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_i24_be3() { + conv_cmps!(to_i24_be3: I24BE3, $($conv_cmps)*); + } + }; + + (to_u24_le3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_u24_le3() { + conv_cmps!(to_u24_le3: U24LE3, $($conv_cmps)*); + } + }; + + (to_u24_be3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_u24_be3() { + conv_cmps!(to_u24_be3: U24BE3, $($conv_cmps)*); + } + }; + (to_i32 { $($conv_cmps:tt)* }) => { #[test] fn test_to_i32() { @@ -178,6 +218,34 @@ macro_rules! test_fn { } }; + ($T:ident: to_i24_le3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_i24_le3() { + conv_cmps!($T; to_i24_le3: I24LE3, $($conv_cmps)*); + } + }; + + ($T:ident: to_i24_be3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_i24_be3() { + conv_cmps!($T; to_i24_be3: I24BE3, $($conv_cmps)*); + } + }; + + ($T:ident: to_u24_le3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_u24_le3() { + conv_cmps!($T; to_u24_le3: U24LE3, $($conv_cmps)*); + } + }; + + ($T:ident: to_u24_be3 { $($conv_cmps:tt)* }) => { + #[test] + fn test_to_u24_be3() { + conv_cmps!($T; to_u24_be3: U24BE3, $($conv_cmps)*); + } + }; + ($T:ident: to_i32 { $($conv_cmps:tt)* }) => { #[test] fn test_to_i32() { @@ -275,14 +343,14 @@ macro_rules! tests { ($T:ident { $($rest:tt)* }) => { pub mod $T { use dasp_sample::conv::$T::*; - use dasp_sample::types::{I24, U24, I48, U48}; + use dasp_sample::types::{I24, I24BE3, I24LE3, U24, U24BE3, U24LE3, I48, U48}; test_fns!($($rest)*); } }; ($T:ident: $mod_name:ident { $($rest:tt)* }) => { pub mod $mod_name { use dasp_sample::conv::$mod_name::*; - use dasp_sample::types::{I24, U24, I48, U48}; + use dasp_sample::types::{I24, I24BE3, I24LE3, U24, U24BE3, U24LE3, I48, U48}; test_fns!($T: $($rest)*); } }; @@ -300,6 +368,10 @@ tests!(i8 { to_u32 { -128, 0; 0, 2_147_483_648; 127, 4_278_190_080; } to_u48 { -128, 0; 0, 140_737_488_355_328; 127, 280_375_465_082_880; } to_u64 { -128, 0; 0, 9_223_372_036_854_775_808; 127, 18_374_686_479_671_623_680; } + to_i24_le3 { -128, -8_388_608; 0, 0; 127, 8_323_072; } + to_i24_be3 { -128, -8_388_608; 0, 0; 127, 8_323_072; } + to_u24_le3 { -128, 0; 0, 8_388_608; 127, 16_711_680; } + to_u24_be3 { -128, 0; 0, 8_388_608; 127, 16_711_680; } to_f32 { -128, -1.0; 0, 0.0; } to_f64 { -128, -1.0; 0, 0.0; } }); @@ -316,6 +388,10 @@ tests!(i16 { to_u32 { -32_768, 0; 0, 2_147_483_648; 32_767, 4_294_901_760; } to_u48 { -32_768, 0; 0, 140_737_488_355_328; 32_767, 281_470_681_743_360; } to_u64 { -32_768, 0; 0, 9_223_372_036_854_775_808; 32_767, 18_446_462_598_732_840_960; } + to_i24_le3 { -32_768, -8_388_608; 0, 0; 32_767, 8_388_352; } + to_i24_be3 { -32_768, -8_388_608; 0, 0; 32_767, 8_388_352; } + to_u24_le3 { -32_768, 0; 0, 8_388_608; 32_767, 16_776_960; } + to_u24_be3 { -32_768, 0; 0, 8_388_608; 32_767, 16_776_960; } to_f32 { -32_768, -1.0; 0, 0.0; } to_f64 { -32_768, -1.0; 0, 0.0; } }); @@ -332,6 +408,52 @@ tests!(I24: i24 { to_u32 { -8_388_608, 0; 0, 2_147_483_648; 8_388_607, 4_294_967_040; } to_u48 { -8_388_608, 0; 0, 140_737_488_355_328; 8_388_607, 281_474_959_933_440; } to_u64 { -8_388_608, 0; 0, 9_223_372_036_854_775_808; 8_388_607, 18_446_742_974_197_923_840; } + to_i24_le3 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_i24_be3 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_u24_le3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_u24_be3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_f32 { -8_388_608, -1.0; 0, 0.0; } + to_f64 { -8_388_608, -1.0; 0, 0.0; } +}); + +// The packed 24-bit types cover the same range as `I24`, so their expectations match the `i24` +// block above. What is under test here is that packing and unpacking preserves the value. +tests!(I24LE3: i24_le3 { + to_i8 { -8_388_608, -128; 0, 0; 8_388_607, 127; } + to_i16 { -8_388_608, -32_768; 0, 0; 8_388_607, 32_767; } + to_i24 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_i32 { -8_388_608, -2_147_483_648; 0, 0; 8_388_607, 2_147_483_392; } + to_i48 { -8_388_608, -140_737_488_355_328; 0, 0; 8_388_607, 140_737_471_578_112; } + to_i64 { -8_388_608, -9_223_372_036_854_775_808; 0, 0; 8_388_607, 9_223_370_937_343_148_032; } + to_u8 { -8_388_608, 0; 0, 128; 8_388_607, 255; } + to_u16 { -8_388_608, 0; 0, 32_768; 8_388_607, 65_535; } + to_u24 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_u32 { -8_388_608, 0; 0, 2_147_483_648; 8_388_607, 4_294_967_040; } + to_u48 { -8_388_608, 0; 0, 140_737_488_355_328; 8_388_607, 281_474_959_933_440; } + to_u64 { -8_388_608, 0; 0, 9_223_372_036_854_775_808; 8_388_607, 18_446_742_974_197_923_840; } + to_i24_be3 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_u24_le3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_u24_be3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_f32 { -8_388_608, -1.0; 0, 0.0; } + to_f64 { -8_388_608, -1.0; 0, 0.0; } +}); + +tests!(I24BE3: i24_be3 { + to_i8 { -8_388_608, -128; 0, 0; 8_388_607, 127; } + to_i16 { -8_388_608, -32_768; 0, 0; 8_388_607, 32_767; } + to_i24 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_i32 { -8_388_608, -2_147_483_648; 0, 0; 8_388_607, 2_147_483_392; } + to_i48 { -8_388_608, -140_737_488_355_328; 0, 0; 8_388_607, 140_737_471_578_112; } + to_i64 { -8_388_608, -9_223_372_036_854_775_808; 0, 0; 8_388_607, 9_223_370_937_343_148_032; } + to_u8 { -8_388_608, 0; 0, 128; 8_388_607, 255; } + to_u16 { -8_388_608, 0; 0, 32_768; 8_388_607, 65_535; } + to_u24 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_u32 { -8_388_608, 0; 0, 2_147_483_648; 8_388_607, 4_294_967_040; } + to_u48 { -8_388_608, 0; 0, 140_737_488_355_328; 8_388_607, 281_474_959_933_440; } + to_u64 { -8_388_608, 0; 0, 9_223_372_036_854_775_808; 8_388_607, 18_446_742_974_197_923_840; } + to_i24_le3 { -8_388_608, -8_388_608; 0, 0; 8_388_607, 8_388_607; } + to_u24_le3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } + to_u24_be3 { -8_388_608, 0; 0, 8_388_608; 8_388_607, 16_777_215; } to_f32 { -8_388_608, -1.0; 0, 0.0; } to_f64 { -8_388_608, -1.0; 0, 0.0; } }); @@ -348,6 +470,10 @@ tests!(i32 { to_u32 { -2_147_483_648, 0; 0, 2_147_483_648; 2_147_483_647, 4_294_967_295; } to_u48 { -2_147_483_648, 0; 0, 140_737_488_355_328; 2_147_483_647, 281_474_976_645_120; } to_u64 { -2_147_483_648, 0; 0, 9_223_372_036_854_775_808; 2_147_483_647, 18_446_744_069_414_584_320; } + to_i24_le3 { -2_147_483_648, -8_388_608; 0, 0; 2_147_483_647, 8_388_607; } + to_i24_be3 { -2_147_483_648, -8_388_608; 0, 0; 2_147_483_647, 8_388_607; } + to_u24_le3 { -2_147_483_648, 0; 0, 8_388_608; 2_147_483_647, 16_777_215; } + to_u24_be3 { -2_147_483_648, 0; 0, 8_388_608; 2_147_483_647, 16_777_215; } to_f32 { -2_147_483_648, -1.0; 0, 0.0; } to_f64 { -2_147_483_648, -1.0; 0, 0.0; } }); @@ -364,6 +490,10 @@ tests!(I48: i48 { to_u32 { -140_737_488_355_328, 0; 0, 2_147_483_648; 140_737_488_355_327, 4_294_967_295; } to_u48 { -140_737_488_355_328, 0; 0, 140_737_488_355_328; 140_737_488_355_327, 281_474_976_710_655; } to_u64 { -140_737_488_355_328, 0; 0, 9_223_372_036_854_775_808; 140_737_488_355_327, 18_446_744_073_709_486_080; } + to_i24_le3 { -140_737_488_355_328, -8_388_608; 0, 0; 140_737_488_355_327, 8_388_607; } + to_i24_be3 { -140_737_488_355_328, -8_388_608; 0, 0; 140_737_488_355_327, 8_388_607; } + to_u24_le3 { -140_737_488_355_328, 0; 0, 8_388_608; 140_737_488_355_327, 16_777_215; } + to_u24_be3 { -140_737_488_355_328, 0; 0, 8_388_608; 140_737_488_355_327, 16_777_215; } }); tests!(i64 { @@ -378,6 +508,10 @@ tests!(i64 { to_u32 { -9_223_372_036_854_775_808, 0; 0, 2_147_483_648; 9_223_372_036_854_775_807, 4_294_967_295; } to_u48 { -9_223_372_036_854_775_808, 0; 0, 140_737_488_355_328; 9_223_372_036_854_775_807, 281_474_976_710_655; } to_u64 { -9_223_372_036_854_775_808, 0; 0, 9_223_372_036_854_775_808; 9_223_372_036_854_775_807, 18_446_744_073_709_551_615; } + to_i24_le3 { -9_223_372_036_854_775_808, -8_388_608; 0, 0; 9_223_372_036_854_775_807, 8_388_607; } + to_i24_be3 { -9_223_372_036_854_775_808, -8_388_608; 0, 0; 9_223_372_036_854_775_807, 8_388_607; } + to_u24_le3 { -9_223_372_036_854_775_808, 0; 0, 8_388_608; 9_223_372_036_854_775_807, 16_777_215; } + to_u24_be3 { -9_223_372_036_854_775_808, 0; 0, 8_388_608; 9_223_372_036_854_775_807, 16_777_215; } to_f32 { -9_223_372_036_854_775_808, -1.0; 0, 0.0; } to_f64 { -9_223_372_036_854_775_808, -1.0; 0, 0.0; } }); @@ -394,6 +528,10 @@ tests!(u8 { to_u32 { 0, 0; 128, 2_147_483_648; 255, 4_278_190_080; } to_u48 { 0, 0; 128, 140_737_488_355_328; 255, 280_375_465_082_880; } to_u64 { 0, 0; 128, 9_223_372_036_854_775_808; 255, 18_374_686_479_671_623_680; } + to_i24_le3 { 0, -8_388_608; 128, 0; 255, 8_323_072; } + to_i24_be3 { 0, -8_388_608; 128, 0; 255, 8_323_072; } + to_u24_le3 { 0, 0; 128, 8_388_608; 255, 16_711_680; } + to_u24_be3 { 0, 0; 128, 8_388_608; 255, 16_711_680; } to_f32 { 0, -1.0; 128, 0.0; } to_f64 { 0, -1.0; 128, 0.0; } }); @@ -410,6 +548,10 @@ tests!(u16 { to_u32 { 0, 0; 32_768, 2_147_483_648; 65_535, 4_294_901_760; } to_u48 { 0, 0; 32_768, 140_737_488_355_328; 65_535, 281_470_681_743_360; } to_u64 { 0, 0; 32_768, 9_223_372_036_854_775_808; 65_535, 18_446_462_598_732_840_960; } + to_i24_le3 { 0, -8_388_608; 32_768, 0; 65_535, 8_388_352; } + to_i24_be3 { 0, -8_388_608; 32_768, 0; 65_535, 8_388_352; } + to_u24_le3 { 0, 0; 32_768, 8_388_608; 65_535, 16_776_960; } + to_u24_be3 { 0, 0; 32_768, 8_388_608; 65_535, 16_776_960; } to_f32 { 0, -1.0; 32_768, 0.0; } to_f64 { 0, -1.0; 32_768, 0.0; } }); @@ -426,6 +568,50 @@ tests!(U24: u24 { to_u32 { 0, 0; 8_388_608, 2_147_483_648; 16_777_215, 4_294_967_040; } to_u48 { 0, 0; 8_388_608, 140_737_488_355_328; 16_777_215, 281_474_959_933_440; } to_u64 { 0, 0; 8_388_608, 9_223_372_036_854_775_808; 16_777_215, 18_446_742_974_197_923_840; } + to_i24_le3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_u24_le3 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } + to_u24_be3 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } + to_f32 { 0, -1.0; 8_388_608, 0.0; } + to_f64 { 0, -1.0; 8_388_608, 0.0; } +}); + +tests!(U24LE3: u24_le3 { + to_i8 { 0, -128; 8_388_608, 0; 16_777_215, 127; } + to_i16 { 0, -32_768; 8_388_608, 0; 16_777_215, 32_767; } + to_i24 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_i32 { 0, -2_147_483_648; 8_388_608, 0; 16_777_215, 2_147_483_392; } + to_i48 { 0, -140_737_488_355_328; 8_388_608, 0; 16_777_215, 140_737_471_578_112; } + to_i64 { 0, -9_223_372_036_854_775_808; 8_388_608, 0; 16_777_215, 9_223_370_937_343_148_032; } + to_u8 { 0, 0; 8_388_608, 128; 16_777_215, 255; } + to_u16 { 0, 0; 8_388_608, 32_768; 16_777_215, 65_535; } + to_u24 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } + to_u32 { 0, 0; 8_388_608, 2_147_483_648; 16_777_215, 4_294_967_040; } + to_u48 { 0, 0; 8_388_608, 140_737_488_355_328; 16_777_215, 281_474_959_933_440; } + to_u64 { 0, 0; 8_388_608, 9_223_372_036_854_775_808; 16_777_215, 18_446_742_974_197_923_840; } + to_i24_le3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_u24_be3 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } + to_f32 { 0, -1.0; 8_388_608, 0.0; } + to_f64 { 0, -1.0; 8_388_608, 0.0; } +}); + +tests!(U24BE3: u24_be3 { + to_i8 { 0, -128; 8_388_608, 0; 16_777_215, 127; } + to_i16 { 0, -32_768; 8_388_608, 0; 16_777_215, 32_767; } + to_i24 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_i32 { 0, -2_147_483_648; 8_388_608, 0; 16_777_215, 2_147_483_392; } + to_i48 { 0, -140_737_488_355_328; 8_388_608, 0; 16_777_215, 140_737_471_578_112; } + to_i64 { 0, -9_223_372_036_854_775_808; 8_388_608, 0; 16_777_215, 9_223_370_937_343_148_032; } + to_u8 { 0, 0; 8_388_608, 128; 16_777_215, 255; } + to_u16 { 0, 0; 8_388_608, 32_768; 16_777_215, 65_535; } + to_u24 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } + to_u32 { 0, 0; 8_388_608, 2_147_483_648; 16_777_215, 4_294_967_040; } + to_u48 { 0, 0; 8_388_608, 140_737_488_355_328; 16_777_215, 281_474_959_933_440; } + to_u64 { 0, 0; 8_388_608, 9_223_372_036_854_775_808; 16_777_215, 18_446_742_974_197_923_840; } + to_i24_le3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 8_388_608, 0; 16_777_215, 8_388_607; } + to_u24_le3 { 0, 0; 8_388_608, 8_388_608; 16_777_215, 16_777_215; } to_f32 { 0, -1.0; 8_388_608, 0.0; } to_f64 { 0, -1.0; 8_388_608, 0.0; } }); @@ -442,6 +628,10 @@ tests!(u32 { to_u24 { 0, 0; 2_147_483_648, 8_388_608; 4_294_967_295, 16_777_215; } to_u48 { 0, 0; 2_147_483_648, 140_737_488_355_328; 4_294_967_295, 281_474_976_645_120; } to_u64 { 0, 0; 2_147_483_648, 9_223_372_036_854_775_808; 4_294_967_295, 18_446_744_069_414_584_320; } + to_i24_le3 { 0, -8_388_608; 2_147_483_648, 0; 4_294_967_295, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 2_147_483_648, 0; 4_294_967_295, 8_388_607; } + to_u24_le3 { 0, 0; 2_147_483_648, 8_388_608; 4_294_967_295, 16_777_215; } + to_u24_be3 { 0, 0; 2_147_483_648, 8_388_608; 4_294_967_295, 16_777_215; } to_f32 { 0, -1.0; 2_147_483_648, 0.0; } to_f64 { 0, -1.0; 2_147_483_648, 0.0; } }); @@ -458,6 +648,10 @@ tests!(U48: u48 { to_u24 { 0, 0; 140_737_488_355_328, 8_388_608; 281_474_976_710_655, 16_777_215; } to_u32 { 0, 0; 140_737_488_355_328, 2_147_483_648; 281_474_976_710_655, 4_294_967_295; } to_u64 { 0, 0; 140_737_488_355_328, 9_223_372_036_854_775_808; 281_474_976_710_655, 18_446_744_073_709_486_080; } + to_i24_le3 { 0, -8_388_608; 140_737_488_355_328, 0; 281_474_976_710_655, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 140_737_488_355_328, 0; 281_474_976_710_655, 8_388_607; } + to_u24_le3 { 0, 0; 140_737_488_355_328, 8_388_608; 281_474_976_710_655, 16_777_215; } + to_u24_be3 { 0, 0; 140_737_488_355_328, 8_388_608; 281_474_976_710_655, 16_777_215; } to_f32 { 0, -1.0; 140_737_488_355_328, 0.0; } to_f64 { 0, -1.0; 140_737_488_355_328, 0.0; } }); @@ -474,6 +668,10 @@ tests!(u64 { to_u24 { 0, 0; 9_223_372_036_854_775_808, 8_388_608; 18_446_744_073_709_551_615, 16_777_215; } to_u32 { 0, 0; 9_223_372_036_854_775_808, 2_147_483_648; 18_446_744_073_709_551_615, 4_294_967_295; } to_u48 { 0, 0; 9_223_372_036_854_775_808, 140_737_488_355_328; 18_446_744_073_709_551_615, 281_474_976_710_655; } + to_i24_le3 { 0, -8_388_608; 9_223_372_036_854_775_808, 0; 18_446_744_073_709_551_615, 8_388_607; } + to_i24_be3 { 0, -8_388_608; 9_223_372_036_854_775_808, 0; 18_446_744_073_709_551_615, 8_388_607; } + to_u24_le3 { 0, 0; 9_223_372_036_854_775_808, 8_388_608; 18_446_744_073_709_551_615, 16_777_215; } + to_u24_be3 { 0, 0; 9_223_372_036_854_775_808, 8_388_608; 18_446_744_073_709_551_615, 16_777_215; } to_f32 { 0, -1.0; 9_223_372_036_854_775_808, 0.0; } to_f64 { 0, -1.0; 9_223_372_036_854_775_808, 0.0; } }); @@ -491,6 +689,10 @@ tests!(f32 { to_u32 { -1.0, 0; 0.0, 2_147_483_648; } to_u48 { -1.0, 0; 0.0, 140_737_488_355_328; } to_u64 { -1.0, 0; 0.0, 9_223_372_036_854_775_808; } + to_i24_le3 { -1.0, -8_388_608; 0.0, 0; } + to_i24_be3 { -1.0, -8_388_608; 0.0, 0; } + to_u24_le3 { -1.0, 0; 0.0, 8_388_608; } + to_u24_be3 { -1.0, 0; 0.0, 8_388_608; } to_f64 { -1.0, -1.0; 0.0, 0.0; } }); @@ -507,5 +709,74 @@ tests!(f64 { to_u32 { -1.0, 0; 0.0, 2_147_483_648; } to_u48 { -1.0, 0; 0.0, 140_737_488_355_328; } to_u64 { -1.0, 0; 0.0, 9_223_372_036_854_775_808; } + to_i24_le3 { -1.0, -8_388_608; 0.0, 0; } + to_i24_be3 { -1.0, -8_388_608; 0.0, 0; } + to_u24_le3 { -1.0, 0; 0.0, 8_388_608; } + to_u24_be3 { -1.0, 0; 0.0, 8_388_608; } to_f32 { -1.0, -1.0; 0.0, 0.0; } }); + +/// A completeness check over the whole conversion matrix. +/// +/// The blocks above are written out by hand, so a missing `FromSample` impl for some pair of +/// types would simply go untested rather than fail. This asserts that every ordered pair of +/// sample types converts at all (a missing impl is a compile error) and that equilibrium is +/// preserved in every direction, which is the one expectation that holds for all of them. +/// +/// Add any new sample type to the single list in `every_pair_converts_and_preserves_equilibrium`. +mod matrix { + use dasp_sample::{Sample, I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; + + macro_rules! conv_row { + ($n:ident; $A:ty; $($B:ty),*) => {$({ + let converted: $B = <$A as Sample>::EQUILIBRIUM.to_sample::<$B>(); + assert_eq!( + converted, + <$B as Sample>::EQUILIBRIUM, + "{} -> {}: equilibrium was not preserved", + stringify!($A), + stringify!($B), + ); + $n += 1; + })*}; + } + + /// Expands to the conversions for every ordered pair, and evaluates to the number of types + /// in the list. + /// + /// The list is given once and expanded along *both* dimensions, which is the point: an + /// earlier version of this test wrote the target types out a second time, and comparing the + /// pair count against `types * types` then only proved the two lists were the same length. + /// Swapping one type for another in just one of them passed while leaving that type + /// untested and its replacement tested twice. With a single list that cannot happen. + macro_rules! conv_matrix { + ($n:ident; $($T:ty),*) => {{ + conv_matrix!(@rows $n; [$($T),*]; $($T),*); + [$(stringify!($T)),*].len() + }}; + // Peel one type off the outer list at a time so the whole inner list can be expanded + // again for each row. A single `$( conv_row!(.. $A .. $($B),*); )*` is not legal: both + // lists sit at the same repetition depth, so rustc tries to iterate them in lockstep. + (@rows $n:ident; [$($B:ty),*]; $A:ty $(, $rest:ty)*) => { + conv_row!($n; $A; $($B),*); + conv_matrix!(@rows $n; [$($B),*]; $($rest),*); + }; + (@rows $n:ident; [$($B:ty),*];) => {}; + } + + #[test] + fn every_pair_converts_and_preserves_equilibrium() { + let mut n = 0usize; + let types = conv_matrix!(n; i8, i16, I24, I24LE3, I24BE3, i32, I48, i64, + u8, u16, U24, U24LE3, U24BE3, u32, U48, u64, f32, f64); + assert_eq!( + n, + types * types, + "expected every ordered pair of {types} sample types" + ); + assert_eq!( + types, 18, + "a sample type was added or removed without updating this count" + ); + } +} diff --git a/dasp_sample/tests/packed.rs b/dasp_sample/tests/packed.rs new file mode 100644 index 00000000..e6d5db12 --- /dev/null +++ b/dasp_sample/tests/packed.rs @@ -0,0 +1,355 @@ +//! Tests for the packed 24-bit sample types. +//! +//! The conversion matrix in `conv.rs` already covers the *values* these types produce. What is +//! tested here is everything that is specific to their being packed: their memory layout, their +//! byte order, and the traits whose derived implementations would have been wrong. + +use dasp_sample::types::{i24, i24_be3, i24_le3, u24_be3, u24_le3, I20, U20}; +use dasp_sample::{Sample, I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; +use std::mem; + +/// The packed types offer the same `From` conversions and `Neg` as their padded equivalents, so +/// that a call site can be retargeted from `I24` to `I24LE3` by changing the type name alone. +/// +/// These are value-widening conversions rather than amplitude-preserving ones — `I24::from(3i8)` +/// is `I24(3)`, not `I24(3 << 16)` — and the packed types match that exactly. +#[test] +fn from_and_neg_match_the_padded_types() { + // Signed packed: the same list `I24` gets. + assert_eq!(I24LE3::from(3i8).inner(), I24::from(3i8).inner()); + assert_eq!(I24LE3::from(3i16).inner(), I24::from(3i16).inner()); + assert_eq!(I24LE3::from(3i32).inner(), I24::from(3i32).inner()); + assert_eq!(I24LE3::from(3u8).inner(), I24::from(3u8).inner()); + assert_eq!(I24LE3::from(3u16).inner(), I24::from(3u16).inner()); + assert_eq!(I24LE3::from(I20::new_unchecked(3)).inner(), 3); + assert_eq!(I24LE3::from(U20::new_unchecked(3)).inner(), 3); + assert_eq!(I24BE3::from(3i16).inner(), 3); + + // Unsigned packed: the same list `U24` gets, which has no signed sources and no `Neg`. + assert_eq!(U24LE3::from(3u8).inner(), U24::from(3u8).inner()); + assert_eq!(U24LE3::from(3u16).inner(), U24::from(3u16).inner()); + assert_eq!(U24BE3::from(3u16).inner(), 3); + assert_eq!(U24LE3::from(U20::new_unchecked(3)).inner(), 3); + + // `From` wraps for both, which is the one place they agree *because* both wrap rather + // than because neither overflows. + assert_eq!( + I24LE3::from(8_388_608i32).inner(), + I24::from(8_388_608i32).inner() + ); + + assert_eq!((-I24LE3::new_unchecked(5)).inner(), -5); + assert_eq!((-I24BE3::new_unchecked(5)).inner(), -5); + // `MIN` has no positive counterpart in 24 bits, so negating it wraps back to itself. + assert_eq!((-i24_le3::MIN).inner(), i24_le3::MIN.inner()); + + // `I48`/`U48` already accepted `I24`/`U24`; they now accept the packed types too. + assert_eq!(I48::from(I24LE3::new_unchecked(-7)).inner(), -7); + assert_eq!(I48::from(I24BE3::new_unchecked(-7)).inner(), -7); + assert_eq!(I48::from(U24LE3::new_unchecked(7)).inner(), 7); + assert_eq!(U48::from(U24LE3::new_unchecked(7)).inner(), 7); + assert_eq!(U48::from(U24BE3::new_unchecked(7)).inner(), 7); +} + +/// The whole reason these types exist: a buffer of packed 24-bit PCM must be reinterpretable as a +/// slice of samples, which requires a stride of exactly three bytes and no alignment requirement. +#[test] +fn layout() { + assert_eq!(mem::size_of::(), 3); + assert_eq!(mem::size_of::(), 3); + assert_eq!(mem::size_of::(), 3); + assert_eq!(mem::size_of::(), 3); + + assert_eq!(mem::align_of::(), 1); + assert_eq!(mem::align_of::(), 1); + assert_eq!(mem::align_of::(), 1); + assert_eq!(mem::align_of::(), 1); +} + +/// The byte order is a property of the type, not of the host, so these assertions hold on a +/// big-endian target too. +#[test] +fn byte_order() { + let n = 0x123456; + assert_eq!(I24LE3::new_unchecked(n).to_bytes(), [0x56, 0x34, 0x12]); + assert_eq!(I24BE3::new_unchecked(n).to_bytes(), [0x12, 0x34, 0x56]); + assert_eq!(U24LE3::new_unchecked(n).to_bytes(), [0x56, 0x34, 0x12]); + assert_eq!(U24BE3::new_unchecked(n).to_bytes(), [0x12, 0x34, 0x56]); + + assert_eq!(I24LE3::from_bytes([0x56, 0x34, 0x12]).inner(), 0x123456); + assert_eq!(I24BE3::from_bytes([0x12, 0x34, 0x56]).inner(), 0x123456); + assert_eq!(U24LE3::from_bytes([0x56, 0x34, 0x12]).inner(), 0x123456); + assert_eq!(U24BE3::from_bytes([0x12, 0x34, 0x56]).inner(), 0x123456); +} + +/// The signed types must sign extend from bit 23; the unsigned types must not. +#[test] +fn sign_extension() { + assert_eq!(I24LE3::from_bytes([0x00, 0x00, 0x80]).inner(), -8_388_608); + assert_eq!(I24BE3::from_bytes([0x80, 0x00, 0x00]).inner(), -8_388_608); + assert_eq!(I24LE3::from_bytes([0xFF, 0xFF, 0xFF]).inner(), -1); + assert_eq!(I24BE3::from_bytes([0xFF, 0xFF, 0xFF]).inner(), -1); + + assert_eq!(U24LE3::from_bytes([0x00, 0x00, 0x80]).inner(), 8_388_608); + assert_eq!(U24BE3::from_bytes([0x80, 0x00, 0x00]).inner(), 8_388_608); + assert_eq!(U24LE3::from_bytes([0xFF, 0xFF, 0xFF]).inner(), 16_777_215); + assert_eq!(U24BE3::from_bytes([0xFF, 0xFF, 0xFF]).inner(), 16_777_215); +} + +#[test] +fn consts() { + assert_eq!(i24_le3::MIN.inner(), -8_388_608); + assert_eq!(i24_le3::MAX.inner(), 8_388_607); + assert_eq!(i24_le3::EQUILIBRIUM.inner(), 0); + assert_eq!(i24_be3::MIN.inner(), -8_388_608); + assert_eq!(i24_be3::MAX.inner(), 8_388_607); + assert_eq!(i24_be3::EQUILIBRIUM.inner(), 0); + + assert_eq!(u24_le3::MIN.inner(), 0); + assert_eq!(u24_le3::MAX.inner(), 16_777_215); + assert_eq!(u24_le3::EQUILIBRIUM.inner(), 8_388_608); + assert_eq!(u24_be3::MIN.inner(), 0); + assert_eq!(u24_be3::MAX.inner(), 16_777_215); + assert_eq!(u24_be3::EQUILIBRIUM.inner(), 8_388_608); + + // The `Sample::EQUILIBRIUM` associated consts must agree with the module consts. + assert_eq!(::EQUILIBRIUM, i24_le3::EQUILIBRIUM); + assert_eq!(::EQUILIBRIUM, i24_be3::EQUILIBRIUM); + assert_eq!(::EQUILIBRIUM, u24_le3::EQUILIBRIUM); + assert_eq!(::EQUILIBRIUM, u24_be3::EQUILIBRIUM); + + assert_eq!(I24LE3::from(I24::EQUILIBRIUM), i24_le3::EQUILIBRIUM); + assert_eq!(U24BE3::from(U24::EQUILIBRIUM), u24_be3::EQUILIBRIUM); +} + +#[test] +fn new_checks_range() { + assert!(I24LE3::new(-8_388_609).is_none()); + assert!(I24LE3::new(8_388_608).is_none()); + assert_eq!(I24LE3::new(-8_388_608), Some(i24_le3::MIN)); + assert_eq!(I24LE3::new(8_388_607), Some(i24_le3::MAX)); + + assert!(U24BE3::new(-1).is_none()); + assert!(U24BE3::new(16_777_216).is_none()); + assert_eq!(U24BE3::new(0), Some(u24_be3::MIN)); + assert_eq!(U24BE3::new(16_777_215), Some(u24_be3::MAX)); +} + +/// A packed type has nowhere to put an out-of-range value, so `new_unchecked` wraps modulo 2^24. +/// This is the one place where a packed type does not agree with its padded equivalent, because +/// the padded one can park an out-of-range value in the spare byte of its container. +#[test] +fn out_of_range_wraps() { + assert_eq!(I24LE3::new_unchecked(8_388_608).inner(), -8_388_608); + assert_eq!(I24LE3::new_unchecked(-8_388_609).inner(), 8_388_607); + assert_eq!(I24BE3::new_unchecked(8_388_608).inner(), -8_388_608); + assert_eq!(U24LE3::new_unchecked(16_777_216).inner(), 0); + assert_eq!(U24BE3::new_unchecked(-1).inner(), 16_777_215); + + // The padded `From` wraps in exactly the same way... + assert_eq!(I24::from(8_388_608i32).inner(), -8_388_608); + // ...but the padded `new_unchecked` keeps the out-of-range value verbatim. + assert_eq!(I24::new_unchecked(8_388_608).inner(), 8_388_608); + + // Which is why converting a float from outside the documented -1.0 <= v < 1.0 range gives + // different answers for the padded and packed types. + assert_eq!(1.0f32.to_sample::().inner(), 8_388_608); + assert_eq!(1.0f32.to_sample::().inner(), -8_388_608); + assert_eq!(1.0f32.to_sample::().inner(), 16_777_216); + assert_eq!(1.0f32.to_sample::().inner(), 0); +} + +/// Asserts `$lo < $hi` numerically *and* that a derived `Ord` would have got it wrong. +/// +/// The second half is the point. Most orderings one might reach for happen to come out the same +/// either way, so an assertion like `MIN < MAX` passes under the very implementation it is +/// supposed to rule out. Requiring the byte-wise comparison to disagree makes each case prove it +/// is actually discriminating. +macro_rules! assert_derived_ord_would_fail { + ($lo:expr, $hi:expr) => {{ + assert!($lo < $hi, "numeric ordering is wrong"); + assert!( + $lo.to_bytes() > $hi.to_bytes(), + "this case is not discriminating: byte order agrees with numeric order here, so it \ + would pass under a derived Ord and proves nothing" + ); + }}; +} + +/// A derived `Ord` compares `[u8; 3]` lexicographically, which is wrong for three of the four +/// types: little-endian orders the *least* significant byte first, and the signed types read +/// their sign byte as unsigned. +#[test] +fn ordering_is_numeric_not_lexicographic() { + // Signed, either byte order: the sign byte read as unsigned puts negatives above positives. + assert_derived_ord_would_fail!(i24_le3::MIN, i24_le3::EQUILIBRIUM); + assert_derived_ord_would_fail!(i24_be3::MIN, i24_be3::EQUILIBRIUM); + assert_derived_ord_would_fail!(I24LE3::new_unchecked(-1), I24LE3::new_unchecked(1)); + assert_derived_ord_would_fail!(I24BE3::new_unchecked(-1), I24BE3::new_unchecked(1)); + + // Little-endian, either signedness: 1 is [01,00,00] and 256 is [00,01,00], so the smaller + // value sorts higher byte-wise even though both are positive. + assert_derived_ord_would_fail!(I24LE3::new_unchecked(1), I24LE3::new_unchecked(256)); + assert_derived_ord_would_fail!(U24LE3::new_unchecked(1), U24LE3::new_unchecked(256)); + + // `U24BE3` can have no discriminating case: for a big-endian *unsigned* value, + // lexicographic byte order and numeric order are the same relation. Its manual `Ord` is + // kept for consistency, not because a test could catch its absence. + assert!(u24_be3::MIN < u24_be3::EQUILIBRIUM); + assert!(u24_be3::EQUILIBRIUM < u24_be3::MAX); + assert!(U24BE3::new_unchecked(1) < U24BE3::new_unchecked(256)); + + // Ordering must agree with the derived `PartialEq`, which does compare bytes. + let a = I24LE3::new_unchecked(42); + assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal); + assert_eq!(a, I24LE3::new_unchecked(42)); +} + +/// Packing and unpacking is a pure re-arrangement of bits and so must be lossless for every value +/// in range, in both directions. +#[test] +fn round_trip_every_value() { + for v in -8_388_608..=8_388_607i32 { + assert_eq!(I24LE3::new_unchecked(v).inner(), v); + assert_eq!(I24BE3::new_unchecked(v).inner(), v); + assert_eq!(I24::from(I24LE3::new_unchecked(v)).inner(), v); + assert_eq!(I24::from(I24BE3::new_unchecked(v)).inner(), v); + } + for v in 0..=16_777_215i32 { + assert_eq!(U24LE3::new_unchecked(v).inner(), v); + assert_eq!(U24BE3::new_unchecked(v).inner(), v); + assert_eq!(U24::from(U24LE3::new_unchecked(v)).inner(), v); + assert_eq!(U24::from(U24BE3::new_unchecked(v)).inner(), v); + } +} + +/// Every one of the 2^24 byte patterns must decode to a distinct value and re-encode to itself, +/// which is what makes the byte-wise `PartialEq` consistent with the value-wise `Ord`. +#[test] +fn every_byte_pattern_survives_a_round_trip() { + for raw in 0..=0xFF_FFFFu32 { + let bytes = [raw as u8, (raw >> 8) as u8, (raw >> 16) as u8]; + assert_eq!( + I24LE3::new_unchecked(I24LE3::from_bytes(bytes).inner()).to_bytes(), + bytes + ); + assert_eq!( + I24BE3::new_unchecked(I24BE3::from_bytes(bytes).inner()).to_bytes(), + bytes + ); + assert_eq!( + U24LE3::new_unchecked(U24LE3::from_bytes(bytes).inner()).to_bytes(), + bytes + ); + assert_eq!( + U24BE3::new_unchecked(U24BE3::from_bytes(bytes).inner()).to_bytes(), + bytes + ); + } +} + +/// `add_amp` and `mul_amp` go via `Sample::Signed` and `Sample::Float`, so they must give the same +/// answers as the padded types. +#[test] +fn sample_ops() { + let half = I24LE3::from_sample(0.5f32); + assert_eq!(half.inner(), 4_194_304); + assert_eq!(half.mul_amp(0.5).inner(), 2_097_152); + assert_eq!(half.mul_amp(0.0), i24_le3::EQUILIBRIUM); + assert_eq!(half.add_amp(I24::new_unchecked(-4_194_304)).inner(), 0); + + // `U24LE3`/`U24BE3` take their `Signed` type from `U24`, which is `i32`, so the amplitude + // offset is in `i32` scale rather than 24-bit scale. + let half = U24BE3::from_sample(0.5f32); + assert_eq!(half.inner(), 12_582_912); + assert_eq!(half.mul_amp(0.0), u24_be3::EQUILIBRIUM); + assert_eq!(half.to_signed_sample(), 1_073_741_824i32); + assert_eq!(half.add_amp(-1_073_741_824), u24_be3::EQUILIBRIUM); + + assert_eq!(I24LE3::from_sample(-1.0f32).to_signed_sample(), i24::MIN); + assert_eq!(I24LE3::from_sample(-1.0f32).to_float_sample(), -1.0f32); + assert_eq!(U24LE3::from_sample(-1.0f32).to_float_sample(), -1.0f32); +} + +/// Pins `Sample::Signed` and `Sample::Float` for all four types. +/// +/// Without this, two of the four were unasserted: changing `I24BE3`'s `Signed` from `I24` to +/// `i32` compiled and passed the entire workspace suite, despite making `add_amp` read its +/// argument on a scale 256x off. The associated types are only observable through the scale +/// `add_amp` works on, so that is what is checked here — for every type, not just one. +#[test] +fn signed_and_float_associated_types() { + fn assert_signed, Expected>() {} + fn assert_float, Expected>() {} + + assert_signed::(); + assert_signed::(); + assert_signed::(); + assert_signed::(); + + assert_float::(); + assert_float::(); + assert_float::(); + assert_float::(); + + // Half scale offset back to equilibrium, in the units of that type's `Signed` -- the + // scale is exactly what a wrong associated type changes. + assert_eq!( + I24LE3::from_sample(0.5f32).add_amp(I24::new_unchecked(-4_194_304)), + i24_le3::EQUILIBRIUM + ); + assert_eq!( + I24BE3::from_sample(0.5f32).add_amp(I24::new_unchecked(-4_194_304)), + i24_be3::EQUILIBRIUM + ); + assert_eq!( + U24LE3::from_sample(0.5f32).add_amp(-1_073_741_824i32), + u24_le3::EQUILIBRIUM + ); + assert_eq!( + U24BE3::from_sample(0.5f32).add_amp(-1_073_741_824i32), + u24_be3::EQUILIBRIUM + ); + + assert_eq!(I24LE3::from_sample(0.5f32).mul_amp(0.5).inner(), 2_097_152); + assert_eq!(I24BE3::from_sample(0.5f32).mul_amp(0.5).inner(), 2_097_152); + assert_eq!( + U24LE3::from_sample(0.5f32).mul_amp(0.0), + u24_le3::EQUILIBRIUM + ); + assert_eq!( + U24BE3::from_sample(0.5f32).mul_amp(0.0), + u24_be3::EQUILIBRIUM + ); +} + +/// The payoff: a byte buffer straight off the wire, reinterpreted without a copy. +#[test] +fn reinterpret_byte_buffer() { + // Three S24_3LE samples: min, equilibrium, max. + let raw: [u8; 9] = [ + 0x00, 0x00, 0x80, // -8_388_608 + 0x00, 0x00, 0x00, // 0 + 0xFF, 0xFF, 0x7F, // 8_388_607 + ]; + + // Sound because `I24LE3` is `repr(transparent)` over `[u8; 3]`: every byte pattern is a valid + // value, the alignment is 1, and the length is an exact multiple of the stride. + assert_eq!(raw.len() % mem::size_of::(), 0); + let samples: &[I24LE3] = unsafe { + std::slice::from_raw_parts( + raw.as_ptr() as *const I24LE3, + raw.len() / mem::size_of::(), + ) + }; + + assert_eq!(samples.len(), 3); + assert_eq!(samples[0], i24_le3::MIN); + assert_eq!(samples[1], i24_le3::EQUILIBRIUM); + assert_eq!(samples[2], i24_le3::MAX); + + let floats: Vec = samples.iter().map(|s| s.to_sample::()).collect(); + assert_eq!(floats[0], -1.0); + assert_eq!(floats[1], 0.0); + assert!(floats[2] > 0.999_999 && floats[2] < 1.0); +} From d6bc91a17a7c9104621de53dcf66db8c1a532559 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 17:26:15 -0400 Subject: [PATCH 5/6] Give the packed types the arithmetic their padded equivalents have A signed sample type that is not `SignedSample` is an anomaly: every other signed sample type in the crate is one, and the trait's own description -- "types whose equilibrium is at 0" -- describes `I24LE3` accurately. The original reasoning for keeping them out was that arithmetic on three unaligned bytes would be slow, and measurement did not support it. The release-mode wrap is in fact free: truncating a value to three bytes *is* reduction modulo 2^24, landing on the same value the padded types reach through an explicit `wrap_overflow_once`, so the packed types skip a compare and a branch the padded ones pay. So `Sample::Signed` for `I24LE3` and `I24BE3` is now `Self`, both are added to `impl_signed_sample!`, and all four types gain the arithmetic operators `new_sample_type!` gives the padded types: `Add`, `Sub`, `Mul`, `Div` and `Rem`, with the same contract of panicking on overflow in debug and wrapping in release, `Div` and `Rem` unchecked as they are there too. `U24LE3` and `U24BE3` remain outside `SignedSample`, as no unsigned type is in it. The bitwise and shift operators are deliberately not mirrored. They are bit manipulation rather than arithmetic and do not carry over: `!x` on a padded type inverts the 32 bits of its container, including the byte that holds no sample data. Two of them are also marked `// TODO: Needs review` where they are defined. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 +++-- dasp_sample/src/lib.rs | 6 +- dasp_sample/src/types.rs | 90 +++++++++++++++++++++++-- dasp_sample/tests/packed.rs | 128 +++++++++++++++++++++++++++++++----- 4 files changed, 210 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba2749d..3b9d50d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,15 @@ four-byte container, these store it in exactly three bytes with an alignment of one, so a buffer of packed 24-bit PCM can be reinterpreted as a slice of samples without a copy. They implement `Sample`, `Frame` and the full set of - `FromSample`/`ToSample` conversions, but deliberately provide no arithmetic - of their own: operations go via `Sample::Signed` and `Sample::Float`, which - are `I24`/`i32` and `f32` as for the padded equivalents. They also offer the - same `From` conversions and `Neg` their padded equivalents do, and `I48`/`U48` - accept them as sources, so a call site can be retargeted from `I24` to - `I24LE3` by changing the type name. + `FromSample`/`ToSample` conversions. `Sample::Signed` and `Sample::Float` are + `I24LE3`/`i32` and `f32`, mirroring the padded types, so `I24LE3` and `I24BE3` + are `SignedSample` as every other signed sample type is, while `U24LE3` and + `U24BE3` are not, as no unsigned type is. They carry the same arithmetic + operators (`Add`, `Sub`, `Mul`, `Div`, `Rem`, and `Neg` on the signed pair) + with the same debug-panic/release-wrap contract, the same `From` conversions, + and `I48`/`U48` accept them as sources. The bitwise and shift operators are + not mirrored, being bit manipulation whose meaning does not carry from a + four-byte container to a three-byte one. Having no spare byte to store one, they wrap out-of-range values modulo 2^24 where `I24`/`U24` keep them. Only conversions from `f32`/`f64` outside the diff --git a/dasp_sample/src/lib.rs b/dasp_sample/src/lib.rs index a549322d..3992acea 100644 --- a/dasp_sample/src/lib.rs +++ b/dasp_sample/src/lib.rs @@ -263,8 +263,8 @@ impl_sample! { I24: Signed: I24, Float: f32, EQUILIBRIUM: types::i24::EQUILIBRIUM, // The packed types share the `Signed` and `Float` types of their padded equivalents, so all // arithmetic on them unpacks first rather than operating three bytes at a time. - I24LE3: Signed: I24, Float: f32, EQUILIBRIUM: types::i24_le3::EQUILIBRIUM, - I24BE3: Signed: I24, Float: f32, EQUILIBRIUM: types::i24_be3::EQUILIBRIUM, + I24LE3: Signed: I24LE3, Float: f32, EQUILIBRIUM: types::i24_le3::EQUILIBRIUM, + I24BE3: Signed: I24BE3, Float: f32, EQUILIBRIUM: types::i24_be3::EQUILIBRIUM, i32: Signed: i32, Float: f32, EQUILIBRIUM: 0, I48: Signed: I48, Float: f64, EQUILIBRIUM: types::i48::EQUILIBRIUM, i64: Signed: i64, Float: f64, EQUILIBRIUM: 0, @@ -292,7 +292,7 @@ pub trait SignedSample: { } macro_rules! impl_signed_sample { ($($T:ty)*) => { $( impl SignedSample for $T {} )* } } -impl_signed_sample!(i8 i16 I24 i32 I48 i64 f32 f64); +impl_signed_sample!(i8 i16 I24 I24LE3 I24BE3 i32 I48 i64 f32 f64); /// Sample format types represented as floating point numbers. /// diff --git a/dasp_sample/src/types.rs b/dasp_sample/src/types.rs index 74eda132..4172157e 100644 --- a/dasp_sample/src/types.rs +++ b/dasp_sample/src/types.rs @@ -30,10 +30,17 @@ //! assert_eq!(samples[2].inner(), 8_388_607); //! ``` //! -//! They are storage types and provide no arithmetic of their own. Operating on three bytes in -//! place would mean a decode and an encode per operation, hidden behind an operator; instead -//! `Sample::Signed` and `Sample::Float` are `I24`/`i32` and `f32` as for the padded types, so a -//! caller unpacks once and operates many times. +//! They carry the same arithmetic as their padded equivalents — `Add`, `Sub`, `Mul`, `Div`, +//! `Rem`, and `Neg` on the signed pair — with the same contract: `Add`, `Sub` and `Mul` panic on +//! overflow in debug and wrap in release, while `Div` and `Rem` are unchecked. The release wrap +//! costs nothing extra here, because truncating a value to three bytes *is* reduction modulo +//! 2^24, where the padded types need an explicit compare. `I24LE3` and `I24BE3` are therefore +//! `SignedSample`, like every other signed sample type; `U24LE3` and `U24BE3` are not, like every +//! other unsigned one. +//! +//! The bitwise and shift operators are not mirrored. They are bit manipulation rather than +//! arithmetic, and do not carry over: `!x` on a padded type inverts the 32 bits of its container, +//! including the byte holding no sample data. //! //! Two behaviours differ from the padded types and are easy to trip over: //! @@ -157,6 +164,77 @@ macro_rules! impl_packed_neg { }; } +// The arithmetic operators, mirroring what `new_sample_type!` gives the padded types: `Add`, +// `Sub` and `Mul` panic on overflow in debug and wrap in release, while `Div` and `Rem` are +// unchecked, exactly as the padded ones are. The release arms need no explicit wrap, because +// truncating to three bytes *is* reduction modulo 2^24 -- it lands on the same value the padded +// types reach through `wrap_overflow_once` (for `Add`/`Sub`) or `wrap_overflow` (for `Mul`). +// +// The bitwise and shift operators are deliberately not mirrored. They are bit manipulation +// rather than arithmetic, and their meaning does not carry over: `!x` on a padded type inverts +// the 32 bits of its container, including the byte that holds no sample data. +macro_rules! impl_packed_arithmetic { + ($T:ident) => { + impl ::core::ops::Add<$T> for $T { + type Output = $T; + #[inline] + fn add(self, other: Self) -> Self { + let sum = self.inner() + other.inner(); + if cfg!(debug_assertions) { + $T::new(sum).expect("arithmetic operation overflowed") + } else { + $T::new_unchecked(sum) + } + } + } + + impl ::core::ops::Sub<$T> for $T { + type Output = $T; + #[inline] + fn sub(self, other: Self) -> Self { + let difference = self.inner() - other.inner(); + if cfg!(debug_assertions) { + $T::new(difference).expect("arithmetic operation overflowed") + } else { + $T::new_unchecked(difference) + } + } + } + + impl ::core::ops::Mul<$T> for $T { + type Output = $T; + #[inline] + fn mul(self, other: Self) -> Self { + // As on the padded types, the product is formed in the same width the values + // unpack to, so a large enough pair overflows that before the sample range is + // ever considered. + let product = self.inner() * other.inner(); + if cfg!(debug_assertions) { + $T::new(product).expect("arithmetic operation overflowed") + } else { + $T::new_unchecked(product) + } + } + } + + impl ::core::ops::Div<$T> for $T { + type Output = $T; + #[inline] + fn div(self, other: Self) -> Self { + $T::new_unchecked(self.inner() / other.inner()) + } + } + + impl ::core::ops::Rem<$T> for $T { + type Output = $T; + #[inline] + fn rem(self, other: Self) -> Self { + $T::new_unchecked(self.inner() % other.inner()) + } + } + }; +} + macro_rules! new_sample_type { ($T:ident: $Rep:ident, eq: $EQ:expr, min: $MIN:expr, max: $MAX:expr, total: $TOTAL:expr, from: $($rest:tt)*) => { pub const MIN: $T = $T($MIN); @@ -483,6 +561,7 @@ pub mod i24_le3 { encode: |v| [v as u8, (v >> 8) as u8, (v >> 16) as u8], from: i8, i16, {I20}, i32, u8, u16, {U20}); impl_packed_neg!(I24LE3); + impl_packed_arithmetic!(I24LE3); } pub mod i24_be3 { @@ -493,6 +572,7 @@ pub mod i24_be3 { encode: |v| [(v >> 16) as u8, (v >> 8) as u8, v as u8], from: i8, i16, {I20}, i32, u8, u16, {U20}); impl_packed_neg!(I24BE3); + impl_packed_arithmetic!(I24BE3); } pub mod i48 { @@ -527,6 +607,7 @@ pub mod u24_le3 { decode: |b| (b[0] as i32) | ((b[1] as i32) << 8) | ((b[2] as i32) << 16), encode: |v| [v as u8, (v >> 8) as u8, (v >> 16) as u8], from: i32, u8, u16, {U20}); + impl_packed_arithmetic!(U24LE3); } pub mod u24_be3 { @@ -536,6 +617,7 @@ pub mod u24_be3 { decode: |b| (b[2] as i32) | ((b[1] as i32) << 8) | ((b[0] as i32) << 16), encode: |v| [(v >> 16) as u8, (v >> 8) as u8, v as u8], from: i32, u8, u16, {U20}); + impl_packed_arithmetic!(U24BE3); } pub mod u48 { diff --git a/dasp_sample/tests/packed.rs b/dasp_sample/tests/packed.rs index e6d5db12..7700d155 100644 --- a/dasp_sample/tests/packed.rs +++ b/dasp_sample/tests/packed.rs @@ -4,12 +4,11 @@ //! tested here is everything that is specific to their being packed: their memory layout, their //! byte order, and the traits whose derived implementations would have been wrong. -use dasp_sample::types::{i24, i24_be3, i24_le3, u24_be3, u24_le3, I20, U20}; -use dasp_sample::{Sample, I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; +use dasp_sample::types::{i24_be3, i24_le3, u24_be3, u24_le3, I20, U20}; +use dasp_sample::{Sample, SignedSample, I24, I24BE3, I24LE3, I48, U24, U24BE3, U24LE3, U48}; use std::mem; -/// The packed types offer the same `From` conversions and `Neg` as their padded equivalents, so -/// that a call site can be retargeted from `I24` to `I24LE3` by changing the type name alone. +/// The packed types offer the same `From` conversions and `Neg` as their padded equivalents. /// /// These are value-widening conversions rather than amplitude-preserving ones — `I24::from(3i8)` /// is `I24(3)`, not `I24(3 << 16)` — and the packed types match that exactly. @@ -51,6 +50,100 @@ fn from_and_neg_match_the_padded_types() { assert_eq!(U48::from(U24BE3::new_unchecked(7)).inner(), 7); } +/// The signed packed types are `SignedSample`, as every other signed sample type is, and the +/// arithmetic operators behave exactly as the padded ones do. +/// +/// Between this, the `From` conversions and `Neg`, a call site can be retargeted from `I24` to +/// `I24LE3` by changing the type name — for everything except the bitwise and shift operators, +/// which the packed types deliberately do not have. +/// +/// The release-mode wrap is free here: truncating to three bytes *is* reduction modulo 2^24, so +/// it lands on the same value the padded types reach through `wrap_overflow_once`. +#[test] +fn arithmetic_matches_the_padded_types() { + fn assert_signed_sample() {} + assert_signed_sample::(); + assert_signed_sample::(); + // The unsigned pair is not, exactly as `U24` is not. + + // The same operations `tests/types.rs` checks on the padded types, with the same answers. + macro_rules! check_arithmetic { + ($T:ident) => {{ + let n = |v| $T::new_unchecked(v); + assert_eq!((n(8) + n(12)).inner(), 20); + assert_eq!((n(12) - n(4)).inner(), 8); + assert_eq!((n(2) * n(2)).inner(), 4); + assert_eq!((n(3) * n(3)).inner(), 9); + assert_eq!((n(5) * n(10)).inner(), 50); + assert_eq!((n(16) / n(8)).inner(), 2); + assert_eq!((n(8) % n(3)).inner(), 2); + }}; + } + check_arithmetic!(I24LE3); + check_arithmetic!(I24BE3); + check_arithmetic!(U24LE3); + check_arithmetic!(U24BE3); + + // Identical to the padded types for the same inputs. + assert_eq!( + (I24LE3::new_unchecked(8) + I24LE3::new_unchecked(12)).inner(), + (I24::new_unchecked(8) + I24::new_unchecked(12)).inner() + ); + assert_eq!( + (I24LE3::new_unchecked(5) * I24LE3::new_unchecked(10)).inner(), + (I24::new_unchecked(5) * I24::new_unchecked(10)).inner() + ); + + // The bitwise and shift operators are deliberately absent, so these do not compile: + // let _ = I24LE3::new_unchecked(1) & I24LE3::new_unchecked(1); + // let _ = !I24LE3::new_unchecked(1); +} + +/// Overflow panics in debug, matching `I24`'s `Add`. In release it wraps instead, which is +/// checked in `overflow_wraps_in_release`. +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "arithmetic operation overflowed")] +fn add_panics_on_overflow_in_debug() { + let _ = i24_le3::MAX + I24LE3::new_unchecked(1); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "arithmetic operation overflowed")] +fn sub_panics_on_underflow_in_debug() { + let _ = i24_le3::MIN - I24LE3::new_unchecked(1); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "arithmetic operation overflowed")] +fn mul_panics_on_overflow_in_debug() { + let _ = i24_le3::MAX * I24LE3::new_unchecked(2); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "arithmetic operation overflowed")] +fn unsigned_sub_panics_on_underflow_in_debug() { + let _ = u24_le3::MIN - U24LE3::new_unchecked(1); +} + +/// In release the packed types wrap, landing on the same value the padded types reach through +/// `wrap_overflow_once`. +#[cfg(not(debug_assertions))] +#[test] +fn overflow_wraps_in_release() { + assert_eq!( + (i24_le3::MAX + I24LE3::new_unchecked(1)).inner(), + -8_388_608 + ); + assert_eq!( + (i24_le3::MAX + I24LE3::new_unchecked(1)).inner(), + (dasp_sample::types::i24::MAX + I24::new_unchecked(1)).inner() + ); +} + /// The whole reason these types exist: a buffer of packed 24-bit PCM must be reinterpretable as a /// slice of samples, which requires a stride of exactly three bytes and no alignment requirement. #[test] @@ -256,34 +349,37 @@ fn sample_ops() { assert_eq!(half.inner(), 4_194_304); assert_eq!(half.mul_amp(0.5).inner(), 2_097_152); assert_eq!(half.mul_amp(0.0), i24_le3::EQUILIBRIUM); - assert_eq!(half.add_amp(I24::new_unchecked(-4_194_304)).inner(), 0); + assert_eq!(half.add_amp(I24LE3::new_unchecked(-4_194_304)).inner(), 0); - // `U24LE3`/`U24BE3` take their `Signed` type from `U24`, which is `i32`, so the amplitude - // offset is in `i32` scale rather than 24-bit scale. + // The unsigned pair takes its `Signed` from `U24`, which is `i32`, so the amplitude offset + // is on the `i32` scale rather than the 24-bit one. The signed pair is its own `Signed`. let half = U24BE3::from_sample(0.5f32); assert_eq!(half.inner(), 12_582_912); assert_eq!(half.mul_amp(0.0), u24_be3::EQUILIBRIUM); assert_eq!(half.to_signed_sample(), 1_073_741_824i32); assert_eq!(half.add_amp(-1_073_741_824), u24_be3::EQUILIBRIUM); - assert_eq!(I24LE3::from_sample(-1.0f32).to_signed_sample(), i24::MIN); + assert_eq!( + I24LE3::from_sample(-1.0f32).to_signed_sample(), + i24_le3::MIN + ); assert_eq!(I24LE3::from_sample(-1.0f32).to_float_sample(), -1.0f32); assert_eq!(U24LE3::from_sample(-1.0f32).to_float_sample(), -1.0f32); } /// Pins `Sample::Signed` and `Sample::Float` for all four types. /// -/// Without this, two of the four were unasserted: changing `I24BE3`'s `Signed` from `I24` to -/// `i32` compiled and passed the entire workspace suite, despite making `add_amp` read its -/// argument on a scale 256x off. The associated types are only observable through the scale -/// `add_amp` works on, so that is what is checked here — for every type, not just one. +/// Without this, two of the four were unasserted: changing `I24BE3`'s `Signed` to `i32` +/// compiled and passed the entire workspace suite, despite making `add_amp` read its argument +/// on a scale 256x off. The associated types are only observable through the scale `add_amp` +/// works on, so that is what is checked here — for every type, not just one. #[test] fn signed_and_float_associated_types() { fn assert_signed, Expected>() {} fn assert_float, Expected>() {} - assert_signed::(); - assert_signed::(); + assert_signed::(); + assert_signed::(); assert_signed::(); assert_signed::(); @@ -295,11 +391,11 @@ fn signed_and_float_associated_types() { // Half scale offset back to equilibrium, in the units of that type's `Signed` -- the // scale is exactly what a wrong associated type changes. assert_eq!( - I24LE3::from_sample(0.5f32).add_amp(I24::new_unchecked(-4_194_304)), + I24LE3::from_sample(0.5f32).add_amp(I24LE3::new_unchecked(-4_194_304)), i24_le3::EQUILIBRIUM ); assert_eq!( - I24BE3::from_sample(0.5f32).add_amp(I24::new_unchecked(-4_194_304)), + I24BE3::from_sample(0.5f32).add_amp(I24BE3::new_unchecked(-4_194_304)), i24_be3::EQUILIBRIUM ); assert_eq!( From adac211c25b51ca563a583c99999f4149fc06b32 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 15 Aug 2026 17:31:51 -0400 Subject: [PATCH 6/6] Point dasp_graph at its workspace siblings rather than crates.io `dasp_graph`'s dependencies on `dasp_frame`, `dasp_ring_buffer`, `dasp_signal` and `dasp_slice` carry no `path`, unlike every other member of this workspace, so it builds against the published crates instead of the ones next to it. That pulls a second copy of most of the workspace into the graph, including `dasp_window 0.11.1`, which is newer than the 0.11.0 in this repository. It went unnoticed because the duplicated crates had the same version numbers as the local ones, so `cargo doc` wrote both copies of each into the same `target/doc/` directory with identical content. Bumping `dasp_sample` and `dasp_frame` to 0.11.1 in this branch made the contents differ, and the two rustdoc invocations then raced over the same output paths -- `cargo doc --all --all-features` failed with error: ".../target/doc/dasp_frame/struct.N23.html": No such file or directory (os error 2) on one CI run and passed on another, which is what a race looks like. With the paths added, each crate is documented exactly once and no published dasp crate is downloaded at all. This is independent of the packed sample types and can be split into its own PR if preferred; it is here because the version bump is what exposed it. Co-Authored-By: Claude Opus 5 (1M context) --- dasp_graph/Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dasp_graph/Cargo.toml b/dasp_graph/Cargo.toml index 85e9cfc1..766e718f 100644 --- a/dasp_graph/Cargo.toml +++ b/dasp_graph/Cargo.toml @@ -21,10 +21,10 @@ node-signal = ["dasp_frame", "dasp_signal"] node-sum = ["dasp_slice"] [dependencies] -dasp_frame = { version = "0.11", default-features = false, features = ["std"], optional = true } -dasp_ring_buffer = { version = "0.11", default-features = false, features = ["std"], optional = true } -dasp_signal = { version = "0.11", default-features = false, features = ["std"], optional = true } -dasp_slice = { version = "0.11", default-features = false, features = ["std"], optional = true } +dasp_frame = { version = "0.11", path = "../dasp_frame", default-features = false, features = ["std"], optional = true } +dasp_ring_buffer = { version = "0.11", path = "../dasp_ring_buffer", default-features = false, features = ["std"], optional = true } +dasp_signal = { version = "0.11", path = "../dasp_signal", default-features = false, features = ["std"], optional = true } +dasp_slice = { version = "0.11", path = "../dasp_slice", default-features = false, features = ["std"], optional = true } petgraph = { version = "0.5", default-features = false } [dev-dependencies]