Skip to content

Feat/transcode ac3 dts - #43

Merged
vyrti merged 38 commits into
mainfrom
feat/transcode-ac3-dts
Aug 26, 2026
Merged

Feat/transcode ac3 dts#43
vyrti merged 38 commits into
mainfrom
feat/transcode-ac3-dts

Conversation

@vyrti

@vyrti vyrti commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

No description provided.

vyrti added 9 commits August 24, 2026 11:54
VuIO has no audio decoder for AC-3, E-AC-3 or DTS, so a TV without those
licences plays the picture and no sound. Symphonia identifies those tracks
(CODEC_ID_AC3 / CODEC_ID_EAC3 / CODEC_ID_DCA) and demuxes them fine — it just
has no decoder. These four crates are that missing piece.

Vendored rather than depended on. oxideav-dts is published to crates.io only as
a yanked 0.0.1, so no registry dependency can ship DTS at all; the rest of the
family moves fast enough that a floating version would change what a release
decodes without anyone choosing it.

The copies are verbatim — same crate names, same layout, no patches — so a diff
against upstream stays meaningful and a refresh is a re-run of the script rather
than a merge. Only three mechanical manifest changes: sibling deps become path
deps, publishing is off (we do not own these names), and lints are allowed,
because normalising 6 MB of foreign code to our clippy settings would destroy
the diffability that is the point of vendoring verbatim.

Upstream tests/ and benches/ are dropped, except the two fixtures the inline
src/ tests include_bytes! — 17 KB total, and worth it: `cargo test -p oxideav-ac3`
still checks the decoder we ship against a real bitstream. All 2330 upstream
tests pass against the vendored copies.

Costs no new external crates: thiserror, serde_json and bytemuck were already
in the tree. Workspace members so the path deps resolve, but not default
members, so their test and bench targets stay off the release path.

Verified: cargo test -p oxideav-core -p oxideav-ac3 -p oxideav-dts -p oxideav-aac
--lib → 869 + 459 + 280 + 722 passed, 0 failed.
The decode half of the feature: identify the three codecs, index an elementary
stream's frames, and drive the vendored decoders to interleaved S16.

`TranscodeCodec` is static identification and compiles in every build, so a
server without a decoder still knows what an AC-3 track is and can say so
honestly. Only the decode path is behind `transcode-ac3` / `transcode-dts` —
the same split `mediainfo` draws between knowing what a provider is and being
able to call one. `is_decodable()` is what lets the DIDL writer ask "can this
build handle this?" without carrying a pile of #[cfg] at the call site.

The index reads headers only and streams the file through a fixed buffer: each
frame declares its own length, so the walk hops frame to frame without decoding,
and the AC-3 track of a two-hour film is around 170 MB. It exists to make the
transcoded resource *seekable* — decoded PCM is constant-bitrate, so an exact
sample count is an exact Content-Length and a byte offset divides back into a
frame. Leading tags and damaged frames resync rather than failing the file.

AC-3 and E-AC-3 share a syncword and are told apart by bsid, which both
syntaxes place at bit 40; base AC-3 reaches it through crc1+fscod+frmsizecod
and Annex E through strmtyp+substreamid+frmsiz+fscod+numblkscod+acmod+lfeon.
One probe serves both, so a stream may even switch mid-file. DTS frames carry
raw NBLKS and run (NBLKS + 1) x 32 samples.

Channel count is measured from a decoded frame rather than predicted from
acmod: what matters downstream is what the decoder actually emits after its
downmix. That downmix is the decoder's own, because AC-3 carries the §7.8
coefficients in the bitstream — summing channels ourselves would discard the
mix the encoder intended and clip besides.

A frame that fails to decode yields silence of the length the index promised.
The Content-Length is already committed by then, so a decode error has to cost
a tick rather than a truncated download.

Verified: 16 tests, including both fixtures decoding to exactly the byte count
their index predicted and to non-silent audio. Builds clean with no features,
with `transcode` alone, and with each decoder alone.
Adds GET/HEAD /media/{id}/transcode/audio.wav, the [transcode] config section,
and the elementary extensions (.ac3/.eac3/.ec3/.dts) that were not in the
supported-media table at all — VuIO could neither play nor even list those
files before, which is why they were left out.

A separate handler from serve_media rather than a mode of it, because almost
every assumption differs: there is no file to open, the length comes from a
plan rather than from stat, the DLNA headers must declare a conversion
(DLNA.ORG_CI=1, hardcoded to 0 on the passthrough path), and the work is
CPU-bound and therefore rationed.

