lac 0.1.0

Lo Audio Codec — lossless audio codec with LPC + partitioned Rice coding.
Documentation
//! Wire-format conformance fixtures.
//!
//! Each `DecodeFixture` pins a `(samples, bytes)` pair at the byte
//! level. A second implementation of LAC MUST produce `samples` when
//! fed `bytes` to its decoder. This test suite is the canonical
//! reference for decoder conformance: byte-identical `bytes` across
//! implementations aren't required (encoders have latitude in order /
//! partition / k selection), but byte-identical decoder output is.
//!
//! # How this file works
//!
//! - `DECODE_FIXTURES` holds the pinned vectors.
//! - `decode_fixtures` runs each fixture's bytes through `decode_frame`
//!   and asserts the output matches. This is the conformance test.
//! - `encode_matches_fixtures` runs each fixture's samples through the
//!   reference encoder and asserts the bytes match. This catches
//!   unintentional drift in the reference's encoder strategy; a
//!   deliberate change (e.g. adding a new predictor or order) will fail
//!   this test and require regenerating the fixtures.
//! - `generate_vectors` (ignored by default) prints the current
//!   reference encoder output in a paste-ready format. Run via
//!   `cargo test --test conformance generate_vectors --
//!   --ignored --nocapture` to refresh the fixtures after an
//!   intentional encoder change.
//!
//! # Rejection fixtures
//!
//! `REJECT_FIXTURES` pins header-level malformed inputs to their
//! expected `DecodeError` variants. These are hand-constructed — the
//! encoder never emits them — so they verify decoder rejection paths
//! across implementations.

use lac::{DecodeError, decode_frame, encode_frame};

// ── Decode / encode fixtures ────────────────────────────────────────────────

struct DecodeFixture {
    name: &'static str,
    samples: &'static [i32],
    bytes: &'static [u8],
}

/// Pinned wire-format vectors. Populated from the reference encoder
/// via `generate_vectors` below. See `ENCODER_PIN` comment at the top
/// of the generator for the rationale on why this doubles as a drift
/// canary for the encoder.
const DECODE_FIXTURES: &[DecodeFixture] = &[
    // ── Degenerate / smallest frames ───────────────────────────────
    DecodeFixture {
        name: "single_zero",
        samples: &[0],
        bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04],
    },
    DecodeFixture {
        name: "silence_4",
        samples: &[0, 0, 0, 0],
        bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x07, 0x80],
    },
    DecodeFixture {
        name: "silence_8",
        samples: &[0, 0, 0, 0, 0, 0, 0, 0],
        bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x08, 0x07, 0xf8],
    },
    // ── Single-sample polarity + magnitude boundaries ──────────────
    DecodeFixture {
        name: "single_pos_one",
        samples: &[1],
        bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01],
    },
    DecodeFixture {
        name: "single_neg_one",
        samples: &[-1],
        bytes: &[0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02],
    },
    DecodeFixture {
        name: "single_full_scale_pos",
        samples: &[(1 << 23) - 1],
        bytes: &[
            0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0xbb, 0xff, 0xff, 0xf8,
        ],
    },
    DecodeFixture {
        name: "single_full_scale_neg",
        samples: &[-((1 << 23) - 1)],
        bytes: &[
            0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x01, 0xbb, 0xff, 0xff, 0xf4,
        ],
    },
    // ── DC and near-DC content ─────────────────────────────────────
    DecodeFixture {
        name: "dc_100_4",
        samples: &[100, 100, 100, 100],
        bytes: &[
            0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x3b, 0x21, 0x90, 0xc8, 0x64, 0x00,
        ],
    },
    // ── Alternating polarity (Nyquist-like) ────────────────────────
    DecodeFixture {
        name: "alternating_small_4",
        samples: &[1000, -1000, 1000, -1000],
        bytes: &[
            0x1a, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x04, 0x53, 0xe8, 0x3e, 0x7b, 0xe8, 0x3e, 0x78,
        ],
    },
    // ── Smooth polynomial — fixed predictor territory ──────────────
    DecodeFixture {
        name: "linear_ramp_8",
        samples: &[0, 100, 200, 300, 400, 500, 600, 700],
        bytes: &[
            0x1a, 0xcc, 0x02, 0x02, 0x02, 0x00, 0x08, 0x40, 0x00, 0xe0, 0x00, 0x34, 0x01, 0x20,
            0x18, 0x30, 0x60,
        ],
    },
    // ── 16-sample growing-amplitude (exercises partition + LPC) ────
    DecodeFixture {
        name: "lfsr_noise_16",
        samples: &[
            21, -100, 42, -200, 51, -400, 71, -800, 90, -1600, 110, -3200, 130, -6400, 151, -12800,
        ],
        bytes: &[
            0x1a, 0xcc, 0x00, 0x01, 0x00, 0x00, 0x10, 0x44, 0xab, 0x8f, 0x54, 0x63, 0xec, 0xc2,
            0x3f, 0x8e, 0x02, 0x7e, 0xc8, 0x5a, 0x71, 0xfe, 0x1b, 0x8c, 0x7f, 0xc4, 0x10, 0x47,
            0xfe, 0x25, 0xc0, 0x4f, 0xfc,
        ],
    },
];

