Skip to content

Commit 5da357d

Browse files
committed
Harden OFDM control path and disconnect handling
1 parent b44e861 commit 5da357d

15 files changed

Lines changed: 258 additions & 162 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include "types.hpp"
4+
#include <algorithm>
5+
6+
namespace ultra {
7+
namespace ofdm_link_adaptation {
8+
9+
inline bool isCoherentModulation(Modulation mod) {
10+
switch (mod) {
11+
case Modulation::BPSK:
12+
case Modulation::QPSK:
13+
case Modulation::QAM8:
14+
case Modulation::QAM16:
15+
case Modulation::QAM32:
16+
case Modulation::QAM64:
17+
case Modulation::QAM256:
18+
return true;
19+
default:
20+
return false;
21+
}
22+
}
23+
24+
// Deterministic pilot profile (must be identical on TX and RX).
25+
// This intentionally uses only signaled mode/rate to avoid cross-station mismatch.
26+
inline int recommendedPilotSpacing(Modulation mod, CodeRate rate) {
27+
const bool coherent = isCoherentModulation(mod);
28+
29+
if (coherent) {
30+
switch (rate) {
31+
case CodeRate::R3_4: return 8;
32+
case CodeRate::R2_3:
33+
case CodeRate::R1_2:
34+
case CodeRate::R1_4:
35+
case CodeRate::R1_3:
36+
default:
37+
return 5;
38+
}
39+
}
40+
41+
// Differential modes: keep DQPSK/DBPSK profile, densify D8PSK.
42+
if (mod == Modulation::D8PSK) {
43+
switch (rate) {
44+
case CodeRate::R3_4: return 8;
45+
case CodeRate::R2_3:
46+
case CodeRate::R1_2: return 8;
47+
case CodeRate::R1_4:
48+
case CodeRate::R1_3:
49+
default:
50+
return 10;
51+
}
52+
}
53+
54+
switch (rate) {
55+
case CodeRate::R3_4: return 15;
56+
case CodeRate::R2_3:
57+
case CodeRate::R1_2:
58+
case CodeRate::R1_4:
59+
case CodeRate::R1_3:
60+
default:
61+
return 10;
62+
}
63+
}
64+
65+
inline int pilotCount(int total_carriers, int pilot_spacing) {
66+
if (pilot_spacing <= 0 || total_carriers <= 0) return 0;
67+
return (total_carriers + pilot_spacing - 1) / pilot_spacing;
68+
}
69+
70+
// Experimental burst interleaver group sizing helper.
71+
inline int recommendedBurstGroupSize(Modulation mod, CodeRate rate, float fading_index = 0.0f) {
72+
if (mod == Modulation::D8PSK &&
73+
(rate == CodeRate::R1_2 || rate == CodeRate::R2_3 || rate == CodeRate::R3_4)) {
74+
if (fading_index >= 0.45f) return 6;
75+
return 4;
76+
}
77+
return 4;
78+
}
79+
80+
inline int sanitizeBurstGroupSize(int value) {
81+
return std::clamp(value, 2, 8);
82+
}
83+
84+
} // namespace ofdm_link_adaptation
85+
} // namespace ultra
86+

src/gui/modem/modem_mode.cpp

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include "modem_engine.hpp"
44
#include "ultra/logging.hpp"
5+
#include "ultra/ofdm_link_adaptation.hpp"
56
#include "protocol/frame_v2.hpp"
67
#include <cstdio>
78

