Skip to main content

orion_sdr/fec/
conv.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/fec/conv.rs
5//
6// Punctured convolutional coding for the inner FEC stage. Two mother codes are
7// supported, selected by [`ConvCode`]:
8//
9//   • [`ConvCode::K5`] — the crate's original rate-1/2, constraint-length-5 code
10//     (generators G0 = 0o25, G1 = 0o23), built on `codec::conv_encode`. This is
11//     the default and is byte-identical to the pre-K7 behavior.
12//   • [`ConvCode::DvbK7`] — DVB-T's rate-1/2, constraint-length-7 inner code
13//     (generators G0 = 0o171, G1 = 0o133, ETSI EN 300 744 §4.3.3). Needed for a
14//     conformant DVB-T payload.
15//
16// On top of the mother code this module adds:
17//
18//   • zero-tail termination — K-1 zero bits appended before encoding so the
19//     trellis ends in the all-zero state, giving a clean per-frame block Viterbi
20//     traceback (the streaming PSK31 decoder is fixed-lag; a frame code wants
21//     block termination). K-1 = 4 for K5, 6 for K7.
22//   • puncturing — deleting coded bits per a fixed per-rate matrix to raise the
23//     rate from 1/2 to 2/3, 3/4, 5/6, or 7/8. The decoder reinserts an erasure
24//     (LLR = 0, i.e. "no information") at each punctured position before the
25//     Viterbi ACS. The puncture patterns are shared by both mother codes (the
26//     standard DVB/802.11 patterns derived from a rate-1/2 code).
27//
28// The decoder is a soft-input (LLR-domain) Viterbi: the existing
29// `codec::psk31::viterbi_decode` metric is hardwired to the DQPSK constellation
30// and cannot consume `OfdmSoftDemod`'s per-bit LLRs. Here the branch metric is
31// the LLR-correlation `Σ (1 - 2·c) · llr` over the branch's coded bits (LLR
32// convention: positive ⇒ bit 0), maximized along the surviving path. It is
33// generic over the mother code via a small [`ConvCode`] descriptor; the K5 path
34// stays bit-identical to the original hand-rolled implementation.
35
36use crate::codec::conv_encode;
37
38/// Selects the convolutional mother code. Both are rate-1/2, zero-tail
39/// terminated, and share the puncture matrices in [`PunctureRate`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ConvCode {
42    /// The crate's original constraint-length-5 code, G0 = 0o25, G1 = 0o23.
43    /// Also the code PSK31 uses (`codec::conv_encode`). Default for backward
44    /// compatibility.
45    #[default]
46    K5,
47    /// DVB-T's constraint-length-7 code, G0 = 0o171, G1 = 0o133
48    /// (ETSI EN 300 744 §4.3.3). The conformant DVB-T inner code.
49    DvbK7,
50}
51
52impl ConvCode {
53    /// Constraint length K (register width is K − 1).
54    #[inline]
55    pub const fn constraint_length(self) -> usize {
56        match self {
57            ConvCode::K5 => 5,
58            ConvCode::DvbK7 => 7,
59        }
60    }
61
62    /// Register width in bits, K − 1 (also the number of zero tail bits).
63    #[inline]
64    pub const fn reg_bits(self) -> usize {
65        self.constraint_length() - 1
66    }
67
68    /// Number of trellis states, `2^(K−1)`.
69    #[inline]
70    pub const fn num_states(self) -> usize {
71        1usize << self.reg_bits()
72    }
73
74    /// Number of zero tail bits appended to terminate the trellis, `K − 1`.
75    #[inline]
76    pub const fn tail_bits(self) -> usize {
77        self.reg_bits()
78    }
79
80    /// Generator taps (G0, G1) as bit masks over a `K`-bit window whose low
81    /// `K−1` bits are the register and whose top bit is the current input.
82    ///
83    /// K5: G0 = 0o25 = 0b10101, G1 = 0o23 = 0b10011 (matches
84    /// `codec::conv_encode`). K7: G0 = 0o171 = 0b1111001, G1 = 0o133 =
85    /// 0b1011011 (DVB-T). The MSB of each generator is the input tap and the
86    /// LSB the oldest register bit, so the window packs `(input << (K-1)) |
87    /// register`.
88    #[inline]
89    const fn generators(self) -> (u16, u16) {
90        match self {
91            ConvCode::K5 => (0b10101, 0b10011),
92            ConvCode::DvbK7 => (0b1111001, 0b1011011),
93        }
94    }
95}
96
97/// Convolutional puncturing rate (numerator/denominator of the code rate).
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PunctureRate {
100    /// The unpunctured mother code.
101    R1_2,
102    R2_3,
103    R3_4,
104    R5_6,
105    R7_8,
106}
107
108impl PunctureRate {
109    /// The puncturing matrix, as two rows (one per generator output G0/G1) of a
110    /// fixed period. A `1` keeps the coded bit; a `0` deletes it. Standard
111    /// DVB/802.11-style patterns derived from a rate-1/2 mother code.
112    fn matrix(self) -> (&'static [u8], &'static [u8]) {
113        match self {
114            PunctureRate::R1_2 => (&[1], &[1]),
115            PunctureRate::R2_3 => (&[1, 1], &[1, 0]),
116            PunctureRate::R3_4 => (&[1, 1, 0], &[1, 0, 1]),
117            PunctureRate::R5_6 => (&[1, 1, 0, 1, 0], &[1, 0, 1, 0, 1]),
118            PunctureRate::R7_8 => (&[1, 1, 1, 1, 0, 1, 0], &[1, 0, 0, 0, 1, 0, 1]),
119        }
120    }
121
122    /// Puncture period (columns in the matrix).
123    fn period(self) -> usize {
124        self.matrix().0.len()
125    }
126
127    /// Coded bits kept per period (matrix ones).
128    fn kept_per_period(self) -> usize {
129        let (g0, g1) = self.matrix();
130        g0.iter().chain(g1.iter()).filter(|&&x| x == 1).count()
131    }
132}
133
134/// Number of tail bits appended to terminate the K = 5 trellis. Retained for
135/// the K5 public API; K7 uses [`ConvCode::tail_bits`].
136pub const TAIL_BITS: usize = 4;
137
138/// Coded bits `(c0, c1)` emitted when register state `s` takes input bit `b`
139/// under mother code `code`.
140#[inline]
141fn branch_bits(code: ConvCode, s: u16, b: u8) -> (u8, u8) {
142    let (g0, g1) = code.generators();
143    let window = (((b & 1) as u16) << code.reg_bits()) | (s & reg_mask(code));
144    (parity(window & g0), parity(window & g1))
145}
146
147/// Next register state after `s` receives input `b`: shift right, insert `b` at
148/// the top register bit.
149#[inline]
150fn next_state(code: ConvCode, s: u16, b: u8) -> u16 {
151    (s >> 1) | (((b & 1) as u16) << (code.reg_bits() - 1))
152}
153
154/// Low `reg_bits` mask for the register.
155#[inline]
156fn reg_mask(code: ConvCode) -> u16 {
157    (1u16 << code.reg_bits()) - 1
158}
159
160#[inline]
161fn parity(x: u16) -> u8 {
162    (x.count_ones() & 1) as u8
163}
164
165/// One trellis branch, resolved once per decode instead of once per visit.
166#[derive(Clone, Copy)]
167struct Branch {
168    /// The branch's coded pair packed as `(c0 << 1) | c1`, which indexes the
169    /// four correlations any branch can contribute at a given step.
170    sym: u8,
171    /// The state this branch leads to.
172    next: u16,
173}
174
175/// The whole trellis for `code`: `num_states · 2` branches, indexed
176/// `state * 2 + bit`.
177///
178/// **Why this is precomputed.** [`branch_bits`] and [`next_state`] take `code`
179/// as a *runtime* value, so each call re-`match`es the enum for `generators()`,
180/// `reg_bits()` and `reg_mask()` before taking two `count_ones` parities. Called
181/// from inside the ACS loop that is `n_steps · num_states · 2` visits deep, that
182/// costs about half the decoder's throughput — measured, when the coder was
183/// generalized over `ConvCode` to add the DVB-T K=7 code, as a drop from ~26 to
184/// ~13.6 Msps at rate 1/2 with bit-identical output. Resolving every branch once
185/// up front is 32 entries for K=5 and 128 for K=7, against the tens of thousands
186/// of repeat evaluations it replaces.
187fn branch_table(code: ConvCode) -> Vec<Branch> {
188    let num_states = code.num_states();
189    let mut out = Vec::with_capacity(num_states * 2);
190    for s in 0..num_states as u16 {
191        for b in 0..2u8 {
192            let (c0, c1) = branch_bits(code, s, b);
193            out.push(Branch {
194                sym: (c0 << 1) | c1,
195                next: next_state(code, s, b),
196            });
197        }
198    }
199    out
200}
201
202/// Systematic-free rate-1/2 encode of `bits` (already tail-padded) under `code`,
203/// returning the interleaved `[g0_0, g1_0, g0_1, g1_1, …]` mother-code output.
204/// For [`ConvCode::K5`] this defers to `codec::conv_encode` so the output stays
205/// bit-identical to the original path.
206fn conv_encode_code(code: ConvCode, bits: &[u8]) -> Vec<u8> {
207    if code == ConvCode::K5 {
208        return conv_encode(bits);
209    }
210    let mut out = Vec::with_capacity(bits.len() * 2);
211    let mut state: u16 = 0;
212    for &b in bits {
213        let (c0, c1) = branch_bits(code, state, b);
214        out.push(c0);
215        out.push(c1);
216        state = next_state(code, state, b);
217    }
218    out
219}
220
221/// Encodes `info_bits` with the K5 mother code (zero-tail, punctured). Kept for
222/// backward compatibility; equivalent to
223/// [`conv_encode_punctured_with`]`(ConvCode::K5, …)`.
224pub fn conv_encode_punctured(info_bits: &[u8], rate: PunctureRate) -> Vec<u8> {
225    conv_encode_punctured_with(ConvCode::K5, info_bits, rate)
226}
227
228/// Encodes `info_bits` with mother code `code`, zero-tail termination, and the
229/// given puncture rate, returning the coded (punctured) bit stream.
230///
231/// Layout before puncturing: `encode([info | (K−1) zero tail bits])`, an
232/// interleaved `[g0_0, g1_0, g0_1, g1_1, …]` of length `2·(info + K − 1)`.
233/// Puncturing then deletes bits per the rate matrix.
234pub fn conv_encode_punctured_with(code: ConvCode, info_bits: &[u8], rate: PunctureRate) -> Vec<u8> {
235    let mut padded = info_bits.to_vec();
236    padded.extend(std::iter::repeat_n(0u8, code.tail_bits()));
237    let coded = conv_encode_code(code, &padded);
238    puncture(&coded, rate)
239}
240
241/// Deletes coded bits per the rate's puncture matrix. `coded` is the
242/// interleaved `[g0, g1, g0, g1, …]` mother-code output.
243fn puncture(coded: &[u8], rate: PunctureRate) -> Vec<u8> {
244    if rate == PunctureRate::R1_2 {
245        return coded.to_vec();
246    }
247    let (g0, g1) = rate.matrix();
248    let period = rate.period();
249    let mut out = Vec::with_capacity(coded.len());
250    // Each trellis step contributes a (g0, g1) pair; step t uses matrix column
251    // t % period.
252    let n_steps = coded.len() / 2;
253    for t in 0..n_steps {
254        let col = t % period;
255        if g0[col] == 1 {
256            out.push(coded[t * 2]);
257        }
258        if g1[col] == 1 {
259            out.push(coded[t * 2 + 1]);
260        }
261    }
262    out
263}
264
265/// Number of coded bits [`conv_encode_punctured`] produces for `info_bits`
266/// information bits at `rate` under the K5 code.
267pub fn punctured_coded_len(info_bits: usize, rate: PunctureRate) -> usize {
268    punctured_coded_len_with(ConvCode::K5, info_bits, rate)
269}
270
271/// Number of coded bits [`conv_encode_punctured_with`] produces for `info_bits`
272/// information bits at `rate` under mother code `code` (deterministic; used by
273/// the frame layer's size bookkeeping).
274pub fn punctured_coded_len_with(code: ConvCode, info_bits: usize, rate: PunctureRate) -> usize {
275    let n_steps = info_bits + code.tail_bits(); // mother code emits 2 bits/step
276    if rate == PunctureRate::R1_2 {
277        return n_steps * 2;
278    }
279    let period = rate.period();
280    let full_periods = n_steps / period;
281    let rem = n_steps % period;
282    let (g0, g1) = rate.matrix();
283    let mut len = full_periods * rate.kept_per_period();
284    for col in 0..rem {
285        len += (g0[col] + g1[col]) as usize;
286    }
287    len
288}
289
290/// Soft-decision Viterbi decode of a K5, punctured, zero-tail-terminated
291/// stream. Kept for backward compatibility; equivalent to
292/// [`viterbi_decode_soft_with`]`(ConvCode::K5, …)`.
293pub fn viterbi_decode_soft(coded_llrs: &[f32], info_bits: usize, rate: PunctureRate) -> Vec<u8> {
294    viterbi_decode_soft_with(ConvCode::K5, coded_llrs, info_bits, rate)
295}
296
297/// Soft-decision Viterbi decode of a punctured, zero-tail-terminated stream
298/// under mother code `code`.
299///
300/// `coded_llrs` are the received per-coded-bit LLRs (positive ⇒ bit 0), in the
301/// punctured order. `info_bits` is the number of information bits to recover
302/// (the tail bits are decoded but dropped). Punctured positions are treated as
303/// erasures (LLR 0). Returns the `info_bits` recovered information bits.
304pub fn viterbi_decode_soft_with(
305    code: ConvCode,
306    coded_llrs: &[f32],
307    info_bits: usize,
308    rate: PunctureRate,
309) -> Vec<u8> {
310    let n_steps = info_bits + code.tail_bits();
311    let num_states = code.num_states();
312
313    // Depuncture: rebuild the full 2-per-step LLR stream, inserting 0.0 at
314    // deleted positions.
315    let mut full = vec![0.0f32; n_steps * 2];
316    if rate == PunctureRate::R1_2 {
317        let n = coded_llrs.len().min(full.len());
318        full[..n].copy_from_slice(&coded_llrs[..n]);
319    } else {
320        let (g0, g1) = rate.matrix();
321        let period = rate.period();
322        let mut src = 0usize;
323        for t in 0..n_steps {
324            let col = t % period;
325            if g0[col] == 1 {
326                if src < coded_llrs.len() {
327                    full[t * 2] = coded_llrs[src];
328                }
329                src += 1;
330            }
331            if g1[col] == 1 {
332                if src < coded_llrs.len() {
333                    full[t * 2 + 1] = coded_llrs[src];
334                }
335                src += 1;
336            }
337        }
338    }
339
340    // Forward ACS. Metrics are correlations to be MAXIMIZED: for a branch with
341    // coded bits (c0, c1), the contribution is `(1-2c0)·llr0 + (1-2c1)·llr1`
342    // (a positive llr favors bit 0, so `(1-2·0)=+1` rewards agreement).
343    let neg_inf = f32::MIN / 2.0;
344    let mut pm = vec![neg_inf; num_states];
345    pm[0] = 0.0; // known start state
346    // The trellis, resolved once (see `branch_table`) rather than re-derived on
347    // every visit.
348    let table = branch_table(code);
349    // Flat survivor table, `prev_state[t * num_states + s]`. This was a
350    // `Vec<Vec<u16>>`, i.e. one heap allocation per trellis step — 516 of them
351    // for a 512-bit block, to hold 16 `u16` each.
352    let mut prev_state = vec![0u16; n_steps * num_states];
353    let top_bit = code.reg_bits() - 1;
354
355    let mut new_pm = vec![neg_inf; num_states];
356    for t in 0..n_steps {
357        let l0 = full[t * 2];
358        let l1 = full[t * 2 + 1];
359        // The only four correlations any branch can contribute at this step,
360        // indexed by the branch's `(c0 << 1) | c1`. Arithmetically identical to
361        // `(1 − 2c0)·l0 + (1 − 2c1)·l1`, which scales by exactly ±1 — so this is
362        // bit-identical, not merely equivalent.
363        let corr = [l0 + l1, l0 - l1, -l0 + l1, -l0 - l1];
364        new_pm.iter_mut().for_each(|m| *m = neg_inf);
365        let row = &mut prev_state[t * num_states..(t + 1) * num_states];
366        for (prev, &pm_prev) in pm.iter().enumerate() {
367            if pm_prev <= neg_inf {
368                continue;
369            }
370            // Bit 0 then bit 1, states ascending — the same visit order as
371            // before, which matters: survivors are kept on a strict `>`, so ties
372            // go to whichever branch is seen first.
373            for br in &table[prev * 2..prev * 2 + 2] {
374                let ns = br.next as usize;
375                let cand = pm_prev + corr[br.sym as usize];
376                if cand > new_pm[ns] {
377                    new_pm[ns] = cand;
378                    row[ns] = prev as u16;
379                }
380            }
381        }
382        std::mem::swap(&mut pm, &mut new_pm);
383    }
384
385    // With zero-tail termination the ending state is 0.
386    let mut state = 0usize;
387    let mut bits = vec![0u8; n_steps];
388    for t in (0..n_steps).rev() {
389        let prev = prev_state[t * num_states + state] as usize;
390        // The input bit driving prev → state is the top register bit of `state`
391        // (next_state inserts b at bit `reg_bits-1`).
392        bits[t] = ((state >> top_bit) & 1) as u8;
393        state = prev;
394    }
395
396    bits.truncate(info_bits);
397    bits
398}