What it keeps is the contract a renderer cannot recover from if it is broken:
an exact Content-Length, Accept-Ranges, and real 206s. Decoded PCM is
constant-bitrate, so a byte offset divides cleanly back into a sample and a
seek is a genuine seek rather than a restart. That is what the frame index
buys, and why AudioPlan resolves total samples, sample rate and channel count
before a byte is sent — a renderer asks for the size and the bytes on separate
connections and will not tolerate the two disagreeing.

Seeking decodes one frame of pre-roll and discards it. AC-3 and DTS frames
overlap by half a window, so the sample at a seek point is reconstructed from
state the previous frame carried; without the pre-roll every seek ticks.

Plans are cached per (id, size, mtime): a renderer issues HEAD, then GET, then
ranges as someone scrubs, and building a plan re-reads the track's headers.
Keying on size and mtime means a file replaced in place is re-indexed rather
than served offsets it no longer has.

max_concurrent refuses rather than queues. A renderer told to wait looks like a
file that will not open, and the streams already playing would lose CPU to it.
A HEAD releases its slot immediately so a renderer that probes before playing
cannot starve one that is playing. Decoding runs on a blocking thread behind a
bounded channel, so a slow reader slows the decoder instead of pulling a film
into memory.

Verified: 8 integration tests against the real router, including a range being
byte-identical to that slice of the full decode, HEAD and GET agreeing on
length, and the fixture decoding to non-silent audio. 498 unit tests pass.
An AC-3/E-AC-3/DTS item now carries two resources in its DIDL: the file as
stored, and the decoded alternative at /media/{id}/transcode/audio.wav. Both
are offered and the renderer picks.

That is the design, not a compromise. There is no reliable table of which
television model licensed which codec — brands dropped DTS at different times,
and the same model differs by region — so any guess we made would be wrong for
someone, and wrong in the direction of silence. Offering both and letting
protocolInfo matching decide is what the standard is for. The decoded resource
carries a different MIME precisely so that matching can tell them apart, and
DLNA.ORG_CI=1 to say the bytes were produced rather than stored; every other
<res> this server writes says CI=0.

`prefer` handles the renderers that take the first resource without checking.
It defaults to "original", which cannot make anything worse: a set that can
already play AC-3 sees exactly the DIDL it saw before. Someone whose TV still
plays silently flips it.

The alternative is only advertised when this build can actually decode the
codec — is_decodable() is a compile-time constant asked at runtime — because
advertising a resource we cannot produce turns a silent film into a broken one.

Both DIDL writers emit it: the indexed path and the older fallback. They were
already near-duplicates, and an item must not lose its alternative depending on
which one happened to serve it. The browse cache is keyed by client profile
already, so per-profile output cannot cross-contaminate.

Verified: 12 integration tests, including two <res> elements for an AC-3 item
browsed as a Samsung set, the ordering flipping with `prefer`, and exactly one
<res> with the feature off.
`audio_format = "aac"` now serves /media/{id}/transcode/audio.aac, so the
config key offers two real choices rather than one working value and one 404.

The trade is explicit in what the resource claims. AAC output size is not known
until it has been produced, so there is no honest Content-Length, and the
response therefore carries no Accept-Ranges and DLNA.ORG_OP=00: no seeking,
rather than advertising ranges it would then refuse. LPCM stays the default
precisely because it keeps those.

ADTS rather than a container — every frame carries its own header, so the
stream is self-describing from any point and needs no muxer, no seek table and
no rewrite at the end. The decoder already emits interleaved S16, which is what
the encoder takes, so this is a pipe rather than a conversion.

Bitrate is left at the encoder's 64 kbps per channel, the conventional AAC-LC
operating point and about a tenth of the same audio as LPCM. Deliberately not a
config key: it is a knob whose wrong setting is audible and which nothing here
benefits from tuning.

Also moves the enabled check to the live config rather than the startup
snapshot, so the admin UI's Live impact for [transcode] is honest.

Verified: 14 integration tests including ADTS framing on the wire, the absent
length and range headers, and the DIDL advertising whichever resource the
config selected.
Adds [transcode] to docs/configuration.md, the decoder rows to the vuio-core
feature table, the two new routes to CLAUDE.md, and a CLAUDE.md rule for
crates/vendor mirroring the existing one for the vuio-web bundle.

The feature table's numbers were stale — 217/148 against a tree that now
resolves 248/142 — so every row is re-measured rather than leaving fresh
numbers beside old ones. Two rows were missing entirely (mediainfo, web-ui).
The zeroes now carry their explanation: dashboard and mcp shed compiled code
rather than dependencies, and metadata shares symphonia with casting so
dropping it alone sheds nothing.

The decoders cost 4 crates, not a subtree: thiserror, serde_json and bytemuck
were already in the tree, so what they add is about 6 MB of compiled decoder.

CI's feature-matrix loop gains the four new features and the mediainfo it had
been missing since that feature landed. It also gains a leg that builds and
tests with the decoders dropped — these ship on by default, which makes their
absence the path that rots unnoticed, so proving a feature builds is no longer
the only thing that job needs to prove.

The Docker image ships with the decoders, since someone whose television plays
a film silently will not rebuild an image to fix it. VUIO_CARGO_FLAGS is there
for an operator who knows their renderers and wants them out.
Two bugs a live server found that the unit tests could not.

The scanner never saw these files. Adding the MIME types was not enough: the
extension allowlist is a separate list with four independent copies — the
platform default, the Docker env default, the generated config template and the
documented example. Every integration test injects MediaFile rows straight into
the database, so all of them passed against a scanner that indexed nothing.

Seeking decoded the primed frame twice. Opening a decoder necessarily decodes a
frame, and the loop then fed that same frame in again, pushing its samples
through the IMDCT overlap buffer a second time — so every frame after a seek
landed somewhere a sequential decode never goes. It cancels out at frame 1,
where the pre-roll is frame 0 and a whole-file decode primes on frame 0 too,
which is exactly where the existing range test seeked. `prime` now hands back
the frame it already decoded instead of it being decoded again.

The range tests now assert the real contract rather than a stronger one that
happened to hold: a ranged response is the same audio as that slice of the
whole to within one LSB, not byte-for-byte. A seek reaches the decoder's
floating-point state by a different route, so a sample on a rounding boundary
can quantise to the adjacent integer — 1/32768, about -90 dBFS, and the same
tolerance the decoder's own conformance suite uses. The comparison respects
sample phase, because a range may begin mid-sample and pairing bytes from the
wrong phase reports a 1-LSB difference as 256.

The new test seeks into frames 2, 5 and 8, where the pre-roll is real.

Verified against a running server: 206s with exact lengths and <= 1 LSB for a
mid-file range, a header-straddling range and a range to the last byte; 416
past the end; both <res> elements present in a Samsung browse; the AAC variant
at 3244 bytes against 98348 of LPCM.
vyrti added 20 commits August 24, 2026 13:35
VuIO re-encodes DTS soundtracks to AC-3 5.1 @ 640 kbps for the video.ts
route, on a single blocking thread that also carries demux, decode and TS
packetisation, and a television seeking through a film pays that cost
twenty times over in ranged probes. Measured against ffmpeg on an M1 the
encoder ran at 35.6x realtime where ffmpeg manages 188x — 5.3x slower.

This takes it to 109x. Three changes, in descending order of what the
profiler actually blamed rather than what looked expensive:

  crc: 25% of encode time. The LFSR ran one bit at a time, and crc1 —
  which sits at the front of the region it protects — was solved by
  Gaussian elimination over GF(2), costing seventeen passes over the 5/8
  region plus sixteen heap allocations per frame. Now a compile-time
  byte table, and crc1 by the closed form X = R·(x^-1)^(8·len-16), one
  multiply against a factor computed once per encoder. Bit-exact: the
  golden digests are unchanged.

  mdct: the forward transform was a dense 256x256 matrix-vector product,
  65,536 multiply-accumulates against 256 KiB of cosine table streamed
  through cache per block per channel, 36 times a frame — despite the
  file calling itself "fast SIMD-accelerated". Now a half-length complex
  FFT with tabled twiddles, checked against the direct-form oracle.

  bit allocation: the rate-control tuner evaluates ~250 candidate
  snroffst values per frame and re-ran the entire psychoacoustic chain
  for each, though snroffst only enters at the final table lookup. Split
  into a MaskCurve computed once per channel-block and a cheap lookup,
  with a test asserting the two agree across every snroffst the search
  can reach.

The crate moves out of crates/vendor: it is no longer a verbatim copy of
github.com/OxideAV/oxideav-ac3, so keeping it there would break the
diffability that vendoring is for. LICENSE and attribution are retained.
…env switches

The rate-control search only ever wanted a bit count, but it materialised
10.5 KiB of bap arrays per candidate and walked them back to add up the
bits. A block's mantissa cost turns out to depend only on how many bap
values of each grouped class it holds — a group opens on every third (or
second) value of its class regardless of what sits between them — so the
count can be accumulated as the baps are derived and the arrays never
built. A test checks that against the sequential accounting it replaces.

Separately, the AC3_* debug switches were read with std::env::var where
they were used, which put a global lock, a scan of environ and sometimes
a String allocation inside per-block and per-candidate loops: ~190
lookups a frame. They are process-lifetime constants; they now load once.

5.1 @ 640 kbps: 109x -> 117x realtime. Both changes are bit-exact.
Everything a syncframe needs after transient detection depends only on
that frame: the §8.2.2 detector carries biquad state across frames and
the delay line carries 256 samples of left context, but the transform,
coupling, exponents, rate control and packing do not. So emit_syncframe
splits in two — analyse_frame, which runs in order and is a pair of
biquads per block, and encode_analysed, which takes &self and is where
all the work is — and a batch of eight frames fans out across rayon.

Eight frames is 256 ms of buffering, well inside the 0.4 s the TS muxer
already batches before it emits anything, so it costs a seek nothing it
was not paying. flush() now pads to a frame boundary rather than
assuming one short frame is all that remains.

5.1 @ 640 kbps on an M1: 117x -> 267x realtime, against ffmpeg's 189x.
Output is bit-identical to the single-threaded encoder — packets are
collected in order and no frame reads another's state.

Also clears the clippy errors this crate carried from upstream: the
simd.rs dispatchers put their scalar fallback after an early return, so
on aarch64 it was unreachable rather than cfg'd out. The gate now passes
where it did not before.
Transient detection is the one part of encoding that has to run in frame
order — the §8.2.2 biquads carry state across frames — so after the rayon
split it sits on the serial path and caps what the parallel half can
reach. It was reaching through self.biquad_state[stage] inside the sample
loop, paying a bounds check and four stores per sample per stage for a
filter whose arithmetic is nine operations. Both stages' history now
lives in locals and is written back once. The expression order is
unchanged, so the filter output is bit-identical.

The peak hierarchy was scanning the same 256 samples three times over.
Max is associative, so the four level-3 segment peaks are all the
scanning needed and the level-2 and level-1 peaks fall out of those; the
silence branch was recomputing two it already had.

Also replaces 2f32.powi(exp) on the per-coefficient quantiser path with a
25-entry table. Exponents are 0..=24 by construction and powers of two
are exact in binary floating point, so this is the same values looked up
rather than an approximation — but powi with a runtime exponent lowers to
a loop of squarings, several thousand times a frame.

5.1 @ 640 kbps: 267x -> 275x realtime (121x on one core). Bit-exact.
vyrti added 9 commits August 25, 2026 21:32
The soundtrack format is now config-driven and defaults to AC-3, so the
surfaces that still described LPCM had to catch up, and one of them was
wrong rather than merely stale.

An audio item whose own codec is AC-3 or E-AC-3 was being offered an AC-3
alternative. The decoded resource exists for a renderer that may hold no
Dolby licence, so handing it the same codec re-encoded offers it nothing it
had not already refused. Such an item now falls back to LPCM, which is what
this path produced before and what every renderer decodes; `aac` and `lpcm`
cannot collide this way and are unaffected. `item_audio_is_dolby` answers
the question next to `item_needs_transcode`, keeping the XML writers clear
of feature-gated codec knowledge.

The transport-stream length promise needed no change — it is derived from
the output codecs' own rates, so it already followed a soundtrack up to
AC-3 5.1 as it had followed one down to stereo AAC. The tests asserting
otherwise were written when stereo was the only outcome, and now check both
formats: five DTS soundtracks reach the renderer at half the rate they left
at rather than an eighth, and the promise says so.

Also: `audio_format = "ac3"` in both config templates and the admin enum,
the docs' account of what the setting decides for an item versus a film,
and a checked-division lint in the AC-3 range handler.

Verified end to end: a DTS 5.1 film reaches video.ts as AC-3 5.1 at 640
kbps with no environment variable set, a 2.0 source stays 2.0 at 192, and a
byte range into audio.ac3 returns the whole encode's own bytes from the
frame after the seek — the one frame the MDCT overlap cannot reproduce is
the frame the viewer dragged to.
Both regressed in f8b616c, and neither showed up until now: CI last ran on
this branch before the codec fork landed, so the first run after it went red
on code nobody had touched.

CLAUDE.md says vuio-codec-ac3 is lint-exempt — 13.7k inherited lines that are
their own cleanup chore, deliberately kept off the performance work. The
`[lints]` table it points at is the right place to say so but cannot be the
only one: CI runs `cargo clippy -p vuio-core … -- -D clippy::all`, cargo hands
those trailing flags to every workspace member unit it builds rather than only
the named ones, and a command-line `-D` outranks the manifest. An attribute
outranks both, so the exemption is now also stated at the crate root, where it
actually holds. Without it every clippy release breaks the build here; 1.98
found eight.