@@ -127,15 +128,8 @@ void ModemEngine::setConnected(bool connected) {
127128
config_.code_rate = data_code_rate_;
128129
config_.use_pilots = true; // Always — must match OFDMChirpWaveform
129130

130-
// Pilot spacing based on code rate (matching OFDMChirpWaveform)
131-
switch (data_code_rate_) {
132-
case CodeRate::R3_4:
133-
config_.pilot_spacing = 15;
134-
break;
135-
default:
136-
config_.pilot_spacing = 10;
137-
break;
138-
}
131+
config_.pilot_spacing =
132+
ofdm_link_adaptation::recommendedPilotSpacing(data_modulation_, data_code_rate_);
139133

140134
decoder_->setRate(data_code_rate_);
141135

@@ -215,18 +209,8 @@ void ModemEngine::setDataMode(Modulation mod, CodeRate rate) {
215209
config_.code_rate = rate;
216210
config_.use_pilots = true; // Always — must match OFDMChirpWaveform
217211

218-
// Configure pilot spacing based on code rate (matching OFDMChirpWaveform)
219-
switch (rate) {
220-
case CodeRate::R3_4:
221-
config_.pilot_spacing = 15; // ~4 pilots
222-
break;
223-
case CodeRate::R2_3:
224-
case CodeRate::R1_2:
225-
case CodeRate::R1_4:
226-
default:
227-
config_.pilot_spacing = 10; // ~6 pilots
228-
break;
229-
}
212+
config_.pilot_spacing =
213+
ofdm_link_adaptation::recommendedPilotSpacing(mod, rate);
230214

231215
// If already connected, update both TX and RX to match
232216
if (connected_) {

src/gui/modem/streaming_decoder.cpp

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ StreamingDecoder::~StreamingDecoder() {
138138
stop();
139139
}
140140

141+
void StreamingDecoder::setBurstInterleaveGroupSize(int size) {
142+
burst_group_size_ = std::clamp(size, 2, 8);
143+
}
144+
141145
// ============================================================================
142146
// AUDIO THREAD - Just buffer samples, nothing else
143147
// ============================================================================
@@ -1975,16 +1979,20 @@ DecodeResult StreamingDecoder::decodeFrame(const std::vector<float>& soft_bits,
19751979
// ============================================================================
19761980

19771981
void StreamingDecoder::accumulateBurstFrames() {
1982+
const int burst_group_size = std::max(2, burst_group_size_);
1983+
const int burst_timeout_ms =
1984+
static_cast<int>(BURST_TIMEOUT_MS_BASE * (burst_group_size / 4.0f));
1985+
19781986
// Timeout check
19791987
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
19801988
std::chrono::steady_clock::now() - burst_start_time_).count();
1981-
if (elapsed > BURST_TIMEOUT_MS) {
1989+
if (elapsed > burst_timeout_ms) {
19821990
LOG_MODEM(WARN, "[%s] Burst group timeout: got %zu/%d frames",
1983-
log_prefix_.c_str(), burst_soft_buffer_.size(), BURST_GROUP_SIZE);
1991+
log_prefix_.c_str(), burst_soft_buffer_.size(), burst_group_size);
19841992
// Discard — TX used 4-frame interleaving, partial is undecodable
19851993
{
19861994
std::lock_guard<std::mutex> slock(stats_mutex_);
1987-
stats_.frames_failed += BURST_GROUP_SIZE;
1995+
stats_.frames_failed += burst_group_size;
19881996
}
19891997
burst_soft_buffer_.clear();
19901998
{
@@ -2001,10 +2009,10 @@ void StreamingDecoder::accumulateBurstFrames() {
20012009
if (result == BurstFrameResult::FAILED) {
20022010
// Hard failure (energy lost or process error) — abort immediately
20032011
LOG_MODEM(WARN, "[%s] Burst group aborted: hard failure at frame %zu/%d",
2004-
log_prefix_.c_str(), burst_soft_buffer_.size() + 1, BURST_GROUP_SIZE);
2012+
log_prefix_.c_str(), burst_soft_buffer_.size() + 1, burst_group_size);
20052013
{
20062014
std::lock_guard<std::mutex> slock(stats_mutex_);
2007-
stats_.frames_failed += BURST_GROUP_SIZE;
2015+
stats_.frames_failed += burst_group_size;
20082016
}
20092017
burst_soft_buffer_.clear();
20102018
{
@@ -2020,7 +2028,7 @@ void StreamingDecoder::accumulateBurstFrames() {
20202028
}
20212029

20222030
// SUCCESS — check if group complete
2023-
if (static_cast<int>(burst_soft_buffer_.size()) == BURST_GROUP_SIZE) {
2031+
if (static_cast<int>(burst_soft_buffer_.size()) == burst_group_size) {
20242032
finalizeBurstGroup();
20252033
burst_soft_buffer_.clear();
20262034
{
@@ -2033,6 +2041,8 @@ void StreamingDecoder::accumulateBurstFrames() {
20332041
}
20342042

20352043
StreamingDecoder::BurstFrameResult StreamingDecoder::tryDemodulateNextBurstFrame() {
2044+
const int burst_group_size = std::max(2, burst_group_size_);
2045+
20362046
// Check available samples at burst_next_pos_
20372047
size_t next_available;
20382048
{
@@ -2071,7 +2081,7 @@ StreamingDecoder::BurstFrameResult StreamingDecoder::tryDemodulateNextBurstFrame
20712081
if (next_rms < BURST_ENERGY_THRESHOLD) {
20722082
LOG_MODEM(WARN, "[%s] Burst frame %zu/%d: energy lost (RMS=%.4f)",
20732083
log_prefix_.c_str(), burst_soft_buffer_.size() + 1,
2074-
BURST_GROUP_SIZE, next_rms);
2084+
burst_group_size, next_rms);
20752085
burst_next_pos_ = (burst_next_pos_ + burst_min_block_) % MAX_BUFFER_SAMPLES;
20762086
return BurstFrameResult::FAILED;
20772087
}
@@ -2081,7 +2091,7 @@ StreamingDecoder::BurstFrameResult StreamingDecoder::tryDemodulateNextBurstFrame
20812091
bool ok = waveform_->process(SampleSpan(block.data(), block.size()));
20822092
if (!ok) {
20832093
LOG_MODEM(WARN, "[%s] Burst frame %zu/%d: process() failed",
2084-
log_prefix_.c_str(), burst_soft_buffer_.size() + 1, BURST_GROUP_SIZE);
2094+
log_prefix_.c_str(), burst_soft_buffer_.size() + 1, burst_group_size);
20852095
burst_next_pos_ = (burst_next_pos_ + burst_min_block_) % MAX_BUFFER_SAMPLES;
20862096
return BurstFrameResult::FAILED;
20872097
}
@@ -2109,17 +2119,18 @@ StreamingDecoder::BurstFrameResult StreamingDecoder::tryDemodulateNextBurstFrame
21092119

21102120
LOG_MODEM(INFO, "[%s] Burst frame %zu/%d demodulated, RMS=%.4f",
21112121
log_prefix_.c_str(), burst_soft_buffer_.size(),
2112-
BURST_GROUP_SIZE, next_rms);
2122+
burst_group_size, next_rms);
21132123
return BurstFrameResult::SUCCESS;
21142124
}
21152125

21162126
void StreamingDecoder::finalizeBurstGroup() {
2127+
const int burst_group_size = std::max(2, burst_group_size_);
21172128
LOG_MODEM(INFO, "[%s] Burst group complete (%d frames), deinterleaving...",
2118-
log_prefix_.c_str(), BURST_GROUP_SIZE);
2129+
log_prefix_.c_str(), burst_group_size);
21192130

21202131
auto logical_soft = fec::BurstInterleaver::deinterleave(burst_soft_buffer_);
21212132

2122-
for (int i = 0; i < BURST_GROUP_SIZE; i++) {
2133+
for (int i = 0; i < burst_group_size; i++) {
21232134
DecodeResult result = decodeFrame(logical_soft[i], burst_snr_, burst_cfo_);
21242135

21252136
{
@@ -2137,7 +2148,7 @@ void StreamingDecoder::finalizeBurstGroup() {
21372148
}
21382149

21392150
LOG_MODEM(INFO, "[%s] Burst logical frame %d/%d: %s (%d/%d CWs)",
2140-
log_prefix_.c_str(), i + 1, BURST_GROUP_SIZE,
2151+
log_prefix_.c_str(), i + 1, burst_group_size,
21412152
result.success ? "OK" : "FAIL",
21422153
result.codewords_ok, result.codewords_ok + result.codewords_failed);
21432154
}

src/gui/modem/streaming_decoder.hpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,11 @@ class StreamingDecoder {
148148
void setChannelInterleave(bool enable) { use_channel_interleave_ = enable; }
149149
bool getChannelInterleave() const { return use_channel_interleave_; }
150150

151-
// Burst-level long interleaver (4-frame groups)
151+
// Burst-level long interleaver (N-frame groups)
152152
void setBurstInterleave(bool enable) { use_burst_interleave_ = enable; }
153153
bool getBurstInterleave() const { return use_burst_interleave_; }
154+
void setBurstInterleaveGroupSize(int size);
155+
int getBurstInterleaveGroupSize() const { return burst_group_size_; }
154156

155157
// Get current mode
156158
protocol::WaveformMode getMode() const { return mode_; }
@@ -366,8 +368,8 @@ class StreamingDecoder {
366368
float burst_snr_ = 0.0f; // SNR from sync detection
367369
float burst_cfo_ = 0.0f; // CFO (updated per frame from pilot tracking)
368370
std::chrono::steady_clock::time_point burst_start_time_; // timeout reference
369-
static constexpr int BURST_GROUP_SIZE = 4;
370-
static constexpr int BURST_TIMEOUT_MS = 8000; // 4 frames × ~0.7s + margin
371+
int burst_group_size_ = 4;
372+
static constexpr int BURST_TIMEOUT_MS_BASE = 8000; // 4 frames × ~0.7s + margin
371373

372374
// Pending frame state for multi-codeword frames
373375
// After reading header, if more codewords needed, wait for more samples

src/gui/modem/streaming_encoder.cpp

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ StreamingEncoder::StreamingEncoder() {
5454

5555
StreamingEncoder::~StreamingEncoder() = default;
5656

57+
void StreamingEncoder::setBurstInterleaveGroupSize(int size) {
58+
burst_group_size_ = std::clamp(size, 2, 8);
59+
}
60+
5761
// ============================================================================
5862
// MODE CONTROL
5963
// ============================================================================
@@ -241,9 +245,9 @@ std::vector<float> StreamingEncoder::encodeBurstLight(const std::vector<Bytes>&
241245
encoded_frames.push_back(encodeFrameBytes(fd));
242246
}
243247

244-
// Phase 2: Group into 4-frame subgroups, burst-interleave each group
248+
// Phase 2: Group into N-frame subgroups, burst-interleave each group
245249
// Track which groups are burst-interleaved (for LTS marker)
246-
constexpr int BURST_GROUP_SIZE = 4;
250+
const int BURST_GROUP_SIZE = std::max(2, burst_group_size_);
247251
std::vector<bool> frame_is_group_start(encoded_frames.size(), false);
248252

249253
if (use_burst_interleave_) {
@@ -542,9 +546,8 @@ Bytes StreamingEncoder::encodeFrameBytes(const Bytes& frame_data) {
542546
}
543547

544548
// OFDM: Check if this is a control frame or data/connect frame
545-
// Control frames (ACK, NACK, MODE_CHANGE, etc.) are 20 bytes = 1 CW, no interleaving
546-
// Connect frames (DISCONNECT) in OFDM mode use 4-CW frame interleaving for fading protection
547-
// (CONNECT/CONNECT_ACK are always MC-DPSK, so only DISCONNECT reaches here)
549+
// Control frames (ACK, NACK, MODE_CHANGE, DISCONNECT, etc.) are 20 bytes = 1 CW, no interleaving
550+
// Connect handshake frames are always MC-DPSK and do not reach this path.
548551
// Data frames use 4-CW fixed frame encoding with frame interleaving
549552
bool is_variable_cw_frame = false;
550553
if (tx_data.size() >= 3) {
@@ -553,12 +556,12 @@ Bytes StreamingEncoder::encodeFrameBytes(const Bytes& frame_data) {
553556
if (v2::isControlFrame(ft)) {
554557
is_variable_cw_frame = true;
555558
}
556-
// Connect frames (DISCONNECT) go through encodeFixedFrame() for 4-CW interleaving
559+
// Non-control frames go through encodeFixedFrame() for 4-CW interleaving
557560
}
558561

559562
if (is_variable_cw_frame) {
560563
// Variable-CW encoding (no frame interleaving needed)
561-
// Control frames = 1 CW, Connect frames = 2 CWs at R1/2
564+
// Control frames = 1 CW
562565
// Control frames always use R1/4: exact fit (20 bytes = 162 info bits / 8)
563566
// and maximum LDPC redundancy for fading resilience
564567
auto cws = v2::encodeFrameWithLDPC(tx_data, CodeRate::R1_4);
@@ -569,7 +572,9 @@ Bytes StreamingEncoder::encodeFrameBytes(const Bytes& frame_data) {
569572
// encoding may produce fewer CWs (e.g. 2 at R1/2 for 44-byte ConnectFrame)
570573
// ControlFrames (20 bytes) don't have total_cw field — parseHeader() returns 1
571574
auto ft = static_cast<v2::FrameType>(tx_data[2]);
572-
if (v2::isConnectFrame(ft) && tx_data.size() > 16 && tx_data[12] != actual_cw) {
575+
if (v2::isConnectFrame(ft) &&
576+
tx_data.size() > v2::ControlFrame::SIZE &&
577+
tx_data[12] != actual_cw) {
573578
LOG_MODEM(INFO, "[%s] OFDM: Patching ConnectFrame total_cw %d -> %d",
574579
log_prefix_.c_str(), tx_data[12], actual_cw);
575580
tx_data[12] = actual_cw;

src/gui/modem/streaming_encoder.hpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ class StreamingEncoder {
123123
// Frame interleaving is always enabled for OFDM
124124
bool getFrameInterleave() const { return use_frame_interleave_; }
125125

126-
// Burst-level long interleaver (spreads CW bytes across 4-frame groups)
126+
// Burst-level long interleaver (spreads CW bytes across N-frame groups)
127127
void setBurstInterleave(bool enable) { use_burst_interleave_ = enable; }
128128
bool getBurstInterleave() const { return use_burst_interleave_; }
129+
void setBurstInterleaveGroupSize(int size);
130+
int getBurstInterleaveGroupSize() const { return burst_group_size_; }
129131

130132
private:
131133
// ========================================================================
@@ -165,7 +167,8 @@ class StreamingEncoder {
165167
std::unique_ptr<ChannelInterleaver> channel_interleaver_;
166168
bool use_channel_interleave_ = true;
167169
bool use_frame_interleave_ = true; // Always on for OFDM
168-
bool use_burst_interleave_ = false; // Burst-level long interleaver (4-frame groups)
170+
bool use_burst_interleave_ = false; // Burst-level long interleaver (N-frame groups)
171+
int burst_group_size_ = 4;
169172

170173
// Logging
171174
std::string log_prefix_ = "StreamingEncoder";

src/main.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ int runProtocolTx(const char* message, const char* output_file,
139139
samples = modem.transmit(frame.serialize());
140140

141141
} else if (strcmp(message, "disconnect") == 0) {
142-
// DISCONNECT frame with full callsigns
142+
// DISCONNECT control frame (hardened 1-CW profile)
143143
frame_type = "DISCONNECT";
144-
auto frame = v2::ConnectFrame::makeDisconnect(src_call, dst_call);
144+
auto frame = v2::ControlFrame::makeDisconnect(src_call, dst_call);
145145
samples = modem.transmit(frame.serialize());
146146

147147
} else {

0 commit comments

Comments
 (0)