Add packed 24-bit sample types (S24_3LE and friends) - #203
Open
mdwn wants to merge 1 commit into
Open
Conversation
mdwn
marked this pull request as ready for review
August 15, 2026 20:45
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
force-pushed
the
feat/packed-24-bit-sample-types
branch
from
August 31, 2026 20:46
adac211 to
f059b71
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full disclosure: Claude Opus 5 was used in the implementation of this.
Adds
I24LE3,I24BE3,U24LE3andU24BE3— 24-bit samples packed into exactly three bytes, in a fixed byte order. These are the formats ALSA callsS24_3LE,S24_3BE,U24_3LEandU24_3BE.Why
I24andU24store their value in ani32, so they are four bytes wide with an alignment of four — theS24_LElayout. 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 ofI24: the stride is wrong and the alignment is not satisfied, so the only options today are a conversion pass or letting ALSA'spluglayer convert in software.The concrete case that prompted this: a Behringer WING offers
S24_3LEat 48 channels and nothing else. cpal, which builds ondasp_sample, reports zero usable configurations for it.What the types are
Each is
#[repr(transparent)]over[u8; 3], sosize_ofis exactly 3,align_ofis 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, andNegon the signed pair — with the same contract: panic on overflow in debug, wrap in release,DivandRemunchecked. 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.I24LE3andI24BE3are thereforeSignedSample, like every other signed sample type;U24LE3andU24BE3are 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:
!xon a padded type inverts the 32 bits of its container, including the byte holding no sample data.Ordis hand-written rather than derived. A derivedOrdcompares[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.PartialEqis still derived, since the encoding is bijective.They otherwise mirror their padded equivalents: the same
Fromimpls,Negon the signed pair only, andI48/U48accept them as sources. Between that and the arithmetic above, a call site can be retargeted fromI24toI24LE3by 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.0range can:I24andU24absorb 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
I24andI48happen 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 I24already does, and theconvmodule documents the behaviour and points at #39 and #73. Happy to revisit if you would rather they saturate.Version bump
dasp_sampleanddasp_framego to 0.11.1, anddasp_frame's requirement ondasp_sampleis raised from"0.11"to"0.11.1".dasp_framenow namestypes::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 compiledasp_frame. Please drop these changes if versioning is better done at release time — the requirement bump is the part that matters.A
dasp_graphbuild fix, dragged in by the abovedasp_graph's dependencies ondasp_frame,dasp_ring_buffer,dasp_signalanddasp_slicecarry nopath, 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, includingdasp_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 docwrote both copies of each into the sametarget/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-featuresfailed withon one CI run of this branch and passed on another. Adding the four
pathentries 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.rscovers 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.types * types, which only proved the lists were the same length.Sample::Signedis now pinned for all four types. It was unasserted forI24BE3andU24LE3: changingI24BE3's fromI24toi32compiled and passed the entire suite.conv_cmp!builds expected values withnewrather thannew_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_stdverified by building forriscv32imafc-unknown-none-elf, not just--no-default-features.cargo fmt --check,cargo test --alland the per-crate--no-default-featuresjobs all pass, andcargo clippyreports no new warnings.Verified on big-endian. The
dasp_sampleanddasp_framesuites — 360 tests, including both exhaustive ones and every doctest — pass ons390x-unknown-linux-gnuunderqemu-s390xuser-mode emulation, alongside the little-endian runs (x86-64, aarch64). That coverslayout,byte_order,sign_extensionand the zero-copyreinterpret_byte_buffertest, 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,transmuteorcfg(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:
hw:device reports no supported configurations. After, it reportsi24packedat 48 channels / 48 kHz in both directions, matchingaplay --dump-hw-params.S24_3LEbytes assembled from the format spec in Python, played throughsnd-aloop, and decoded correctly by these types — includingFF 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.