Skip to content

Add packed 24-bit sample types (S24_3LE and friends) - #203

Open
mdwn wants to merge 1 commit into
RustAudio:masterfrom
mdwn:feat/packed-24-bit-sample-types
Open

Add packed 24-bit sample types (S24_3LE and friends)#203
mdwn wants to merge 1 commit into
RustAudio:masterfrom
mdwn:feat/packed-24-bit-sample-types

Conversation

@mdwn

@mdwn mdwn commented Aug 15, 2026

Copy link
Copy Markdown

Full disclosure: Claude Opus 5 was used in the implementation of this.

Adds I24LE3, I24BE3, U24LE3 and U24BE3 — 24-bit samples packed into exactly three bytes, in a fixed byte order. These are the formats ALSA calls S24_3LE, S24_3BE, U24_3LE and U24_3BE.

Why

I24 and U24 store their value in an i32, so they are four bytes wide with an alignment of four — the S24_LE layout. A lot of hardware instead carries 24-bit audio as exactly three bytes, and some supports nothing else. A buffer in that format cannot be reinterpreted as a slice of I24: the stride is wrong and the alignment is not satisfied, so the only options today are a conversion pass or letting ALSA's plug layer convert in software.

The concrete case that prompted this: a Behringer WING offers S24_3LE at 48 channels and nothing else. cpal, which builds on dasp_sample, reports zero usable configurations for it.

What the types are

Each is #[repr(transparent)] over [u8; 3], so size_of is exactly 3, align_of is exactly 1, and all 2^24 byte patterns are valid values. That is what makes the zero-copy reinterpretation sound.

They carry the same arithmetic as their padded equivalents — Add, Sub, Mul, Div, Rem, and Neg on the signed pair — with the same contract: panic on overflow in debug, wrap in release, Div and Rem unchecked. The release wrap costs nothing extra, 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.