// ── Rejection fixtures ──────────────────────────────────────────────────────

struct RejectFixture {
    name: &'static str,
    bytes: &'static [u8],
    expected: DecodeError,
}

const REJECT_FIXTURES: &[RejectFixture] = &[
    RejectFixture {
        name: "bad_sync_word",
        // Sync byte flipped to 0xFF; rest is a well-formed minimal
        // verbatim header so the decoder only rejects on the first
        // check.
        bytes: &[0xFF, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01],
        expected: DecodeError::BadSyncWord { got: 0xFFCC },
    },
    RejectFixture {
        name: "prediction_order_above_max",
        bytes: &[0x1A, 0xCC, 0x21, 0x00, 0x00, 0x00, 0x01],
        expected: DecodeError::InvalidPredictionOrder { got: 33 },
    },
    RejectFixture {
        name: "partition_order_above_max",
        bytes: &[0x1A, 0xCC, 0x00, 0x08, 0x00, 0x00, 0x01],
        expected: DecodeError::InvalidPartitionOrder { got: 8 },
    },
    RejectFixture {
        name: "coefficient_shift_above_max",
        // Non-zero prediction_order so the shift is actually used.
        // 2 bytes of (zero) coefficient follow so the header is
        // structurally valid before the shift check fires.
        bytes: &[0x1A, 0xCC, 0x01, 0x00, 0x06, 0x00, 0x01, 0x00, 0x00],
        expected: DecodeError::InvalidCoefficientShift { got: 6 },
    },
    RejectFixture {
        name: "coefficient_shift_without_order",
        // order = 0, shift = 3 — contradictory per spec §3.4.
        bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x03, 0x00, 0x01],
        expected: DecodeError::CoefficientShiftWithoutOrder { shift: 3 },
    },
    RejectFixture {
        name: "zero_frame_sample_count",
        bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x00],
        expected: DecodeError::InvalidParameter,
    },
    RejectFixture {
        name: "frame_count_not_divisible_by_partition_count",
        // partition_order = 3 → 8 partitions, count = 7 doesn't divide.
        bytes: &[0x1A, 0xCC, 0x00, 0x03, 0x00, 0x00, 0x07],
        expected: DecodeError::InvalidParameter,
    },
    RejectFixture {
        name: "truncated_before_header_complete",
        // Only 3 bytes — below the 7-byte fixed header minimum.
        bytes: &[0x1A, 0xCC, 0x00],
        expected: DecodeError::Truncated,
    },
    RejectFixture {
        name: "truncated_before_coefficients",
        // prediction_order = 2 claims 4 trailing coefficient bytes,
        // but only 7 bytes are present.
        bytes: &[0x1A, 0xCC, 0x02, 0x00, 0x00, 0x00, 0x04],
        expected: DecodeError::Truncated,
    },
    RejectFixture {
        name: "truncated_before_rice_bitstream",
        // Fully valid 7-byte header with count=1 and order=0 (no
        // coefficients), then no Rice bytes at all. Decoder reads the
        // header, enters Rice decode, tries to read the 5-bit `k`
        // field, and fails. Covers the third truncation class in
        // spec §6 (Rice-bitstream-level, distinct from header and
        // coefficient-array truncations above).
        bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01],
        expected: DecodeError::Truncated,
    },
    RejectFixture {
        name: "rice_k_above_max",
        // Valid 7-byte verbatim header; first Rice byte stores `k=31`
        // in its high 5 bits (31 = 0b11111, left-shifted 3 = 0xF8).
        // The decoder reads `k` and rejects immediately — never
        // proceeds to the codeword — per spec §4.1 (k must be in
        // [0, 23]).
        bytes: &[0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01, 0xF8],
        expected: DecodeError::InvalidParameter,
    },
];

// ── Tests ───────────────────────────────────────────────────────────────────

#[test]
fn decode_fixtures() {
    // The canonical conformance check: every fixture's bytes must
    // decode to the claimed samples. Any second implementation's
    // decoder that fails this test is non-conformant.
    for f in DECODE_FIXTURES {
        if f.bytes.is_empty() {
            // Placeholder fixture — regenerate with `generate_vectors`.
            continue;
        }
        let decoded = decode_frame(f.bytes)
            .unwrap_or_else(|e| panic!("fixture {}: decode failed with {e:?}", f.name));
        assert_eq!(
            decoded, f.samples,
            "fixture {}: decoded samples mismatch",
            f.name
        );
    }
}

