orion_sdr/codec/ft8.rs
1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// FT8 channel codec: ties together CRC-14, LDPC(174,91), and Gray code.
5//
6// Encode path: 77-bit payload → CRC → LDPC → Gray → Ft8Frame (58 tone indices)
7// Decode path: Ft8Frame → inverse Gray → LLRs → LDPC → CRC check → 77-bit payload
8
9use crate::codec::crc::{ft8_add_crc, ft8_crc14, ft8_extract_crc};
10use crate::codec::gray::{gray8_decode, gray8_encode};
11use crate::codec::ldpc::{self, ldpc_decode_soft, ldpc_encode};
12use crate::message::{CallsignHashTable, Ft8Message, unpack77};
13use crate::modulate::Ft8Frame;
14use crate::modulate::ft4::{FT4_FRAME_LEN, FT4_TONE_SPACING_HZ};
15use crate::modulate::ft8::{FT8_DATA_SYMS, FT8_FRAME_LEN, FT8_TONE_SPACING_HZ};
16use crate::sync::{ft4_sync, ft8_sync};
17use num_complex::Complex32 as C32;
18
19/// 77-bit FT8 payload packed into 10 bytes (MSB first; bits 77..79 of byte 9 are zero).
20pub type Ft8Bits = [u8; 10];
21
22/// FT8 channel encoder/decoder.
23pub struct Ft8Codec;
24
25impl Ft8Codec {
26 /// Encode a 77-bit payload into an `Ft8Frame` of 58 Gray-coded tone indices.
27 ///
28 /// Steps: payload → CRC-14 appended → LDPC(174,91) → Gray code → 58 tones.
29 pub fn encode(payload: &Ft8Bits) -> Ft8Frame {
30 // 1. Append CRC-14 to get 91-bit a91
31 let mut a91 = [0u8; ldpc::K_BYTES];
32 ft8_add_crc(payload, &mut a91);
33
34 // 2. LDPC encode: 91 bits → 174-bit codeword
35 let mut codeword = [0u8; ldpc::N_BYTES];
36 ldpc_encode(&a91, &mut codeword);
37
38 // 3. Extract 174 bits, group into 58 × 3-bit words, Gray-encode each
39 let mut tones = [0u8; FT8_DATA_SYMS];
40 let mut mask: u8 = 0x80;
41 let mut byte_idx = 0usize;
42
43 for tone in tones.iter_mut() {
44 let mut bits3: u8 = 0;
45 for bit_pos in (0u8..3).rev() {
46 if codeword[byte_idx] & mask != 0 {
47 bits3 |= 1 << bit_pos;
48 }
49 mask >>= 1;
50 if mask == 0 {
51 mask = 0x80;
52 byte_idx += 1;
53 }
54 }
55 *tone = gray8_encode(bits3);
56 }
57
58 Ft8Frame::new(tones)
59 }
60
61 /// Decode an `Ft8Frame` using hard decisions.
62 ///
63 /// Applies inverse Gray code, treats each bit as a ±10 LLR, runs LDPC,
64 /// then verifies the CRC. Returns the 77-bit payload on success.
65 pub fn decode_hard(frame: &Ft8Frame) -> Option<Ft8Bits> {
66 // Build ±10 LLRs from hard tone decisions
67 let llr = Self::frame_to_llr_hard(frame);
68 Self::decode_llr(&llr)
69 }
70
71 /// Decode using soft LLR values produced by a sync/correlator stage.
72 ///
73 /// `llrs` — 174 floats, LLR = log(P(bit=0)/P(bit=1)), positive ⇒ likely 0.
74 pub fn decode_soft(llr: &[f32; ldpc::N]) -> Option<Ft8Bits> {
75 Self::decode_llr(llr)
76 }
77
78 /// Convert an `Ft8Frame` (hard tone decisions) into 174 LLRs (±10.0).
79 pub fn frame_to_llr_hard(frame: &Ft8Frame) -> [f32; ldpc::N] {
80 let mut llr = [0.0f32; ldpc::N];
81 for (sym_idx, &tone) in frame.0.iter().enumerate() {
82 let bin = gray8_decode(tone);
83 for bit_pos in 0..3usize {
84 let bit = (bin >> (2 - bit_pos)) & 1;
85 llr[sym_idx * 3 + 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<Ft8Bits> {
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.
107 //
108 // The CRC covers only the 77-bit payload zero-extended to 82 bits; the
109 // 14 CRC bits themselves (bits 77-90 of a91) must NOT be included.
110 // We therefore zero out the CRC area (bits 77-95 of the buffer) before
111 // calling ft8_crc14 with num_bits=82. Running ft8_crc14(&a91, 82)
112 // without zeroing would include 5 CRC bits in the computation and
113 // produce a wrong answer.
114 let extracted = ft8_extract_crc(&a91);
115 let mut buf = a91;
116 buf[9] &= 0xF8; // zero bits 77-79 (slack bits, also start of CRC)
117 buf[10] = 0; // zero bits 80-87 (CRC bits 3-10)
118 buf[11] = 0; // zero bits 88-95 (CRC bits 11-13 + unused)
119 let computed = ft8_crc14(&buf, 82);
120 if extracted != computed {
121 return None;
122 }
123
124 // Return the 77 payload bits. Bits 77-79 of byte 9 are slack; mask
125 // them to zero so callers get a canonical representation.
126 let mut payload = [0u8; 10];
127 payload.copy_from_slice(&a91[..10]);
128 payload[9] &= 0xF8;
129 Some(payload)
130 }
131}
132
133// ── Ft8StreamDecoder ──────────────────────────────────────────────────────────
134
135/// Result of one successfully decoded FT8 or FT4 frame.
136pub struct Ft8DecodeResult {
137 /// Decoded message content.
138 pub message: Ft8Message,
139 /// Tone-0 frequency in Hz (carrier of the detected frame).
140 pub carrier_hz: f32,
141 /// SNR estimate in dB (Costas score, arbitrary but monotone with true SNR).
142 pub snr_db: f32,
143}
144
145/// Accumulates IQ samples at 12 kHz and decodes FT8 or FT4 frames.
146///
147/// Feed samples incrementally with [`feed`]. When the internal buffer reaches
148/// `frame_len` samples, a decode attempt is triggered automatically and the
149/// results are returned. Call [`flush`] to attempt a decode on whatever is
150/// currently buffered (useful at the end of a session or after a gap). Call
151/// [`clear`] to discard the buffer without decoding.
152///
153/// The decoder operates at the FT8/FT4 native sample rate of **12 000 Hz**.
154/// Callers receiving samples at a higher rate (e.g. 48 kHz) must decimate
155/// before feeding.
156///
157/// A single [`CallsignHashTable`] is maintained across frames so nonstandard
158/// callsigns hashed in earlier frames can be resolved in later ones.
159pub struct Ft8StreamDecoder {
160 buf: Vec<C32>,
161 fs: f32,
162 base_hz: f32,
163 max_hz: f32,
164 frame_len: usize,
165 is_ft8: bool,
166 max_cand: usize,
167 hash_table: CallsignHashTable,
168}
169
170impl Ft8StreamDecoder {
171 /// Create a decoder for FT8 frames.
172 ///
173 /// - `fs` — sample rate (should be 12 000 Hz)
174 /// - `base_hz` — lowest tone-0 frequency to search (Hz)
175 /// - `max_hz` — highest tone-0 frequency to search (Hz)
176 /// - `max_cand` — maximum sync candidates to score per decode attempt
177 pub fn new_ft8(fs: f32, base_hz: f32, max_hz: f32, max_cand: usize) -> Self {
178 Self {
179 buf: Vec::new(),
180 fs,
181 base_hz,
182 max_hz,
183 frame_len: FT8_FRAME_LEN,
184 is_ft8: true,
185 max_cand: max_cand.max(1),
186 hash_table: CallsignHashTable::new(),
187 }
188 }
189
190 /// Create a decoder for FT4 frames.
191 pub fn new_ft4(fs: f32, base_hz: f32, max_hz: f32, max_cand: usize) -> Self {
192 Self {
193 buf: Vec::new(),
194 fs,
195 base_hz,
196 max_hz,
197 frame_len: FT4_FRAME_LEN,
198 is_ft8: false,
199 max_cand: max_cand.max(1),
200 hash_table: CallsignHashTable::new(),
201 }
202 }
203
204 /// Feed IQ samples into the accumulation buffer.
205 ///
206 /// If the buffer reaches `frame_len` samples after appending, a decode
207 /// attempt is triggered and the results are returned. Otherwise returns
208 /// an empty `Vec`.
209 pub fn feed(&mut self, iq: &[C32]) -> Vec<Ft8DecodeResult> {
210 self.buf.extend_from_slice(iq);
211 if self.buf.len() >= self.frame_len {
212 self.decode_buf()
213 } else {
214 Vec::new()
215 }
216 }
217
218 /// Attempt a decode on whatever is currently in the buffer.
219 ///
220 /// Useful at a gap edge when the caller knows a frame has ended but the
221 /// buffer may be shorter than `frame_len` (e.g. due to signal dropout).
222 /// Returns decoded results; does NOT clear the buffer.
223 pub fn flush(&mut self) -> Vec<Ft8DecodeResult> {
224 if self.buf.is_empty() {
225 return Vec::new();
226 }
227 self.decode_buf()
228 }
229
230 /// Discard all accumulated samples without decoding.
231 pub fn clear(&mut self) {
232 self.buf.clear();
233 }
234
235 /// Number of accumulated samples.
236 pub fn len(&self) -> usize {
237 self.buf.len()
238 }
239
240 /// True if no samples have been accumulated.
241 pub fn is_empty(&self) -> bool {
242 self.buf.is_empty()
243 }
244
245 /// Read-only view of the accumulated IQ buffer.
246 pub fn view_buf(&self) -> &[C32] {
247 &self.buf
248 }
249
250 // ── Internal ──────────────────────────────────────────────────────────
251
252 fn decode_buf(&mut self) -> Vec<Ft8DecodeResult> {
253 let mut results = Vec::new();
254
255 let tone_spacing = if self.is_ft8 {
256 FT8_TONE_SPACING_HZ
257 } else {
258 FT4_TONE_SPACING_HZ
259 };
260
261 // Clamp search range so base_hz is valid.
262 let search_min = self.base_hz;
263 let search_max = (self.max_hz + tone_spacing).max(search_min + tone_spacing);
264
265 let candidates = if self.is_ft8 {
266 let raw = ft8_sync(
267 &self.buf,
268 self.fs,
269 search_min,
270 search_max,
271 0,
272 0,
273 self.max_cand,
274 );
275 // Convert to unified representation for processing below.
276 raw.into_iter()
277 .map(|r| SyncCandidate {
278 freq_bin: r.freq_bin,
279 score: r.score,
280 llr: r.llr,
281 })
282 .collect::<Vec<_>>()
283 } else {
284 let raw = ft4_sync(
285 &self.buf,
286 self.fs,
287 search_min,
288 search_max,
289 0,
290 0,
291 self.max_cand,
292 );
293 raw.into_iter()
294 .map(|r| SyncCandidate {
295 freq_bin: r.freq_bin,
296 score: r.score,
297 llr: r.llr,
298 })
299 .collect::<Vec<_>>()
300 };
301
302 for cand in candidates {
303 let payload = if self.is_ft8 {
304 Ft8Codec::decode_soft(&cand.llr)
305 } else {
306 crate::codec::ft4::Ft4Codec::decode_soft(&cand.llr)
307 };
308
309 if let Some(bits) = payload {
310 let message = unpack77(&bits, &self.hash_table);
311 let carrier_hz = self.base_hz + cand.freq_bin as f32 * tone_spacing;
312 results.push(Ft8DecodeResult {
313 message,
314 carrier_hz,
315 snr_db: cand.score,
316 });
317 // Stop after the first CRC-passing candidate.
318 break;
319 }
320 }
321
322 results
323 }
324}
325
326/// Internal unified sync result (avoids duplicating processing logic for FT8/FT4).
327struct SyncCandidate {
328 freq_bin: usize,
329 score: f32,
330 llr: [f32; ldpc::N],
331}