Ord is hand-written rather than derived. A derived Ord compares [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. PartialEq is still derived, since the encoding is bijective.

They otherwise mirror their padded equivalents: the same From impls, Neg on the signed pair only, and I48/U48 accept them as sources. Between that and the arithmetic above, a call site can be retargeted from I24 to I24LE3 by changing the type name, for everything but the bitwise and shift operators.

Overflow: not litigated here

Every ordered pair of the eighteen sample types converts. 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 — every integer conversion into 24 bits is a shift that fits by construction, so no integer source can overflow. A float outside the documented -1.0 <= v < 1.0 range can: I24 and U24 absorb the overshoot in the spare byte of their container, and a packed type has no spare byte, so it wraps modulo 2^24.

That tolerance looks accidental rather than designed. #73 reports the same overflow against the primitive types and notes in passing that I24 and I48 happen not to be affected — which is exactly this spare-byte behaviour, not a decision anyone made.

I considered saturating instead, and measured it: ~+0.45 ns/sample on aarch64, about +32% of that conversion, though only 0.1% of a core for 48 channels at 48 kHz. I have not done it. There is no saturating behaviour anywhere in the crate today, #39 has been open since 2016, and a feature PR is the wrong place to decide policy that would apply to every type. So these types wrap, which is what impl From<i32> for I24 already does, and the conv module documents the behaviour and points at #39 and #73. Happy to revisit if you would rather they saturate.

Version bump

dasp_sample and dasp_frame 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 this, so no test run or CI job catches it, but a downstream crate resolving 0.11.0 would fail to compile dasp_frame. Please drop these changes if versioning is better done at release time — the requirement bump is the part that matters.

A dasp_graph build fix, dragged in by the above

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 rather than the ones sitting next to it. That pulls a second copy of most of the workspace into the dependency graph, including dasp_window 0.11.1, which is newer than the 0.11.0 in this repository.

It was invisible while the duplicates carried the same version numbers as the local crates: cargo doc wrote both copies of each into the same target/doc/<crate> directory with identical content. The bump to 0.11.1 above makes the contents differ, and the two rustdoc invocations then race 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 of this branch and passed on another. Adding the four path entries means each crate is documented exactly once and no published dasp crate is downloaded at all.

This is independent of the sample types and is happy to become its own PR; it is here because the version bump is what exposed it.

Testing

  • tests/packed.rs covers layout, byte order, sign extension, ordering, wrapping, and the zero-copy reinterpretation. Two tests are exhaustive, over all 2^24 values and all 2^24 byte patterns.
  • The conversion matrix test now expands a single type list along both dimensions. Previously it wrote the targets out twice and compared against types * types, which only proved the lists were the same length.
  • Sample::Signed is now pinned for all four types. It was unasserted for I24BE3 and U24LE3: changing I24BE3's from I24 to i32 compiled and passed the entire suite.
  • conv_cmp! builds expected values with new rather than new_unchecked, so an out-of-range expectation fails instead of quietly becoming the value the assertion agrees with. All existing expectations were already in range.

no_std verified by building for riscv32imafc-unknown-none-elf, not just --no-default-features. cargo fmt --check, cargo test --all and the per-crate --no-default-features jobs all pass, and cargo clippy reports no new warnings.

Verified on big-endian. The dasp_sample and dasp_frame suites — 360 tests, including both exhaustive ones and every doctest — pass on s390x-unknown-linux-gnu under qemu-s390x user-mode emulation, alongside the little-endian runs (x86-64, aarch64). That covers layout, byte_order, sign_extension and the zero-copy reinterpret_byte_buffer test, which are the ones a host-endianness assumption would break. Consistent with that, the encode and decode paths index bytes explicitly and use no *_ne_bytes, transmute or cfg(target_endian). Emulated rather than real hardware, but it does exercise big-endian byte order throughout.

Hardware

Verified end-to-end against a Behringer WING over ALSA, using a patched cpal:

  • Before, the raw hw: device reports no supported configurations. After, it reports i24packed at 48 channels / 48 kHz in both directions, matching aplay --dump-hw-params.
  • Callback buffers arrive as exactly 241 frames × 48 channels. Under four-byte accounting the same period would be 180.75 frames, so the three-byte stride is carried correctly through the whole path.
  • Byte order confirmed against an independent oracle: raw S24_3LE bytes assembled from the format spec in Python, played through snd-aloop, and decoded correctly by these types — including FF 00 00 → 255, which a big-endian reading would render as −65536. A loopback round-trip alone would not prove this, since a consistent byte-order error cancels out.
  • A full song played through the device at 24-bit with no xruns using a patched cpal and a patched mtrack.

@mdwn
mdwn marked this pull request as ready for review August 15, 2026 20:45
@mdwn

mdwn commented Aug 15, 2026

Copy link
Copy Markdown
Author

Sorry to tag you directly here @roderickvd -- are you who I would ask to review this?

Add `I24LE3`, `I24BE3`, `U24LE3` and `U24BE3`: 24-bit samples packed into
exactly three bytes in a fixed byte order, the formats ALSA calls
`S24_3LE`, `S24_3BE`, `U24_3LE` and `U24_3BE`.

`I24` and `U24` store their value in an `i32`, so they are four bytes wide
with an alignment of four. A lot of hardware instead carries 24-bit audio
as exactly three bytes, and some supports nothing else. Such a buffer
cannot be reinterpreted as a slice of `I24`: the stride is wrong and the
alignment is not satisfied, leaving a conversion pass or ALSA's `plug`
layer as the only options. Each new type is `#[repr(transparent)]` over
`[u8; 3]`, so `size_of` is exactly 3, `align_of` is exactly 1, and all
2^24 byte patterns are valid values, which is what makes the zero-copy
reinterpretation sound.

They mirror their padded equivalents throughout: `Sample`, `Frame`, the
full `FromSample`/`ToSample` matrix, the same `From` impls, `I48`/`U48`
accepting them as sources, and the arithmetic operators with the same
debug-panic/release-wrap contract, so `I24LE3` and `I24BE3` are
`SignedSample` and the unsigned pair are not. The bitwise and shift
operators are deliberately not mirrored: they are bit manipulation rather
than arithmetic, and `!x` on a padded type inverts the byte that holds no
sample data. `Ord` is hand-written, since a derived one would compare
`[u8; 3]` lexicographically -- least significant byte first for the
little-endian types, and the sign byte read as unsigned for the signed
ones.

Having no spare byte to store one, they wrap out-of-range values modulo
2^24 where `I24`/`U24` keep them. Only `f32`/`f64` sources 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<i32> for I24` already does, and no attempt is
made here to settle the wider overflow question raised in RustAudio#39 and RustAudio#73.

`dasp_sample` and `dasp_frame` go to 0.11.1, and `dasp_frame`'s
requirement on `dasp_sample` is raised to `0.11.1`, since it now names
`types::I24LE3` and would not build against the published 0.11.0. Inside
the workspace the path dependency masks that, so no CI job would catch it.

Also adds the missing `path` entries to `dasp_graph`'s dependencies on its
workspace siblings. Without them it builds against the published crates,
pulling a second copy of most of the workspace into the graph. The version
bump above made the duplicated contents differ, and the two rustdoc
invocations then raced over the same `target/doc` paths, so
`cargo doc --all --all-features` failed on one CI run and passed on
another. This is independent of the sample types and can be split out if
preferred; it is here because the version bump is what exposed it.

tests/packed.rs covers layout, byte order, sign extension, ordering,
wrapping and the zero-copy reinterpretation, two of its tests 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 compared a pair count
against two separately written lists, `Sample::Signed` was unasserted for
`I24BE3` and `U24LE3`, and `conv_cmp!` built expectations with
`new_unchecked`, which wraps. The suites pass on s390x-unknown-linux-gnu
under qemu as well as on little-endian hosts.
@mdwn
mdwn force-pushed the feat/packed-24-bit-sample-types branch from adac211 to f059b71 Compare August 31, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant