Skip to content

Add AC3 RF audio demodulation in Python (lddecode/ac3rf.py)#1050

Merged
happycube merged 7 commits into
happycube:mainfrom
staffanu:ac3-python-port
Jul 18, 2026
Merged

Add AC3 RF audio demodulation in Python (lddecode/ac3rf.py)#1050
happycube merged 7 commits into
happycube:mainfrom
staffanu:ac3-python-port

Conversation

@staffanu

Copy link
Copy Markdown
Contributor

Summary

This is the resubmission of AC3 RF audio decoding (previously PR #1034, merged and then reverted in #1040). Following the discussion on #1034, the demodulator is now implemented in-tree in Python, following the same pattern as lddecode/efm_pll.py — numpy/scipy for filter design, numba for the DPLL and FIR kernels. There is no external repository, no nix flake dependency, and no new Python dependency (numpy/scipy/numba are already required).

With --AC3 (NTSC only), ld-decode demodulates the 2.88 MHz QPSK subcarrier and writes the raw symbols to <output>.ac3sym (one symbol per byte, values 0–3). The number of symbols demodulated during each field is recorded in the field metadata as ac3Symbols / ac3_symbols, exactly analogous to efmTValues / efm_t_values. Framing, Reed-Solomon error correction and AC3 frame assembly happen downstream in decode-orc's AC3 RF Sink stage, which already queries this metadata column; a companion decode-orc PR restores that stage's TBC-source read path.

What's in the PR

  • lddecode/ac3rf.py — the demodulator: half-band Kaiser decimation stages, 2.88 MHz mixer, root-raised-cosine matched filter, and a DPLL for symbol timing recovery (numba jitclass, like EFM_PLL). Accepts arbitrary block lengths; all filter/timing state carries across calls.
  • Integration in core.py replacing the dead ac3_pipe code (which shelled out to C++ tools removed in the removetools-202601 cleanup).
  • tests/test_ac3rf.py — self-contained tests, no capture files needed: DQPSK loopback (clean + noisy), and an end-to-end test that synthesizes a complete NTSC LD signal (FM video carrier + AC3 subcarrier), runs ld-decode --AC3 on it, and validates the demodulated symbols and the per-field metadata counts.
  • Documentation of the --AC3 behavior and the decode-orc handoff in docs/user-guide/command.md.

Validation

  • Bit-exact vs the previous C++ implementation: 100% symbol agreement over three real AC3 disc captures, at 40 MHz (int16) and 62.5 MHz (uint8) sample rates.
  • End-to-end on real discs: .ac3sym output decoded through the AC3 framing/RS stage produces a clean AC3 elementary stream (48 kHz, 5.1, 384 kb/s), with near-zero Reed-Solomon corrections (e.g. 3 corrected symbols in 11,952 C1 codewords), and audio verified by listening.
  • Performance: ~1.6× realtime single-threaded for 40 MSps input; runs nogil so it overlaps the decoder's other threads.

Notes

@staffanu

Copy link
Copy Markdown
Contributor Author

Pushed ef928ce, syncing the DPLL loop gains with the reference C++ implementation (museld b82e92b): the proportional/integral gains are now computed from the second-order loop design parameters (1800 Hz natural frequency, damping 0.6, detector/VCO gain 0.3) instead of hard-coded constants. Output on real captures is unchanged (re-verified 100% symbol agreement against the updated C++ on three captures at 40 MHz and 62.5 MHz); the faster loop tracks wow/flutter substantially better per the reference implementation's testing.

staffanu added 7 commits July 17, 2026 16:14
Port of the Ac3RfDemodulator/Ac3DPLL C++ classes from
https://github.com/staffanu/museld (player/src/ac3/) to Python,
following the same implementation pattern as efm_pll.py: numpy/scipy
for filter design, numba for the sequential DPLL loop and the FIR
decimation kernel.

The demodulator takes raw RF samples (any block length; filter and
timing state is carried across calls) and outputs a stream of raw
differential QPSK symbols.  Framing, Reed-Solomon error correction and
AC3 frame assembly happen downstream in decode-orc's ac3rf_sink stage.

Verified bit-exact (100.000% symbol agreement) against the C++
implementation on three real AC3 disc captures at 40 MHz and 62.5 MHz
sample rates.  Runs at ~1.6x realtime single-threaded for 40 MSps
input.  Includes a synthetic DQPSK loopback test.
Replace the dead ac3_pipe code (which shelled out to the C++
ld-ac3-demodulate/ld-ac3-decode tools removed in the removetools-202601
cleanup) with the in-tree Python demodulator.  With --AC3, demodulated
QPSK symbols are written to <output>.ac3sym and the per-field symbol
offset is recorded in the field metadata; decode-orc's ac3rf_sink stage
consumes both to produce the final .ac3 file.

Compared to the reverted PR happycube#1034 this needs no external module: the
demodulator accepts any block length, so the StridedCollector buffering
and the input alignment assert are gone, and the logger plumbing uses
lddecode's own logger.

Verified end-to-end on real captures: ld-decode --AC3 output decodes
through decode-orc to clean AC3 (48 kHz 5.1, 384 kb/s) with near-zero
Reed-Solomon corrections.
Store the number of symbols demodulated during each field as
ac3Symbols / ac3_symbols, matching the efmTValues convention and the
column decode-orc's metadata readers have queried since AC3 support was
added there (the previous ac3_sym_offset column was never read by
decode-orc, which fell back to distributing symbols uniformly across
fields).

The per-field counts sum exactly to the .ac3sym file size, so consumers
can reconstruct each field's symbol range by summation, exactly as they
do for EFM T-values.
Synthesizes an NTSC LD signal - a black raster FM modulated onto the video
carrier, with the AC3 QPSK subcarrier mixed in underneath - runs ld-decode
--AC3 over it, and checks the demodulated symbols against the transmitted
ones and the per-field symbol counts recorded in the metadata.  This covers
the demodulator's integration into the decoder, which the loopback tests do
not reach, without needing an AC3 capture to test against.

The subcarrier is modulated against the AC3 spec's carrier and symbol
rate rather than ac3rf.py's own constants, so the test would notice if
those drifted.
Port of museld commit b82e92b: the proportional and integral gains are
derived from a second-order loop design (natural frequency 1800 Hz,
damping factor 0.6) divided by the combined phase-detector/VCO gain of
0.3, instead of the hard-coded 1/16 and 1/512 which ignored the
sub-unity detector gain and made the realized loop ~2.5x slower than
designed.  Per the reference implementation's A/B testing, output is
bit-identical on real captures while tracking through wow/flutter
improves substantially.

Re-verified 100.000% symbol agreement against the updated C++
implementation on the same three captures as before.
@happycube
happycube merged commit e060a9c into happycube:main Jul 18, 2026
6 checks passed
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.

2 participants