mbelib_rs/encode/
pack.rs

1// SPDX-FileCopyrightText: 2026 Swift Raccoon
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4//! AMBE frame packing — the exact inverse of [`crate::unpack`].
5//!
6//! Given a 72-bit codeword array `ambe_fr` (C0 at 0..24, C1 at 24..47,
7//! C2 at 47..58, C3 at 58..72 — the same layout the decoder consumes
8//! after error correction and C1 demodulation), this module:
9//!
10//! 1. **Modulates C1** by XOR-ing its bits with an LFSR sequence
11//!    seeded from the C0 data bits. The XOR operation is self-inverse,
12//!    so we call [`crate::unpack::demodulate_c1`] directly — "modulate"
13//!    and "demodulate" are the same byte-level op; only the pipeline
14//!    direction differs. Applied after the caller has already encoded
15//!    FEC into C0 and C1 (or, for P1 round-trip testing, the caller
16//!    supplies already-encoded codewords and this step restores them
17//!    to wire form).
18//! 2. **Packs** the 72 bits back into 9 bytes via the inverse interleave
19//!    table, MSB-first within each byte (bit 7 of byte 0 is input bit 0).
20//!
21//! The output is a valid 9-byte AMBE wire frame suitable for the
22//! DSVT voice-data slot in the D-STAR frame.
23
24use crate::encode::interleave::{AMBE_FRAME_BITS, INVERSE};
25use crate::unpack::demodulate_c1;
26
27/// Pack a 72-bit FEC-codeword array into a 9-byte AMBE wire frame.
28///
29/// This is the inverse of the decoder's `unpack_frame` composed with
30/// `demodulate_c1`: given an `ambe_fr` array that contains
31/// already-FEC-encoded codewords (C0 Golay-encoded, C1 Golay-encoded
32/// but *not* yet XOR-scrambled, C2 / C3 as-is), produce the on-wire
33/// byte sequence that a conformant AMBE decoder would recover back
34/// to the same `ambe_fr`.
35///
36/// # Arguments
37///
38/// - `ambe_fr`: a 72-element array where each byte holds a single bit
39///   (0 or 1), laid out in FEC-codeword order (C0 at 0..24, C1 at
40///   24..47, C2 at 47..58, C3 at 58..72). This is the *same* layout
41///   `unpack_frame` writes into.
42///
43/// # Returns
44///
45/// 9 packed bytes, MSB-first. `result[0]` bit 7 is the first bit that
46/// goes on the wire.
47///
48/// # Round-trip invariant
49///
50/// For any `ambe_fr` produced by the decoder's `unpack_frame` +
51/// `demodulate_c1`, the following holds (see `tests` below):
52///
53/// ```text
54/// let mut fr = [0u8; 72];
55/// unpack_frame(&wire, &mut fr);
56/// demodulate_c1(&mut fr);
57/// let wire_round_trip = pack_frame(&fr);
58/// assert_eq!(wire, wire_round_trip);
59/// ```
60#[must_use]
61pub fn pack_frame(ambe_fr: &[u8; AMBE_FRAME_BITS]) -> [u8; 9] {
62    // Step 1: modulate C1 back to its on-wire scrambled form. The
63    // decoder calls `demodulate_c1` after Golay-correcting C0; the
64    // encoder must undo that scrambling before packing. XOR is its own
65    // inverse so the same routine serves both directions.
66    let mut working = *ambe_fr;
67    demodulate_c1(&mut working);
68
69    // Step 2: pack 72 codeword bits back into 9 bytes, MSB-first.
70    // Iterate over every `ambe_fr` position paired with its
71    // wire-order input-bit index from `INVERSE`. That bit lands at
72    // byte `input_bit / 8`, bit position `7 - (input_bit % 8)`
73    // (MSB-first within each byte).
74    let mut out = [0u8; 9];
75    for (&bit_val, &input_bit_u8) in working.iter().zip(INVERSE.iter()) {
76        if bit_val == 0 {
77            continue;
78        }
79        let input_bit = input_bit_u8 as usize;
80        let byte_idx = input_bit / 8;
81        let bit_pos = 7 - (input_bit % 8);
82        if let Some(b) = out.get_mut(byte_idx) {
83            *b |= 1 << bit_pos;
84        }
85    }
86
87    out
88}
89
90#[cfg(test)]
91mod tests {
92    use super::{AMBE_FRAME_BITS, pack_frame};
93    use crate::unpack::{demodulate_c1, unpack_frame};
94    use proptest::prelude::*;
95
96    /// Hand-round-trip a zero frame. Trivial but catches "did anything
97    /// compile at all" regressions.
98    #[test]
99    fn zero_frame_round_trips() {
100        let wire = [0u8; 9];
101        let mut fr = [0u8; AMBE_FRAME_BITS];
102        unpack_frame(&wire, &mut fr);
103        demodulate_c1(&mut fr);
104        let wire_rt = pack_frame(&fr);
105        assert_eq!(wire, wire_rt);
106    }
107
108    /// Hand-round-trip a frame where every bit is 1. Exercises the
109    /// packing when every output position must be set.
110    #[test]
111    fn all_ones_frame_round_trips() {
112        let wire = [0xFFu8; 9];
113        let mut fr = [0u8; AMBE_FRAME_BITS];
114        unpack_frame(&wire, &mut fr);
115        demodulate_c1(&mut fr);
116        let wire_rt = pack_frame(&fr);
117        assert_eq!(wire, wire_rt);
118    }
119
120    proptest! {
121        /// For ANY 9-byte input, unpack → demodulate → pack must
122        /// return the original 9 bytes. This is the key correctness
123        /// invariant for the packing layer: together, unpack+pack
124        /// form a bijection on the wire-frame byte space.
125        #[test]
126        fn wire_frame_round_trip(wire in prop::array::uniform9(0u8..=255)) {
127            let mut fr = [0u8; AMBE_FRAME_BITS];
128            unpack_frame(&wire, &mut fr);
129            demodulate_c1(&mut fr);
130            let wire_rt = pack_frame(&fr);
131            prop_assert_eq!(wire, wire_rt);
132        }
133    }
134}