#[test]
fn encode_matches_fixtures() {
    // Drift canary: the reference encoder currently produces these
    // exact bytes for these inputs. A deliberate change to the
    // encoder's search strategy (new predictor, different grid,
    // different tie-break) will fail this test and should be followed
    // by regenerating the `bytes` fields via `generate_vectors`.
    //
    // Second implementations are not obligated to produce byte-
    // identical output, so this test is reference-specific.
    for f in DECODE_FIXTURES {
        if f.bytes.is_empty() {
            continue;
        }
        let encoded = encode_frame(f.samples);
        assert_eq!(
            &encoded[..],
            f.bytes,
            "fixture {}: encoder output drifted from pinned bytes",
            f.name
        );
    }
}

#[test]
fn reject_fixtures() {
    for f in REJECT_FIXTURES {
        match decode_frame(f.bytes) {
            Ok(samples) => panic!(
                "fixture {}: expected {:?}, got Ok with {} samples",
                f.name,
                f.expected,
                samples.len()
            ),
            Err(e) => assert_eq!(e, f.expected, "fixture {}: wrong error variant", f.name),
        }
    }
}

/// Spec §6 rejection class 10: decoder **must** reject any codeword
/// whose unary run length exceeds `u32::MAX >> k` (equivalently
/// `q > (2³² − 1) / 2^k`). Lives here rather than in `REJECT_FIXTURES`
/// because the minimal triggering payload is ~75 bytes of mostly
/// zeros — a const array of that shape is noise, and the construction
/// logic (`k = 23`, so `q_max = 511`; emit 512 unary zeros; then
/// terminator + zero remainder) documents the intent better than a
/// hex blob.
#[test]
fn reject_unary_run_above_cap() {
    // Header: sync + order=0 + po=0 + shift=0 + count=1. Rice section
    // begins at byte 7 with no coefficients in between.
    let mut bytes: Vec<u8> = vec![0x1A, 0xCC, 0x00, 0x00, 0x00, 0x00, 0x01];

    // Rice payload, built bit-by-bit via the codec's own BitWriter
    // analogue below. Doing it with raw byte arithmetic here would
    // duplicate bit-ordering logic that already lives in `bit_io`.
    // We construct the payload into a separate Vec and append.
    //
    // Payload structure (541 bits total, padded to 544 = 68 bytes):
    //   - 5 bits: k = 23           (triggers q_max = u32::MAX >> 23 = 511)
    //   - 512 bits: unary zeros    (one past the cap)
    //   - 1 bit: terminator = 1
    //   - 23 bits: remainder = 0
    let mut rice: Vec<u8> = Vec::with_capacity(68);
    let mut bit_accum: u32 = 0;
    let mut bits_in_accum: u32 = 0;
    let push_bits = |value: u32, count: u32, rice: &mut Vec<u8>, accum: &mut u32, n: &mut u32| {
        for i in (0..count).rev() {
            let bit = (value >> i) & 1;
            *accum = (*accum << 1) | bit;
            *n += 1;
            if *n == 8 {
                rice.push(*accum as u8);
                *accum = 0;
                *n = 0;
            }
        }
    };
    push_bits(23, 5, &mut rice, &mut bit_accum, &mut bits_in_accum); // k = 23
    for _ in 0..512 {
        push_bits(0, 1, &mut rice, &mut bit_accum, &mut bits_in_accum);
    }
    push_bits(1, 1, &mut rice, &mut bit_accum, &mut bits_in_accum); // terminator
    push_bits(0, 23, &mut rice, &mut bit_accum, &mut bits_in_accum); // remainder
    // Flush partial byte (left-aligned, matching spec §4.3).
    if bits_in_accum > 0 {
        rice.push((bit_accum << (8 - bits_in_accum)) as u8);
    }

    bytes.extend_from_slice(&rice);
    assert_eq!(bytes.len(), 7 + 68, "unexpected fixture length");

    assert_eq!(
        decode_frame(&bytes),
        Err(DecodeError::InvalidParameter),
        "q=512 with k=23 exceeds u32::MAX >> k = 511; decoder must reject \
         with InvalidParameter per spec §4.2"
    );
}

#[test]
#[ignore = "helper for refreshing the pinned byte literals"]
fn generate_vectors() {
    // Prints DECODE_FIXTURES entries in paste-ready format. Run with
    //   cargo test --test conformance generate_vectors -- --ignored --nocapture
    // then copy the output over the existing fixture bodies. Intended
    // for use after a deliberate change to encoder strategy; refuses
    // to run in normal test flow to avoid accidental acceptance of
    // silent drift.
    for f in DECODE_FIXTURES {
        let encoded = encode_frame(f.samples);
        eprintln!("    DecodeFixture {{");
        eprintln!("        name: {:?},", f.name);
        eprint!("        samples: &[");
        for (i, s) in f.samples.iter().enumerate() {
            if i > 0 {
                eprint!(", ");
            }
            eprint!("{s}");
        }
        eprintln!("],");
        eprint!("        bytes: &[");
        for (i, b) in encoded.iter().enumerate() {
            if i > 0 {
                eprint!(", ");
            }
            eprint!("{b:#04x}");
        }
        eprintln!("],");
        eprintln!("    }},");
    }
}