acoustic_ofdm/
modem.rs

1// Copyright (c) 2026 Elias S. G. Carotti
2
3use rustfft::num_complex::Complex32;
4
5use crate::baseband::{
6    decode_packet_info_baseband, fft, known_pilot_symbols, known_training_symbols, ofdm_bin_plan,
7    packet_symbol_plan, recover_decided_packet_bytes_baseband, tx_one_packet_baseband,
8    PacketSymbolKind,
9};
10use crate::config::{OfdmConfig, PassbandMode};
11use crate::debug::{
12    EncodedBurst, EncodedPacketMeta, PassbandBinDump, PassbandBinDumpRow,
13    PassbandChannelCompareDump, PassbandChannelCompareRow, PassbandConstellationDump,
14    PassbandDiagnostics, PassbandIqChainDump, PassbandPilotTrackDump, PassbandSyncDump,
15};
16use crate::equalizer::{
17    decision_directed_evm, equalize_symbol_with_pilots, equalizer_initial_channel,
18    equalizer_refresh_channel, equalizer_reset_tracking, regularized_equalize, rms_evm,
19    EqualizerTrackingState,
20};
21use crate::packet::{
22    build_packet_bytes, fec_encoded_bits_len, modulation_from_id, split_payload, PacketInfo,
23};
24use crate::sync::{
25    active_baseband_fs, apply_cfo_hz, coarse_cfo_correct, estimate_coarse_cfo_hz,
26    find_repeated_half_sync_offset, refine_sync_offset, repeated_half_sync_metrics,
27    resample_from_offset, resample_from_offset_rate, sample_complex_linear,
28};
29use crate::wake::make_wake_tone;
30
31fn pilot_phase_error(
32    xeq_used: &[Complex32],
33    used_bins: &[usize],
34    pilot_bins: &[usize],
35    pref: &[Complex32],
36) -> Option<f32> {
37    if pilot_bins.is_empty() || pref.is_empty() {
38        return None;
39    }
40    let mut acc = Complex32::new(0.0, 0.0);
41    for (k, pbin) in pilot_bins.iter().enumerate() {
42        if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
43            acc += xeq_used[pos] * pref[k].conj();
44        }
45    }
46    if acc.norm() <= 1.0e-9 {
47        None
48    } else {
49        Some(acc.arg())
50    }
51}
52
53fn resample_complex_linear_rate(x: &[Complex32], fs_in: f32, fs_out: f32) -> Vec<Complex32> {
54    if x.is_empty() || fs_in <= 0.0 || fs_out <= 0.0 {
55        return Vec::new();
56    }
57    if (fs_in - fs_out).abs() <= 1.0e-6 {
58        return x.to_vec();
59    }
60    let out_len = ((x.len() as f32) * fs_out / fs_in).round().max(1.0) as usize;
61    let step = fs_in / fs_out;
62    let mut out = Vec::with_capacity(out_len);
63    for n in 0..out_len {
64        out.push(sample_complex_linear(x, n as f32 * step));
65    }
66    out
67}
68
69/// Encodes a full payload into one multi-packet OFDM burst.
70///
71/// Parameters:
72/// - `payload`: full application payload bytes.
73/// - `cfg`: modem configuration.
74/// Returns:
75/// - `EncodedBurst`: audio samples and per-packet metadata.
76pub fn encode_payload(payload: &[u8], cfg: &OfdmConfig) -> EncodedBurst {
77    let chunks = split_payload(payload, cfg.packet_payload_bytes);
78    let num_packets = chunks.len();
79    let mut audio = Vec::<f32>::new();
80    let mut meta = Vec::<EncodedPacketMeta>::with_capacity(num_packets);
81
82    let pre_sil = (0.015 * cfg.fs) as usize;
83    let post_sil = (0.020 * cfg.fs) as usize;
84
85    for (i, chunk) in chunks.iter().enumerate() {
86        let pkt_bytes = build_packet_bytes(chunk, i as u8, num_packets as u8, cfg);
87        let xbb = tx_one_packet_baseband(&pkt_bytes, cfg);
88        let pkt_audio = tx_one_packet(&pkt_bytes, cfg);
89
90        audio.extend(std::iter::repeat(0.0).take(pre_sil));
91        let packet_start = audio.len();
92        audio.extend_from_slice(&pkt_audio);
93        let packet_len = pkt_audio.len();
94        audio.extend(std::iter::repeat(0.0).take(post_sil));
95
96        meta.push(EncodedPacketMeta {
97            frag_index: i,
98            frag_count: num_packets,
99            packet_start,
100            packet_len,
101            xbb,
102        });
103    }
104
105    normalize_in_place(&mut audio, 0.85);
106    EncodedBurst {
107        audio,
108        packet_meta: meta,
109    }
110}
111
112/// Decodes an encoded burst using oracle packet boundaries and baseband symbols.
113///
114/// Parameters:
115/// - `burst`: encoded burst with per-packet metadata.
116/// - `cfg`: modem configuration.
117/// Returns:
118/// - `Option<Vec<u8>>`: reconstructed payload on success, otherwise `None`.
119pub fn decode_encoded_burst_oracle(burst: &EncodedBurst, cfg: &OfdmConfig) -> Option<Vec<u8>> {
120    let mut frags: Vec<Option<Vec<u8>>> = vec![None; burst.packet_meta.len()];
121    for m in &burst.packet_meta {
122        let info = decode_packet_info_baseband(&m.xbb, cfg)?;
123        if modulation_from_id(info.mod_id)? != cfg.modulation {
124            return None;
125        }
126        let idx = info.frag_index as usize;
127        if idx >= frags.len() || info.frag_count as usize != frags.len() {
128            return None;
129        }
130        frags[idx] = Some(info.payload);
131    }
132
133    let mut out = Vec::new();
134    for f in frags {
135        out.extend(f?);
136    }
137    Some(out)
138}
139
140/// Encodes one payload fragment (single packet) into passband samples.
141///
142/// Parameters:
143/// - `payload`: single-packet payload bytes (must fit packet payload budget).
144/// - `cfg`: modem configuration.
145/// Returns:
146/// - `Vec<f32>`: passband waveform for one packet.
147pub fn encode_single_packet_passband(payload: &[u8], cfg: &OfdmConfig) -> Vec<f32> {
148    let pkt_bytes = build_packet_bytes(payload, 0, 1, cfg);
149    tx_one_packet(&pkt_bytes, cfg)
150}
151
152/// Encodes one payload fragment into passband OFDM-body samples only.
153///
154/// Parameters:
155/// - `payload`: single-packet payload bytes (must fit packet payload budget).
156/// - `cfg`: modem configuration.
157/// Returns:
158/// - `Vec<f32>`: passband waveform for the OFDM body only, without wake/guard.
159pub fn encode_single_packet_passband_body(payload: &[u8], cfg: &OfdmConfig) -> Vec<f32> {
160    let pkt_bytes = build_packet_bytes(payload, 0, 1, cfg);
161    tx_one_packet_body(&pkt_bytes, cfg)
162}
163
164/// Decodes one passband packet waveform into payload bytes.
165///
166/// Parameters:
167/// - `pkt_audio`: passband packet waveform (wake + guard + OFDM body).
168/// - `cfg`: modem configuration.
169/// Returns:
170/// - `Option<Vec<u8>>`: decoded payload bytes, or `None` on failure.
171pub fn decode_single_packet_passband(pkt_audio: &[f32], cfg: &OfdmConfig) -> Option<Vec<u8>> {
172    decode_packet_from_passband(pkt_audio, cfg).map(|p| p.payload)
173}
174
175/// Decodes one passband packet waveform into payload bytes using a known sync offset.
176///
177/// Parameters:
178/// - `pkt_audio`: passband packet waveform (wake + guard + OFDM body).
179/// - `cfg`: modem configuration.
180/// - `sync_off`: known repeated-half/training alignment offset in baseband samples.
181/// Returns:
182/// - `Option<Vec<u8>>`: decoded payload bytes, or `None` on failure.
183pub fn decode_single_packet_passband_with_sync(
184    pkt_audio: &[f32],
185    cfg: &OfdmConfig,
186    sync_off: f32,
187) -> Option<Vec<u8>> {
188    decode_single_packet_passband_with_sync_rate(pkt_audio, cfg, sync_off, 1.0)
189}
190
191pub fn decode_single_packet_passband_with_sync_rate(
192    pkt_audio: &[f32],
193    cfg: &OfdmConfig,
194    sync_off: f32,
195    time_scale: f32,
196) -> Option<Vec<u8>> {
197    decode_packet_from_passband_with_sync_rate(pkt_audio, cfg, sync_off, time_scale)
198        .map(|p| p.payload)
199}
200
201pub fn recover_decided_packet_bytes_passband_with_sync(
202    pkt_audio: &[f32],
203    cfg: &OfdmConfig,
204    sync_off: f32,
205) -> Option<Vec<u8>> {
206    recover_decided_packet_bytes_passband_with_sync_rate(pkt_audio, cfg, sync_off, 1.0)
207}
208
209pub fn recover_decided_packet_bytes_passband_with_sync_rate(
210    pkt_audio: &[f32],
211    cfg: &OfdmConfig,
212    sync_off: f32,
213    time_scale: f32,
214) -> Option<Vec<u8>> {
215    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
216    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
217    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
218        return None;
219    }
220
221    let passband = &pkt_audio[wake_len + guard_len..];
222    let pre = 128usize.min(passband.len());
223    let mut chunk = vec![0.0f32; pre];
224    chunk.extend_from_slice(passband);
225    let rbb_full = downconvert_passband(&chunk, cfg);
226    let rbb = &rbb_full[pre..];
227    let rbb_sync = resample_from_offset_rate(rbb, sync_off, time_scale);
228    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
229    recover_decided_packet_bytes_baseband(&rbb_cfo, cfg)
230}
231
232/// Produces diagnostics for one passband packet window.
233///
234/// Parameters:
235/// - `pkt_audio`: passband packet waveform window.
236/// - `cfg`: modem configuration.
237/// Returns:
238/// - `PassbandDiagnostics`: sync/CFO/equalization diagnostics.
239pub fn diagnose_passband_window(pkt_audio: &[f32], cfg: &OfdmConfig) -> PassbandDiagnostics {
240    diagnose_passband_window_with_sync_opt(pkt_audio, cfg, None)
241}
242
243/// Produces diagnostics for one passband packet window using a known sync offset.
244///
245/// Parameters:
246/// - `pkt_audio`: passband packet waveform window.
247/// - `cfg`: modem configuration.
248/// - `sync_off`: known repeated-half/training alignment offset in baseband samples.
249/// Returns:
250/// - `PassbandDiagnostics`: sync/CFO/equalization diagnostics.
251pub fn diagnose_passband_window_with_sync(
252    pkt_audio: &[f32],
253    cfg: &OfdmConfig,
254    sync_off: f32,
255) -> PassbandDiagnostics {
256    diagnose_passband_window_with_sync_rate(pkt_audio, cfg, sync_off, 1.0)
257}
258
259pub fn diagnose_passband_window_with_sync_rate(
260    pkt_audio: &[f32],
261    cfg: &OfdmConfig,
262    sync_off: f32,
263    time_scale: f32,
264) -> PassbandDiagnostics {
265    diagnose_passband_window_with_sync_opt(pkt_audio, cfg, Some((sync_off, time_scale)))
266}
267
268fn diagnose_passband_window_with_sync_opt(
269    pkt_audio: &[f32],
270    cfg: &OfdmConfig,
271    forced_sync: Option<(f32, f32)>,
272) -> PassbandDiagnostics {
273    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
274    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
275    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
276        return PassbandDiagnostics {
277            enough_samples: false,
278            sync_off: 0,
279            cfo_hz: 0.0,
280            sync_rms: 0.0,
281            sync_peak: 0.0,
282            post_rms: 0.0,
283            post_peak: 0.0,
284            train_rms: 0.0,
285            hest_mag_min: 0.0,
286            hest_mag_mean: 0.0,
287            hest_mag_max: 0.0,
288            train_recon_evm: 0.0,
289            pilot_residual_evm: 0.0,
290            pilot_post_evm: 0.0,
291            post_eq_evm: 0.0,
292            decoded: false,
293            decoded_payload_len: None,
294        };
295    }
296
297    let passband = &pkt_audio[wake_len + guard_len..];
298    let pre = 128usize.min(passband.len());
299    let mut chunk = vec![0.0f32; pre];
300    chunk.extend_from_slice(passband);
301    let rbb_full = downconvert_passband(&chunk, cfg);
302    let rbb = &rbb_full[pre..];
303    let coarse_sync_off = find_repeated_half_sync_offset(rbb, cfg);
304    let (sync_off, time_scale) =
305        forced_sync.unwrap_or_else(|| (refine_sync_offset(rbb, cfg, coarse_sync_off), 1.0));
306    let rbb_sync = resample_from_offset_rate(rbb, sync_off, time_scale);
307    let cfo_hz = estimate_coarse_cfo_hz(&rbb_sync, cfg);
308    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
309
310    let (used_bins, pilot_bins, data_bins) = ofdm_bin_plan(cfg);
311    let xsync_len = 2 * cfg.sync_half_len;
312    let train_len = cfg.nfft + cfg.ncp;
313    if rbb_cfo.len() < xsync_len + train_len || used_bins.is_empty() {
314        return PassbandDiagnostics {
315            enough_samples: false,
316            sync_off: sync_off.round() as usize,
317            cfo_hz,
318            sync_rms: 0.0,
319            sync_peak: 0.0,
320            post_rms: 0.0,
321            post_peak: 0.0,
322            train_rms: 0.0,
323            hest_mag_min: 0.0,
324            hest_mag_mean: 0.0,
325            hest_mag_max: 0.0,
326            train_recon_evm: 0.0,
327            pilot_residual_evm: 0.0,
328            pilot_post_evm: 0.0,
329            post_eq_evm: 0.0,
330            decoded: false,
331            decoded_payload_len: None,
332        };
333    }
334
335    let train_start = xsync_len;
336    let sync_slice = &rbb_cfo[..xsync_len.min(rbb_cfo.len())];
337    let data_post_start = (xsync_len + train_len).min(rbb_cfo.len());
338    let post_slice = &rbb_cfo[data_post_start..];
339    let sync_rms = if sync_slice.is_empty() {
340        0.0
341    } else {
342        (sync_slice.iter().map(|v| v.norm_sqr()).sum::<f32>() / sync_slice.len() as f32).sqrt()
343    };
344    let sync_peak = sync_slice.iter().map(|v| v.norm()).fold(0.0f32, f32::max);
345    let post_rms = if post_slice.is_empty() {
346        0.0
347    } else {
348        (post_slice.iter().map(|v| v.norm_sqr()).sum::<f32>() / post_slice.len() as f32).sqrt()
349    };
350    let post_peak = post_slice.iter().map(|v| v.norm()).fold(0.0f32, f32::max);
351    let train_no_cp = &rbb_cfo[train_start + cfg.ncp..train_start + cfg.ncp + cfg.nfft];
352    let train_rms =
353        (train_no_cp.iter().map(|v| v.norm_sqr()).sum::<f32>() / (train_no_cp.len() as f32)).sqrt();
354    let ytrain = fft(train_no_cp);
355    let train_known = known_training_symbols(used_bins.len(), cfg.modulation);
356    let mut hest = equalizer_initial_channel(cfg, &ytrain, &used_bins, &train_known);
357    let mut eq_state = EqualizerTrackingState::default();
358    equalizer_reset_tracking(&mut eq_state);
359    let mut mags = hest.iter().map(|h| h.norm()).collect::<Vec<_>>();
360    let mut ytrain_eq = Vec::with_capacity(used_bins.len());
361    for (k, &bin) in used_bins.iter().enumerate() {
362        ytrain_eq.push(regularized_equalize(ytrain[bin], hest[k]));
363    }
364    let train_recon_evm = rms_evm(&ytrain_eq, &train_known);
365    let data_start = xsync_len + train_len;
366    let sym_len = cfg.nfft + cfg.ncp;
367    let max_payload_bytes = cfg.packet_payload_bytes + 16;
368    let max_bits = fec_encoded_bits_len(max_payload_bytes * 8, cfg.fec_mode);
369    let max_data_ofdm =
370        max_bits.div_ceil(data_bins.len().max(1) * cfg.modulation.bits_per_symbol()) + 2;
371    let symbol_plan = packet_symbol_plan(max_data_ofdm, cfg);
372    let mut pilot_eq = Vec::new();
373    let mut pilot_ref = Vec::new();
374    let mut pilot_eq_post = Vec::new();
375    let mut post_eq_data = Vec::new();
376    let mut data_symbol_idx = 0usize;
377    for (sym_idx, kind) in symbol_plan.into_iter().enumerate() {
378        let s0 = data_start + sym_idx * sym_len;
379        let s1 = s0 + sym_len;
380        if s1 > rbb_cfo.len() || data_symbol_idx >= 3 {
381            break;
382        }
383        let y = fft(&rbb_cfo[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
384        if kind == PacketSymbolKind::Training {
385            equalizer_refresh_channel(cfg, &mut hest, &y, &used_bins, &train_known);
386            equalizer_reset_tracking(&mut eq_state);
387            mags.extend(hest.iter().map(|h| h.norm()));
388            continue;
389        }
390        let pref = known_pilot_symbols(pilot_bins.len(), data_symbol_idx + 1);
391        let xeq_used = equalize_symbol_with_pilots(
392            cfg,
393            &mut eq_state,
394            &y,
395            &used_bins,
396            &pilot_bins,
397            &pref,
398            &hest,
399        );
400        if !pilot_bins.is_empty() {
401            for (k, pbin) in pilot_bins.iter().enumerate() {
402                if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
403                    pilot_eq.push(xeq_used[pos]);
404                    pilot_ref.push(pref[k]);
405                    pilot_eq_post.push(xeq_used[pos]);
406                }
407            }
408        }
409        for dbin in &data_bins {
410            if let Some(pos) = used_bins.iter().position(|b| b == dbin) {
411                post_eq_data.push(xeq_used[pos]);
412            }
413        }
414        data_symbol_idx += 1;
415    }
416    let hest_mag_min = mags.iter().copied().fold(f32::INFINITY, f32::min);
417    let hest_mag_max = mags.iter().copied().fold(0.0f32, f32::max);
418    let hest_mag_mean = mags.iter().sum::<f32>() / (mags.len() as f32);
419    let pilot_residual_evm = rms_evm(&pilot_eq, &pilot_ref);
420    let pilot_post_evm = rms_evm(&pilot_eq_post, &pilot_ref);
421    let post_eq_evm = decision_directed_evm(&post_eq_data, cfg.modulation);
422    let decoded = decode_packet_info_baseband(&rbb_cfo, cfg);
423
424    PassbandDiagnostics {
425        enough_samples: true,
426        sync_off: sync_off.round() as usize,
427        cfo_hz,
428        sync_rms,
429        sync_peak,
430        post_rms,
431        post_peak,
432        train_rms,
433        hest_mag_min,
434        hest_mag_mean,
435        hest_mag_max,
436        train_recon_evm,
437        pilot_residual_evm,
438        pilot_post_evm,
439        post_eq_evm,
440        decoded: decoded.is_some(),
441        decoded_payload_len: decoded.map(|p| p.payload.len()),
442    }
443}
444
445/// Extracts equalizer input/output constellation samples for one passband window.
446///
447/// Parameters:
448/// - `pkt_audio`: passband packet waveform window.
449/// - `cfg`: modem configuration.
450/// Returns:
451/// - `Option<PassbandConstellationDump>`: constellation samples when extraction succeeds.
452pub(crate) fn dump_passband_constellation_impl(
453    pkt_audio: &[f32],
454    cfg: &OfdmConfig,
455    sync_off: f32,
456) -> Option<PassbandConstellationDump> {
457    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
458    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
459    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
460        return None;
461    }
462
463    let passband = &pkt_audio[wake_len + guard_len..];
464    let pre = 128usize.min(passband.len());
465    let mut chunk = vec![0.0f32; pre];
466    chunk.extend_from_slice(passband);
467    let rbb_full = downconvert_passband(&chunk, cfg);
468    let rbb = &rbb_full[pre..];
469    let rbb_sync = resample_from_offset(rbb, sync_off);
470    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
471
472    let (used_bins, pilot_bins, data_bins) = ofdm_bin_plan(cfg);
473    if used_bins.is_empty() || data_bins.is_empty() {
474        return None;
475    }
476    let xsync_len = 2 * cfg.sync_half_len;
477    let train_len = cfg.nfft + cfg.ncp;
478    if rbb_cfo.len() < xsync_len + train_len + cfg.nfft + cfg.ncp {
479        return None;
480    }
481
482    let train_start = xsync_len;
483    let train_no_cp = &rbb_cfo[train_start + cfg.ncp..train_start + cfg.ncp + cfg.nfft];
484    let ytrain = fft(train_no_cp);
485    let train_known = known_training_symbols(used_bins.len(), cfg.modulation);
486    let mut hest = equalizer_initial_channel(cfg, &ytrain, &used_bins, &train_known);
487    let mut eq_state = EqualizerTrackingState::default();
488    equalizer_reset_tracking(&mut eq_state);
489
490    let data_start = xsync_len + train_len;
491    let sym_len = cfg.nfft + cfg.ncp;
492    let mut pre_eq = Vec::new();
493    let mut post_eq = Vec::new();
494    let max_payload_bytes = cfg.packet_payload_bytes + 16;
495    let max_bits = fec_encoded_bits_len(max_payload_bytes * 8, cfg.fec_mode);
496    let max_data_ofdm =
497        max_bits.div_ceil(data_bins.len().max(1) * cfg.modulation.bits_per_symbol()) + 2;
498    let symbol_plan = packet_symbol_plan(max_data_ofdm, cfg);
499    let mut data_symbol_idx = 0usize;
500    for (sym_idx, kind) in symbol_plan.into_iter().enumerate() {
501        let s0 = data_start + sym_idx * sym_len;
502        let s1 = s0 + sym_len;
503        if s1 > rbb_cfo.len() || data_symbol_idx >= 8 {
504            break;
505        }
506        let y = fft(&rbb_cfo[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
507        if kind == PacketSymbolKind::Training {
508            equalizer_refresh_channel(cfg, &mut hest, &y, &used_bins, &train_known);
509            equalizer_reset_tracking(&mut eq_state);
510            continue;
511        }
512        let pref = known_pilot_symbols(pilot_bins.len(), data_symbol_idx + 1);
513        let xeq_used = equalize_symbol_with_pilots(
514            cfg,
515            &mut eq_state,
516            &y,
517            &used_bins,
518            &pilot_bins,
519            &pref,
520            &hest,
521        );
522        for dbin in &data_bins {
523            if let Some(pos) = used_bins.iter().position(|b| b == dbin) {
524                pre_eq.push(y[*dbin]);
525                post_eq.push(xeq_used[pos]);
526            }
527        }
528        data_symbol_idx += 1;
529    }
530
531    Some(PassbandConstellationDump { pre_eq, post_eq })
532}
533
534pub(crate) fn dump_passband_pilot_tracking_impl(
535    pkt_audio: &[f32],
536    cfg: &OfdmConfig,
537) -> Option<PassbandPilotTrackDump> {
538    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
539    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
540    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
541        return None;
542    }
543
544    let passband = &pkt_audio[wake_len + guard_len..];
545    let pre = 128usize.min(passband.len());
546    let mut chunk = vec![0.0f32; pre];
547    chunk.extend_from_slice(passband);
548    let rbb_full = downconvert_passband(&chunk, cfg);
549    let rbb = &rbb_full[pre..];
550    let coarse_sync_off = find_repeated_half_sync_offset(rbb, cfg);
551    let sync_off = refine_sync_offset(rbb, cfg, coarse_sync_off);
552    let rbb_sync = resample_from_offset(rbb, sync_off);
553    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
554
555    let (used_bins, pilot_bins, data_bins) = ofdm_bin_plan(cfg);
556    if used_bins.is_empty() || pilot_bins.is_empty() || data_bins.is_empty() {
557        return Some(PassbandPilotTrackDump {
558            pilot_phase_rad: Vec::new(),
559            pilot_evm_pre: Vec::new(),
560            pilot_evm_post: Vec::new(),
561            hest_mag_mean: Vec::new(),
562            hest_mag_max: Vec::new(),
563        });
564    }
565    let xsync_len = 2 * cfg.sync_half_len;
566    let train_len = cfg.nfft + cfg.ncp;
567    if rbb_cfo.len() < xsync_len + train_len + cfg.nfft + cfg.ncp {
568        return None;
569    }
570
571    let train_start = xsync_len;
572    let train_no_cp = &rbb_cfo[train_start + cfg.ncp..train_start + cfg.ncp + cfg.nfft];
573    let ytrain = fft(train_no_cp);
574    let train_known = known_training_symbols(used_bins.len(), cfg.modulation);
575    let mut hest = equalizer_initial_channel(cfg, &ytrain, &used_bins, &train_known);
576    let mut eq_state = EqualizerTrackingState::default();
577    equalizer_reset_tracking(&mut eq_state);
578
579    let data_start = xsync_len + train_len;
580    let sym_len = cfg.nfft + cfg.ncp;
581    let max_payload_bytes = cfg.packet_payload_bytes + 16;
582    let max_bits = fec_encoded_bits_len(max_payload_bytes * 8, cfg.fec_mode);
583    let max_data_ofdm =
584        max_bits.div_ceil(data_bins.len().max(1) * cfg.modulation.bits_per_symbol()) + 2;
585    let symbol_plan = packet_symbol_plan(max_data_ofdm, cfg);
586    let mut data_symbol_idx = 0usize;
587    let mut pilot_phase_rad = Vec::new();
588    let mut pilot_evm_pre = Vec::new();
589    let mut pilot_evm_post = Vec::new();
590    let mut hest_mag_mean = Vec::new();
591    let mut hest_mag_max = Vec::new();
592    for (sym_idx, kind) in symbol_plan.into_iter().enumerate() {
593        let s0 = data_start + sym_idx * sym_len;
594        let s1 = s0 + sym_len;
595        if s1 > rbb_cfo.len() {
596            break;
597        }
598        let y = fft(&rbb_cfo[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
599        if kind == PacketSymbolKind::Training {
600            equalizer_refresh_channel(cfg, &mut hest, &y, &used_bins, &train_known);
601            equalizer_reset_tracking(&mut eq_state);
602            let mags = hest.iter().map(|h| h.norm()).collect::<Vec<_>>();
603            hest_mag_mean.push(mags.iter().sum::<f32>() / (mags.len() as f32));
604            hest_mag_max.push(mags.iter().copied().fold(0.0f32, f32::max));
605            continue;
606        }
607        let pref = known_pilot_symbols(pilot_bins.len(), data_symbol_idx + 1);
608        let xeq_used = equalize_symbol_with_pilots(
609            cfg,
610            &mut eq_state,
611            &y,
612            &used_bins,
613            &pilot_bins,
614            &pref,
615            &hest,
616        );
617        let phase = pilot_phase_error(&xeq_used, &used_bins, &pilot_bins, &pref).unwrap_or(0.0);
618        let mut pilot_eq_pre = Vec::new();
619        let mut pilot_ref = Vec::new();
620        for (k, pbin) in pilot_bins.iter().enumerate() {
621            if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
622                pilot_eq_pre.push(xeq_used[pos]);
623                pilot_ref.push(pref[k]);
624            }
625        }
626        let mut pilot_eq_post = Vec::new();
627        for (k, pbin) in pilot_bins.iter().enumerate() {
628            if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
629                let _ = k;
630                pilot_eq_post.push(xeq_used[pos]);
631            }
632        }
633        pilot_phase_rad.push(phase);
634        pilot_evm_pre.push(rms_evm(&pilot_eq_pre, &pilot_ref));
635        pilot_evm_post.push(rms_evm(&pilot_eq_post, &pilot_ref));
636        data_symbol_idx += 1;
637    }
638
639    Some(PassbandPilotTrackDump {
640        pilot_phase_rad,
641        pilot_evm_pre,
642        pilot_evm_post,
643        hest_mag_mean,
644        hest_mag_max,
645    })
646}
647
648pub(crate) fn dump_passband_bins_impl(
649    pkt_audio: &[f32],
650    cfg: &OfdmConfig,
651) -> Option<PassbandBinDump> {
652    dump_passband_bins_with_sync_opt(pkt_audio, cfg, None)
653}
654
655pub(crate) fn dump_passband_bins_with_sync_impl(
656    pkt_audio: &[f32],
657    cfg: &OfdmConfig,
658    sync_off: f32,
659) -> Option<PassbandBinDump> {
660    dump_passband_bins_with_sync_opt(pkt_audio, cfg, Some(sync_off))
661}
662
663fn dump_passband_bins_with_sync_opt(
664    pkt_audio: &[f32],
665    cfg: &OfdmConfig,
666    forced_sync_off: Option<f32>,
667) -> Option<PassbandBinDump> {
668    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
669    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
670    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
671        return None;
672    }
673
674    let passband = &pkt_audio[wake_len + guard_len..];
675    let pre = 128usize.min(passband.len());
676    let mut chunk = vec![0.0f32; pre];
677    chunk.extend_from_slice(passband);
678    let rbb_full = downconvert_passband(&chunk, cfg);
679    let rbb = &rbb_full[pre..];
680    let coarse_sync_off = find_repeated_half_sync_offset(rbb, cfg);
681    let sync_off = forced_sync_off.unwrap_or_else(|| refine_sync_offset(rbb, cfg, coarse_sync_off));
682    let rbb_sync = resample_from_offset(rbb, sync_off);
683    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
684
685    let (used_bins, pilot_bins, data_bins) = ofdm_bin_plan(cfg);
686    if used_bins.is_empty() || data_bins.is_empty() {
687        return None;
688    }
689    let xsync_len = 2 * cfg.sync_half_len;
690    let train_len = cfg.nfft + cfg.ncp;
691    if rbb_cfo.len() < xsync_len + train_len + cfg.nfft + cfg.ncp {
692        return None;
693    }
694
695    let train_start = xsync_len;
696    let train_no_cp = &rbb_cfo[train_start + cfg.ncp..train_start + cfg.ncp + cfg.nfft];
697    let ytrain = fft(train_no_cp);
698    let train_known = known_training_symbols(used_bins.len(), cfg.modulation);
699    let mut hest = equalizer_initial_channel(cfg, &ytrain, &used_bins, &train_known);
700    let mut eq_state = EqualizerTrackingState::default();
701    equalizer_reset_tracking(&mut eq_state);
702
703    let data_start = xsync_len + train_len;
704    let sym_len = cfg.nfft + cfg.ncp;
705    let max_payload_bytes = cfg.packet_payload_bytes + 16;
706    let max_bits = fec_encoded_bits_len(max_payload_bytes * 8, cfg.fec_mode);
707    let max_data_ofdm =
708        max_bits.div_ceil(data_bins.len().max(1) * cfg.modulation.bits_per_symbol()) + 2;
709    let symbol_plan = packet_symbol_plan(max_data_ofdm, cfg);
710    let mut data_symbol_idx = 0usize;
711    let mut rows = Vec::new();
712    for (sym_idx, kind) in symbol_plan.into_iter().enumerate() {
713        let s0 = data_start + sym_idx * sym_len;
714        let s1 = s0 + sym_len;
715        if s1 > rbb_cfo.len() || data_symbol_idx >= 8 {
716            break;
717        }
718        let y = fft(&rbb_cfo[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
719        if kind == PacketSymbolKind::Training {
720            equalizer_refresh_channel(cfg, &mut hest, &y, &used_bins, &train_known);
721            equalizer_reset_tracking(&mut eq_state);
722            continue;
723        }
724        let pref = known_pilot_symbols(pilot_bins.len(), data_symbol_idx + 1);
725        let xeq_used = equalize_symbol_with_pilots(
726            cfg,
727            &mut eq_state,
728            &y,
729            &used_bins,
730            &pilot_bins,
731            &pref,
732            &hest,
733        );
734        let mut pre_eq_used = Vec::with_capacity(used_bins.len());
735        let mut post_eq_used = Vec::with_capacity(used_bins.len());
736        for (k, &bin) in used_bins.iter().enumerate() {
737            pre_eq_used.push(y[bin]);
738            post_eq_used.push(xeq_used[k]);
739        }
740        for (k, &ubin) in used_bins.iter().enumerate() {
741            let role = if pilot_bins.contains(&ubin) {
742                "pilot"
743            } else {
744                "data"
745            };
746            let reference = if role == "pilot" {
747                pilot_bins
748                    .iter()
749                    .position(|b| *b == ubin)
750                    .map(|pos| pref[pos])
751            } else {
752                None
753            };
754            rows.push(PassbandBinDumpRow {
755                data_symbol_idx: data_symbol_idx + 1,
756                used_bin: ubin,
757                role,
758                pre_eq: pre_eq_used[k],
759                post_eq: post_eq_used[k],
760                reference,
761            });
762        }
763        data_symbol_idx += 1;
764    }
765
766    Some(PassbandBinDump { rows })
767}
768
769pub(crate) fn dump_passband_channel_compare_with_sync_impl(
770    payload: &[u8],
771    pkt_audio: &[f32],
772    cfg: &OfdmConfig,
773    sync_off: f32,
774) -> Option<PassbandChannelCompareDump> {
775    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
776    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
777    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
778        return None;
779    }
780
781    let pkt_bytes = build_packet_bytes(payload, 0, 1, cfg);
782    let xbb = tx_one_packet_baseband(&pkt_bytes, cfg);
783    let passband = &pkt_audio[wake_len + guard_len..];
784    let pre = 128usize.min(passband.len());
785    let mut chunk = vec![0.0f32; pre];
786    chunk.extend_from_slice(passband);
787    let rbb_full = downconvert_passband(&chunk, cfg);
788    let rbb = &rbb_full[pre..];
789    let rbb_sync = resample_from_offset(rbb, sync_off);
790    let rbb_cfo = coarse_cfo_correct(&rbb_sync, cfg);
791
792    let (used_bins, pilot_bins, data_bins) = ofdm_bin_plan(cfg);
793    if used_bins.is_empty() || data_bins.is_empty() {
794        return None;
795    }
796    let xsync_len = 2 * cfg.sync_half_len;
797    let train_len = cfg.nfft + cfg.ncp;
798    if rbb_cfo.len() < xsync_len + train_len + cfg.nfft + cfg.ncp
799        || xbb.len() < xsync_len + train_len
800    {
801        return None;
802    }
803
804    let train_start = xsync_len;
805    let train_no_cp = &rbb_cfo[train_start + cfg.ncp..train_start + cfg.ncp + cfg.nfft];
806    let ytrain = fft(train_no_cp);
807    let train_known = known_training_symbols(used_bins.len(), cfg.modulation);
808    let mut hest = equalizer_initial_channel(cfg, &ytrain, &used_bins, &train_known);
809    let mut eq_state = EqualizerTrackingState::default();
810    equalizer_reset_tracking(&mut eq_state);
811
812    let data_start = xsync_len + train_len;
813    let sym_len = cfg.nfft + cfg.ncp;
814    let max_payload_bytes = cfg.packet_payload_bytes + 16;
815    let max_bits = fec_encoded_bits_len(max_payload_bytes * 8, cfg.fec_mode);
816    let max_data_ofdm =
817        max_bits.div_ceil(data_bins.len().max(1) * cfg.modulation.bits_per_symbol()) + 2;
818    let symbol_plan = packet_symbol_plan(max_data_ofdm, cfg);
819    let mut data_symbol_idx = 0usize;
820    let mut rows = Vec::new();
821    for (sym_idx, kind) in symbol_plan.into_iter().enumerate() {
822        let s0 = data_start + sym_idx * sym_len;
823        let s1 = s0 + sym_len;
824        if s1 > rbb_cfo.len() || s1 > xbb.len() || data_symbol_idx >= 8 {
825            break;
826        }
827        let y = fft(&rbb_cfo[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
828        let x = fft(&xbb[s0 + cfg.ncp..s0 + cfg.ncp + cfg.nfft]);
829        if kind == PacketSymbolKind::Training {
830            equalizer_refresh_channel(cfg, &mut hest, &y, &used_bins, &train_known);
831            equalizer_reset_tracking(&mut eq_state);
832            continue;
833        }
834        let pref = known_pilot_symbols(pilot_bins.len(), data_symbol_idx + 1);
835        let xeq_used = equalize_symbol_with_pilots(
836            cfg,
837            &mut eq_state,
838            &y,
839            &used_bins,
840            &pilot_bins,
841            &pref,
842            &hest,
843        );
844        let mut phase_by_bin = vec![0.0f32; used_bins.len()];
845        if !pilot_bins.is_empty() && !pref.is_empty() {
846            let mut pilot_phase_pts = Vec::<(f32, f32)>::new();
847            for (k, pbin) in pilot_bins.iter().enumerate() {
848                if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
849                    let z0 = regularized_equalize(y[*pbin], hest[pos]);
850                    let ref_sym = pref[k];
851                    if ref_sym.norm_sqr() > 1.0e-9 {
852                        pilot_phase_pts.push((*pbin as f32, (z0 * ref_sym.conj()).arg()));
853                    }
854                }
855            }
856            if pilot_phase_pts.len() >= 2 {
857                for i in 1..pilot_phase_pts.len() {
858                    let mut phi = pilot_phase_pts[i].1;
859                    let prev = pilot_phase_pts[i - 1].1;
860                    while phi - prev > std::f32::consts::PI {
861                        phi -= 2.0 * std::f32::consts::PI;
862                    }
863                    while phi - prev < -std::f32::consts::PI {
864                        phi += 2.0 * std::f32::consts::PI;
865                    }
866                    pilot_phase_pts[i].1 = phi;
867                }
868                let n = pilot_phase_pts.len() as f32;
869                let sx = pilot_phase_pts.iter().map(|(x, _)| *x).sum::<f32>();
870                let sy = pilot_phase_pts.iter().map(|(_, y)| *y).sum::<f32>();
871                let sxx = pilot_phase_pts.iter().map(|(x, _)| x * x).sum::<f32>();
872                let sxy = pilot_phase_pts.iter().map(|(x, y)| x * y).sum::<f32>();
873                let denom = n * sxx - sx * sx;
874                if denom.abs() > 1.0e-9 {
875                    let slope = (n * sxy - sx * sy) / denom;
876                    let intercept = (sy - slope * sx) / n;
877                    for (k, &bin) in used_bins.iter().enumerate() {
878                        phase_by_bin[k] = intercept + slope * (bin as f32);
879                    }
880                }
881            }
882        }
883        let mut num = Complex32::new(0.0, 0.0);
884        let mut den = 0.0f32;
885        for (k, pbin) in pilot_bins.iter().enumerate() {
886            if let Some(pos) = used_bins.iter().position(|b| b == pbin) {
887                num += xeq_used[pos] * pref[k].conj();
888                den += pref[k].norm_sqr();
889            }
890        }
891        let g = if den > 1.0e-9 {
892            num / den
893        } else {
894            Complex32::new(1.0, 0.0)
895        };
896        for (k, &ubin) in used_bins.iter().enumerate() {
897            let xref = x[ubin];
898            if xref.norm() <= 1.0e-9 {
899                continue;
900            }
901            let role = if pilot_bins.contains(&ubin) {
902                "pilot"
903            } else {
904                "data"
905            };
906            rows.push(PassbandChannelCompareRow {
907                data_symbol_idx: data_symbol_idx + 1,
908                used_bin: ubin,
909                role,
910                actual_h: y[ubin] / xref,
911                estimated_h_train: hest[k],
912                estimated_h_pilot: hest[k] * Complex32::from_polar(1.0, phase_by_bin[k]) * g,
913            });
914        }
915        data_symbol_idx += 1;
916    }
917
918    Some(PassbandChannelCompareDump { rows })
919}
920
921/// Extracts Schmidl-Cox timing metrics for one passband window.
922///
923/// Parameters:
924/// - `pkt_audio`: passband packet waveform window.
925/// - `cfg`: modem configuration.
926/// Returns:
927/// - `Option<PassbandSyncDump>`: sync metric samples and chosen offsets.
928pub(crate) fn dump_passband_sync_metric_impl(
929    pkt_audio: &[f32],
930    cfg: &OfdmConfig,
931) -> Option<PassbandSyncDump> {
932    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
933    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
934    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
935        return None;
936    }
937
938    let passband = &pkt_audio[wake_len + guard_len..];
939    let pre = 128usize.min(passband.len());
940    let mut chunk = vec![0.0f32; pre];
941    chunk.extend_from_slice(passband);
942    let rbb_full = downconvert_passband(&chunk, cfg);
943    let rbb = &rbb_full[pre..];
944    let metrics = repeated_half_sync_metrics(rbb, cfg);
945    let coarse_sync_off = find_repeated_half_sync_offset(rbb, cfg);
946    let refined_sync_off = refine_sync_offset(rbb, cfg, coarse_sync_off);
947    Some(PassbandSyncDump {
948        coarse_sync_off,
949        refined_sync_off: refined_sync_off.round() as usize,
950        metrics,
951    })
952}
953
954pub(crate) fn dump_passband_iq_chain_impl(
955    pkt_audio: &[f32],
956    cfg: &OfdmConfig,
957) -> Option<PassbandIqChainDump> {
958    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
959    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
960    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
961        return None;
962    }
963
964    let passband = &pkt_audio[wake_len + guard_len..];
965    let pre = 128usize.min(passband.len());
966    let mut chunk = vec![0.0f32; pre];
967    chunk.extend_from_slice(passband);
968    let down_audio = iq_downconvert(&chunk, cfg.fs, cfg.fc, passband_lpf_cutoff_hz(cfg));
969    let baseband = match cfg.passband_mode {
970        PassbandMode::Legacy => down_audio.clone(),
971        PassbandMode::Iq => resample_complex_linear_rate(&down_audio, cfg.fs, cfg.fs_baseband),
972    };
973    let pre_baseband = match cfg.passband_mode {
974        PassbandMode::Legacy => pre,
975        PassbandMode::Iq => ((pre as f32) * (cfg.fs_baseband / cfg.fs)).round() as usize,
976    };
977    Some(PassbandIqChainDump {
978        downconverted_audio_rate: down_audio[pre..].to_vec(),
979        baseband_rate: baseband[pre_baseband.min(baseband.len())..].to_vec(),
980        fs_audio: cfg.fs,
981        fs_baseband: active_baseband_fs(cfg),
982    })
983}
984
985/// Encodes one packet into passband samples (wake + guard + OFDM body).
986///
987/// Parameters:
988/// - `pkt_bytes`: serialized packet bytes.
989/// - `cfg`: modem configuration.
990/// Returns:
991/// - `Vec<f32>`: real passband waveform for one packet.
992fn tx_one_packet(pkt_bytes: &[u8], cfg: &OfdmConfig) -> Vec<f32> {
993    let passband = tx_one_packet_body(pkt_bytes, cfg);
994    let mut wake = make_wake_tone(cfg);
995    normalize_in_place(&mut wake, 0.25);
996    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
997
998    let mut out = Vec::with_capacity(wake.len() + guard_len + passband.len());
999    out.extend_from_slice(&wake);
1000    out.extend(std::iter::repeat(0.0).take(guard_len));
1001    out.extend(passband);
1002    out
1003}
1004
1005fn tx_one_packet_body(pkt_bytes: &[u8], cfg: &OfdmConfig) -> Vec<f32> {
1006    let xbb = tx_one_packet_baseband(pkt_bytes, cfg);
1007    let mut passband = upconvert_passband(&xbb, cfg);
1008    if (cfg.payload_gain - 1.0).abs() > f32::EPSILON {
1009        for s in &mut passband {
1010            *s *= cfg.payload_gain;
1011        }
1012    }
1013    let sync_len = (2 * cfg.sync_half_len).min(passband.len());
1014    if sync_len > 0 {
1015        normalize_in_place(&mut passband[..sync_len], 0.35);
1016    }
1017    if sync_len < passband.len() {
1018        normalize_in_place(&mut passband[sync_len..], 0.96);
1019    }
1020    passband
1021}
1022
1023fn upconvert_passband(xbb: &[Complex32], cfg: &OfdmConfig) -> Vec<f32> {
1024    match cfg.passband_mode {
1025        PassbandMode::Legacy => iq_upconvert(xbb, cfg.fs, cfg.fc),
1026        PassbandMode::Iq => {
1027            let xbb_audio = resample_complex_linear_rate(xbb, cfg.fs_baseband, cfg.fs);
1028            iq_upconvert(&xbb_audio, cfg.fs, cfg.fc)
1029        }
1030    }
1031}
1032
1033fn passband_lpf_cutoff_hz(cfg: &OfdmConfig) -> f32 {
1034    let (used_bins, _, _) = ofdm_bin_plan(cfg);
1035    let max_bin = used_bins.iter().copied().max().unwrap_or(1) as f32;
1036    let bw = ((max_bin + 2.0) * active_baseband_fs(cfg) / cfg.nfft as f32).max(500.0);
1037    bw.min(0.45 * cfg.fs).min(0.45 * active_baseband_fs(cfg))
1038}
1039
1040fn downconvert_passband(pkt_audio: &[f32], cfg: &OfdmConfig) -> Vec<Complex32> {
1041    let mixed = iq_downconvert(pkt_audio, cfg.fs, cfg.fc, passband_lpf_cutoff_hz(cfg));
1042    match cfg.passband_mode {
1043        PassbandMode::Legacy => mixed,
1044        PassbandMode::Iq => resample_complex_linear_rate(&mixed, cfg.fs, cfg.fs_baseband),
1045    }
1046}
1047
1048/// Decodes one passband packet (wake/guard prefixed) into packet metadata.
1049///
1050/// Parameters:
1051/// - `pkt_audio`: real passband packet waveform (wake + guard + body).
1052/// - `cfg`: modem configuration.
1053/// Returns:
1054/// - `Option<PacketInfo>`: parsed packet metadata, or `None` on failure.
1055fn decode_packet_from_passband(pkt_audio: &[f32], cfg: &OfdmConfig) -> Option<PacketInfo> {
1056    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
1057    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
1058    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
1059        return None;
1060    }
1061
1062    let passband = &pkt_audio[wake_len + guard_len..];
1063    let pre = 128usize.min(passband.len());
1064    let mut chunk = vec![0.0f32; pre];
1065    chunk.extend_from_slice(passband);
1066    let rbb_full = downconvert_passband(&chunk, cfg);
1067    let rbb = &rbb_full[pre..];
1068    let coarse_sync_off = find_repeated_half_sync_offset(rbb, cfg);
1069    let sync_off = refine_sync_offset(rbb, cfg, coarse_sync_off);
1070    for off in [sync_off, coarse_sync_off as f32] {
1071        let rbb_sync = resample_from_offset(rbb, off);
1072        if let Some(pkt) = decode_packet_from_synced_baseband(&rbb_sync, cfg) {
1073            return Some(pkt);
1074        }
1075    }
1076    None
1077}
1078
1079fn decode_packet_from_passband_with_sync_rate(
1080    pkt_audio: &[f32],
1081    cfg: &OfdmConfig,
1082    sync_off: f32,
1083    time_scale: f32,
1084) -> Option<PacketInfo> {
1085    let wake_len = (cfg.wake_ms * 1e-3 * cfg.fs) as usize;
1086    let guard_len = (cfg.wake_guard_ms * 1e-3 * cfg.fs) as usize;
1087    if pkt_audio.len() <= wake_len + guard_len + cfg.nfft + cfg.ncp {
1088        return None;
1089    }
1090
1091    let passband = &pkt_audio[wake_len + guard_len..];
1092    let pre = 128usize.min(passband.len());
1093    let mut chunk = vec![0.0f32; pre];
1094    chunk.extend_from_slice(passband);
1095    let rbb_full = downconvert_passband(&chunk, cfg);
1096    let rbb = &rbb_full[pre..];
1097    let rbb_sync = resample_from_offset_rate(rbb, sync_off, time_scale);
1098    decode_packet_from_synced_baseband(&rbb_sync, cfg)
1099}
1100
1101fn decode_packet_from_synced_baseband(
1102    rbb_sync: &[Complex32],
1103    cfg: &OfdmConfig,
1104) -> Option<PacketInfo> {
1105    let coarse_cfo_hz = estimate_coarse_cfo_hz(rbb_sync, cfg);
1106    let mut tried = Vec::new();
1107    tried.push(coarse_cfo_hz);
1108    for hz in [-12.0f32, -8.0, -4.0, 4.0, 8.0, 12.0] {
1109        tried.push(coarse_cfo_hz + hz);
1110    }
1111    for cfo_hz in tried {
1112        let rbb_cfo = apply_cfo_hz(rbb_sync, active_baseband_fs(cfg), cfo_hz);
1113        if let Some(pkt) = decode_packet_info_baseband(&rbb_cfo, cfg) {
1114            return Some(pkt);
1115        }
1116    }
1117    None
1118}
1119
1120/// Finds sync start using Schmidl-Cox metric on repeated-half preamble.
1121///
1122/// Parameters:
1123/// - `rbb`: baseband complex samples near packet start.
1124/// - `cfg`: modem configuration.
1125/// Returns:
1126/// - `usize`: estimated sync start offset in samples (relative to `rbb`).
1127/// IQ-upconverts complex baseband to real passband.
1128///
1129/// Parameters:
1130/// - `xbb`: baseband complex samples.
1131/// - `fs`: sample rate (Hz).
1132/// - `fc`: carrier frequency (Hz).
1133/// Returns:
1134/// - `Vec<f32>`: real passband samples.
1135fn iq_upconvert(xbb: &[Complex32], fs: f32, fc: f32) -> Vec<f32> {
1136    xbb.iter()
1137        .enumerate()
1138        .map(|(n, x)| {
1139            let ph = 2.0 * std::f32::consts::PI * fc * (n as f32) / fs;
1140            (x * Complex32::from_polar(1.0, ph)).re
1141        })
1142        .collect()
1143}
1144
1145/// IQ-downconverts real passband to complex baseband and low-pass filters it.
1146///
1147/// Parameters:
1148/// - `y`: real passband samples.
1149/// - `fs`: sample rate (Hz).
1150/// - `fc`: carrier frequency (Hz).
1151/// - `cutoff_hz`: low-pass cutoff (Hz).
1152/// Returns:
1153/// - `Vec<Complex32>`: filtered complex baseband samples.
1154fn iq_downconvert(y: &[f32], fs: f32, fc: f32, cutoff_hz: f32) -> Vec<Complex32> {
1155    let mixed: Vec<Complex32> = y
1156        .iter()
1157        .enumerate()
1158        .map(|(n, &s)| {
1159            let ph = -2.0 * std::f32::consts::PI * fc * (n as f32) / fs;
1160            Complex32::new(s, 0.0) * Complex32::from_polar(1.0, ph)
1161        })
1162        .collect();
1163    let mut out = lowpass_fir(&mixed, fs, cutoff_hz.max(100.0), 97);
1164    for z in &mut out {
1165        *z *= 2.0;
1166    }
1167    out
1168}
1169
1170/// Scales samples so max absolute value equals `target_peak`.
1171///
1172/// Parameters:
1173/// - `x`: samples modified in place.
1174/// - `target_peak`: desired max absolute amplitude.
1175/// Returns:
1176/// - none.
1177fn normalize_in_place(x: &mut [f32], target_peak: f32) {
1178    let peak = x
1179        .iter()
1180        .fold(0.0f32, |a, &b| if b.abs() > a { b.abs() } else { a });
1181    if peak > 0.0 {
1182        let g = target_peak / peak;
1183        for v in x {
1184            *v *= g;
1185        }
1186    }
1187}
1188
1189/// Applies FIR low-pass filtering to complex samples.
1190///
1191/// Parameters:
1192/// - `x`: input complex samples.
1193/// - `fs`: sample rate (Hz).
1194/// - `cutoff_hz`: cutoff frequency (Hz).
1195/// - `len`: FIR length (taps).
1196/// Returns:
1197/// - `Vec<Complex32>`: filtered samples.
1198fn lowpass_fir(x: &[Complex32], fs: f32, cutoff_hz: f32, len: usize) -> Vec<Complex32> {
1199    let h = fir_lowpass(len, cutoff_hz, fs);
1200    let mut y = vec![Complex32::new(0.0, 0.0); x.len()];
1201    for n in 0..x.len() {
1202        let mut acc = Complex32::new(0.0, 0.0);
1203        let kmax = (n + 1).min(h.len());
1204        for k in 0..kmax {
1205            acc += x[n - k] * h[k];
1206        }
1207        y[n] = acc;
1208    }
1209    // Group delay compensation (match Octave behavior).
1210    let gd = (len - 1) / 2;
1211    let mut out = Vec::with_capacity(y.len());
1212    out.extend_from_slice(&y[gd..]);
1213    out.extend(std::iter::repeat(Complex32::new(0.0, 0.0)).take(gd));
1214    out
1215}
1216
1217/// Designs a windowed-sinc low-pass FIR.
1218///
1219/// Parameters:
1220/// - `len`: FIR length (taps).
1221/// - `cutoff_hz`: cutoff frequency (Hz).
1222/// - `fs`: sample rate (Hz).
1223/// Returns:
1224/// - `Vec<f32>`: FIR coefficients.
1225fn fir_lowpass(len: usize, cutoff_hz: f32, fs: f32) -> Vec<f32> {
1226    let m = (len - 1) as f32 / 2.0;
1227    let mut h = vec![0.0f32; len];
1228    for (i, hi) in h.iter_mut().enumerate() {
1229        let n = i as f32 - m;
1230        let sinc = if n.abs() < 1e-6 {
1231            2.0 * cutoff_hz / fs
1232        } else {
1233            let x = 2.0 * std::f32::consts::PI * cutoff_hz * n / fs;
1234            (2.0 * cutoff_hz / fs) * (x.sin() / x)
1235        };
1236        let w = 0.54 - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / (len as f32 - 1.0)).cos();
1237        *hi = sinc * w;
1238    }
1239    let sum: f32 = h.iter().sum();
1240    if sum != 0.0 {
1241        for v in &mut h {
1242            *v /= sum;
1243        }
1244    }
1245    h
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use crate::config::Modulation;
1252    use crate::equalizer::hard_slice_symbols;
1253
1254    #[test]
1255    /// Verifies full BPSK oracle burst round-trip.
1256    fn round_trip_oracle_burst() {
1257        let cfg = OfdmConfig::default();
1258        let payload = (0..80u8).collect::<Vec<_>>();
1259        let tx = encode_payload(&payload, &cfg);
1260        let rx = decode_encoded_burst_oracle(&tx, &cfg).expect("decode failed");
1261        assert_eq!(rx, payload);
1262    }
1263
1264    #[test]
1265    /// Verifies full QPSK oracle burst round-trip.
1266    fn round_trip_oracle_burst_qpsk() {
1267        let mut cfg = OfdmConfig::default();
1268        cfg.modulation = Modulation::Qpsk;
1269        let payload = (0..96u8).collect::<Vec<_>>();
1270        let tx = encode_payload(&payload, &cfg);
1271        let rx = decode_encoded_burst_oracle(&tx, &cfg).expect("decode failed");
1272        assert_eq!(rx, payload);
1273    }
1274
1275    #[test]
1276    /// Verifies QPSK oracle burst round-trip with pilots explicitly disabled.
1277    fn round_trip_oracle_burst_qpsk_no_pilots() {
1278        let mut cfg = OfdmConfig::default();
1279        cfg.modulation = Modulation::Qpsk;
1280        cfg.use_pilots = Some(false);
1281        let payload = (0..80u8).collect::<Vec<_>>();
1282        let tx = encode_payload(&payload, &cfg);
1283        let rx = decode_encoded_burst_oracle(&tx, &cfg).expect("decode failed");
1284        assert_eq!(rx, payload);
1285    }
1286
1287    #[test]
1288    /// Verifies passband packet encode/decode path.
1289    fn decode_single_packet_passband_test() {
1290        let cfg = OfdmConfig::default();
1291        let payload: Vec<u8> = (40..64).collect();
1292        let y = encode_single_packet_passband(&payload, &cfg);
1293        let out =
1294            decode_single_packet_passband_with_sync(&y, &cfg, 0.0).expect("passband decode failed");
1295        assert_eq!(out, payload);
1296    }
1297
1298    #[test]
1299    /// Verifies BPSK/QPSK hard slicer output points.
1300    fn hard_slice_bpsk_and_qpsk() {
1301        let bpsk = vec![Complex32::new(0.3, 0.0), Complex32::new(-0.2, 1.0)];
1302        let bpsk_s = hard_slice_symbols(&bpsk, Modulation::Bpsk);
1303        assert_eq!(bpsk_s[0], Complex32::new(1.0, 0.0));
1304        assert_eq!(bpsk_s[1], Complex32::new(-1.0, 0.0));
1305
1306        let qpsk = vec![
1307            Complex32::new(0.1, 0.2),
1308            Complex32::new(0.1, -0.2),
1309            Complex32::new(-0.1, 0.2),
1310            Complex32::new(-0.1, -0.2),
1311        ];
1312        let qpsk_s = hard_slice_symbols(&qpsk, Modulation::Qpsk);
1313        let k = 1.0 / 2.0f32.sqrt();
1314        assert_eq!(qpsk_s[0], Complex32::new(1.0 * k, 1.0 * k));
1315        assert_eq!(qpsk_s[1], Complex32::new(1.0 * k, -1.0 * k));
1316        assert_eq!(qpsk_s[2], Complex32::new(-1.0 * k, 1.0 * k));
1317        assert_eq!(qpsk_s[3], Complex32::new(-1.0 * k, -1.0 * k));
1318    }
1319}
1320
1321// vim: set ts=4 sw=4 et: