Skip to main content

orion_sdr/codec/
ft4.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// FT4 channel codec: same LDPC(174,91) as FT8 but 4-FSK (2 bits/symbol).
5//
6// FT4 difference from FT8:
7//   - 77-bit payload is XOR'd with a 10-byte pseudorandom sequence before CRC+LDPC.
8//   - 174 codeword bits are split into 87 × 2-bit groups (not 58 × 3-bit).
9//   - Gray code is the 4-symbol table [0,1,3,2] (not the 8-symbol FT8 table).
10
11use crate::codec::crc::{ft8_add_crc, ft8_crc14, ft8_extract_crc};
12use crate::codec::gray::{gray4_decode, gray4_encode};
13use crate::codec::ldpc::{self, ldpc_decode_soft, ldpc_encode};
14use crate::modulate::Ft4Frame;
15use crate::modulate::ft4::FT4_DATA_SYMS;
16
17/// 77-bit FT4 payload packed into 10 bytes (MSB first).
18pub type Ft4Bits = [u8; 10];
19
20// FT4 XOR scramble sequence — applied to payload before CRC+LDPC.
21// Source: ft8_lib `kFT4_XOR_sequence`.
22const FT4_XOR: [u8; 10] = [0x4A, 0x5E, 0x89, 0xB4, 0xB0, 0x8A, 0x79, 0x55, 0xBE, 0x28];
23
24/// FT4 channel encoder/decoder.
25pub struct Ft4Codec;
26
27impl Ft4Codec {
28    /// Encode a 77-bit payload into an `Ft4Frame` of 87 Gray-coded tone indices.
29    pub fn encode(payload: &Ft4Bits) -> Ft4Frame {
30        // 1. XOR payload with scramble sequence
31        let mut scrambled = [0u8; 10];
32        for i in 0..10 {
33            scrambled[i] = payload[i] ^ FT4_XOR[i];
34        }
35
36        // 2. Append CRC-14
37        let mut a91 = [0u8; ldpc::K_BYTES];
38        ft8_add_crc(&scrambled, &mut a91);
39
40        // 3. LDPC encode
41        let mut codeword = [0u8; ldpc::N_BYTES];
42        ldpc_encode(&a91, &mut codeword);
43
44        // 4. Extract 174 bits, group into 87 × 2-bit words, Gray-encode each
45        let mut tones = [0u8; FT4_DATA_SYMS];
46        let mut mask: u8 = 0x80;
47        let mut byte_idx = 0usize;
48
49        for tone in tones.iter_mut() {
50            let mut bits2: u8 = 0;
51            for bit_pos in (0u8..2).rev() {
52                if codeword[byte_idx] & mask != 0 {
53                    bits2 |= 1 << bit_pos;
54                }
55                mask >>= 1;
56                if mask == 0 {
57                    mask = 0x80;
58                    byte_idx += 1;
59                }
60            }
61            *tone = gray4_encode(bits2);
62        }
63
64        Ft4Frame::new(tones)
65    }
66
67    /// Decode an `Ft4Frame` using hard decisions.
68    pub fn decode_hard(frame: &Ft4Frame) -> Option<Ft4Bits> {
69        let llr = Self::frame_to_llr_hard(frame);
70        Self::decode_llr(&llr)
71    }
72
73    /// Decode using soft LLR values.
74    pub fn decode_soft(llr: &[f32; ldpc::N]) -> Option<Ft4Bits> {
75        Self::decode_llr(llr)
76    }
77
78    /// Convert an `Ft4Frame` (hard tone decisions) into 174 LLRs (±10.0).
79    pub fn frame_to_llr_hard(frame: &Ft4Frame) -> [f32; ldpc::N] {
80        let mut llr = [0.0f32; ldpc::N];
81        for (sym_idx, &tone) in frame.0.iter().enumerate() {
82            let bin = gray4_decode(tone);
83            for bit_pos in 0..2usize {
84                let bit = (bin >> (1 - bit_pos)) & 1;
85                llr[sym_idx * 2 + bit_pos] = if bit == 0 { 10.0 } else { -10.0 };
86            }
87        }
88        llr
89    }
90
91    fn decode_llr(llr: &[f32; ldpc::N]) -> Option<Ft4Bits> {
92        let mut plain = [0u8; ldpc::N];
93        let errors = ldpc_decode_soft(llr, 20, &mut plain);
94        if errors != 0 {
95            return None;
96        }
97
98        // Pack the first K bits back into bytes
99        let mut a91 = [0u8; ldpc::K_BYTES];
100        for i in 0..ldpc::K {
101            if plain[i] == 1 {
102                a91[i / 8] |= 0x80 >> (i % 8);
103            }
104        }
105
106        // Verify CRC — same logic as FT8: zero the CRC area before recomputing
107        // so the 14 CRC bits don't corrupt the computation.  See ft8.rs for a
108        // detailed explanation.
109        let extracted = ft8_extract_crc(&a91);
110        let mut buf = a91;
111        buf[9] &= 0xF8;
112        buf[10] = 0;
113        buf[11] = 0;
114        let computed = ft8_crc14(&buf, 82);
115        if extracted != computed {
116            return None;
117        }
118
119        // Un-XOR to recover the original (pre-scramble) payload.
120        // Note: a91 holds the *scrambled* payload; XOR again to undo it.
121        // Also zero the 3 slack bits of byte 9 for a canonical representation.
122        let mut payload = [0u8; 10];
123        for i in 0..10 {
124            payload[i] = a91[i] ^ FT4_XOR[i];
125        }
126        payload[9] &= 0xF8;
127        Some(payload)
128    }
129}