The MSRV came along with the same commit's publish preparation — version
0.0.45 to 0.0.1, a new licence, a new author list — which raised
`rust-version` from 1.80 to 1.97 while every other crate in the workspace
promises 1.95. Nothing in the codec needs it: it checks clean on 1.95, so the
promise is restored rather than the other crates dragged up to meet it.

Two failures on this run are neither: libxaac-sys' cmake passes /O2 and /RTC1
together on MSVC, and the aarch64 and armv7 Linux runners failed to fetch the
toolchain action and the soft-float headers.
libxaac-sys arrived on this branch in place of oxideav-aac, and its bundled
cmake build has never compiled on two of the targets CI covers. Both failures
are in that build, not in anything calling it.

On MSVC the library's own cmake asks for /O2 unconditionally. A Visual Studio
generator is multi-config, so that reaches the Debug configuration too, and
MSVC refuses /O2 beside the /RTC1 Debug adds — error D8016, which took out
both Windows test jobs and Windows clippy. /O2 is now asked for on every
configuration except Debug.

Debug was also the wrong configuration to be building at all: it links the
debug CRT while rustc links the release one whatever the cargo profile says,
so a dev build was quietly mixing the two. A non-release profile now builds
RelWithDebInfo, which keeps the symbols with neither problem and matches the
opt-level 3 the workspace already asks of this package in dev. Single-config
generators ignore --config, so nothing outside Windows changes.

On armv7 the library builds 32-bit arm as -mfloat-abi=softfp. That leaves
__ARM_PCS_VFP undefined, so glibc's gnu/stubs.h reaches for gnu/stubs-soft.h
— which an arm-linux-gnueabihf sysroot does not ship, and the build stopped at
the first #include. The ABI now follows the Rust target triple, defaulting to
the previous softfp for a genuinely soft-float toolchain.

The generator expression was checked against a multi-config generator rather
than guessed at: Debug resolves to /W3 alone, every other configuration to
/W3 /O2.
The last thing keeping armv7 red. libxaac's 61 hand-written armv7 kernels are
written in unified (UAL) syntax — the condition code goes after the S flag and
after an LDM/STM addressing mode, so SUBSNE and LDMFDLE rather than the divided
syntax's SUBNES and LDMLEFD. GNU as still defaults to divided and rejects those
outright. Upstream builds with clang, whose integrated assembler defaults to
unified, which is why the files never said which dialect they are. Now they do.

Six lines needed rewriting as well: `SUB r3, r1, LSL #3` is the pre-UAL
shorthand for `SUB r3, r3, r1, LSL #3`, and unified syntax wants the
destination named. The two forms assemble to the same e0433181.

Verified against the assembler CI actually uses rather than reasoned about —
arm-linux-gnueabihf-as 2.47, with the cortex-a8/neon/hard-float flags the
library's cmake passes:

  - before, 5 of 61 files fail to assemble; after, 0 of 61 do
  - of the 56 that did assemble before, all 56 disassemble byte-identically
    afterwards, so the directive changed no instruction that was already
    being accepted, and the six rewritten SUBs encode as they did

That check is also why this touches all 61 rather than the one file the build
log named: grepping for the ambiguous forms found three files, and the
assembler then found two more.
The last armv7 failure, one layer up from the assembly. bindings.rs is
committed bindgen output, and bindgen records the layout it measured: nine
blocks of size, alignment and offset assertions written as subtractions
against literals. Every struct they describe holds pointers, so on a 32-bit
target the struct is smaller than the literal, the subtraction underflows, and
it is a compile error rather than a failed assertion — six of them on armv7.

Only the assertions are 64-bit facts. The structs need no help: they are
#[repr(C)] over real pointer types with no explicit padding, so rustc lays
them out for whatever pointer width the target has. So the assertions are
gated on target_pointer_width = "64" and nothing else changes.

Checked against a real 32-bit target rather than reasoned about, by compiling
bindings.rs standalone — it is self-contained:

  - i686-unknown-linux-gnu: 7 E0080 errors before, none after
  - the 64-bit host still compiles it with the assertions active
  - corrupting one of those assertions is still caught on 64-bit, so the gate
    disabled the check only where it was never true
@vyrti
vyrti merged commit 7d82b2e into main Aug 26, 2026
23 checks passed
@vyrti
vyrti deleted the feat/transcode-ac3-dts branch August 26, 2026 18:48
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