Skip to content

Commit 96e51c1

Browse files
committed
Improve RX decoder resilience, diagnostics, and threaded GUI decode
1 parent c508b30 commit 96e51c1

File tree

7 files changed

+252
-75
lines changed

7 files changed

+252
-75
lines changed

src/gui/app.cpp

Lines changed: 115 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ static void guiLog(const char* fmt, ...) {
138138
fflush(g_gui_log_file);
139139
}
140140

141+
static std::string trimF32Extension(const std::string& path) {
142+
if (path.size() >= 4 && path.substr(path.size() - 4) == ".f32") {
143+
return path.substr(0, path.size() - 4);
144+
}
145+
return path;
146+
}
147+
148+
static bool writeF32File(const std::string& path, const std::vector<float>& samples) {
149+
if (samples.empty()) {
150+
return false;
151+
}
152+
std::ofstream file(path, std::ios::binary);
153+
if (!file.is_open()) {
154+
return false;
155+
}
156+
file.write(reinterpret_cast<const char*>(samples.data()), samples.size() * sizeof(float));
157+
return file.good();
158+
}
159+
141160
// Convert fading index to channel quality string
142161
// Thresholds aligned with waveform_selection.hpp (2026-02-03)
143162
// Combined index = freq_cv + temporal_cv (includes Doppler spread measurement)
@@ -238,6 +257,10 @@ App::App(const Options& opts) : options_(opts), sim_ui_visible_(opts.enable_sim)
238257
ultra::gui::startupTrace("App", "gui-log-enter");
239258
guiLog("=== GUI Started ===");
240259
ultra::gui::startupTrace("App", "gui-log-exit");
260+
if (options_.record_audio) {
261+
recording_enabled_ = true;
262+
guiLog("Recording enabled (-rec): base path '%s'", options_.record_path.c_str());
263+
}
241264
// Load persistent settings
242265
ultra::gui::startupTrace("App", "settings-load-enter");
243266
settings_.load();
@@ -247,9 +270,8 @@ App::App(const Options& opts) : options_(opts), sim_ui_visible_(opts.enable_sim)
247270
config_ = presets::balanced();
248271
ultra::gui::startupTrace("App", "presets-balanced-exit");
249272

250-
// Run modem decode synchronously in GUI thread to avoid startup-time
251-
// worker-thread races on fragile Windows systems.
252-
modem_.setSynchronousMode(true);
273+
// Use dedicated RX decode thread by default.
274+
modem_.setSynchronousMode(false);
253275

254276
if (!options_.disable_waterfall) {
255277
ultra::gui::startupTrace("App", "waterfall-create-begin");
@@ -793,22 +815,57 @@ App::~App() {
793815
audio_.shutdown();
794816

795817
// Write recording to file if -rec was enabled
796-
if (options_.record_audio && !recorded_samples_.empty()) {
818+
if (options_.record_audio) {
797819
writeRecordingToFile();
798820
}
799821
}
800822

801823
void App::writeRecordingToFile() {
802-
std::ofstream file(options_.record_path, std::ios::binary);
803-
if (file.is_open()) {
804-
file.write(reinterpret_cast<const char*>(recorded_samples_.data()),
805-
recorded_samples_.size() * sizeof(float));
806-
guiLog("Recording saved: %s (%zu samples, %.1f seconds)",
807-
options_.record_path.c_str(),
808-
recorded_samples_.size(),
809-
recorded_samples_.size() / 48000.0f);
810-
} else {
811-
guiLog("ERROR: Failed to save recording to %s", options_.record_path.c_str());
824+
const std::string base = trimF32Extension(options_.record_path);
825+
bool wrote_any = false;
826+
827+
if (!recorded_tx_samples_.empty()) {
828+
const std::string path = base + "_tx.f32";
829+
if (writeF32File(path, recorded_tx_samples_)) {
830+
guiLog("Recording saved: %s (%zu samples, %.1f seconds)",
831+
path.c_str(),
832+
recorded_tx_samples_.size(),
833+
recorded_tx_samples_.size() / 48000.0f);
834+
wrote_any = true;
835+
} else {
836+
guiLog("ERROR: Failed to save TX recording to %s", path.c_str());
837+
}
838+
}
839+
840+
if (!recorded_rx_samples_.empty()) {
841+
const std::string path = base + "_rx.f32";
842+
if (writeF32File(path, recorded_rx_samples_)) {
843+
guiLog("Recording saved: %s (%zu samples, %.1f seconds)",
844+
path.c_str(),
845+
recorded_rx_samples_.size(),
846+
recorded_rx_samples_.size() / 48000.0f);
847+
wrote_any = true;
848+
} else {
849+
guiLog("ERROR: Failed to save RX recording to %s", path.c_str());
850+
}
851+
}
852+
853+
// Backward-compatible simulation capture file.
854+
if (!recorded_samples_.empty()) {
855+
const std::string path = base + "_sim.f32";
856+
if (writeF32File(path, recorded_samples_)) {
857+
guiLog("Recording saved: %s (%zu samples, %.1f seconds)",
858+
path.c_str(),
859+
recorded_samples_.size(),
860+
recorded_samples_.size() / 48000.0f);
861+
wrote_any = true;
862+
} else {
863+
guiLog("ERROR: Failed to save simulation recording to %s", path.c_str());
864+
}
865+
}
866+
867+
if (!wrote_any) {
868+
guiLog("Recording skipped: no captured samples");
812869
}
813870
}
814871

@@ -819,7 +876,7 @@ void App::initVirtualStation() {
819876

820877
// Create virtual station's modem
821878
virtual_modem_ = std::make_unique<ModemEngine>();
822-
virtual_modem_->setSynchronousMode(true);
879+
virtual_modem_->setSynchronousMode(false);
823880

824881
// Set up virtual station's protocol
825882
virtual_protocol_.setLocalCallsign(virtual_callsign_);
@@ -1086,11 +1143,9 @@ void App::startSimulator() {
10861143

10871144
guiLog("SIM: Starting simulator");
10881145

1089-
// Switch both modems to synchronous mode — sim loop drives decode directly,
1090-
// no separate decode thread. This prevents buffer overflows during LDPC decode
1091-
// (same model as cli_simulator: feed + process in lockstep).
1092-
modem_.setSynchronousMode(true);
1093-
virtual_modem_->setSynchronousMode(true);
1146+
// Keep both modems in asynchronous mode for consistent threaded behavior.
1147+
modem_.setSynchronousMode(false);
1148+
virtual_modem_->setSynchronousMode(false);
10941149

10951150
sim_thread_running_ = true;
10961151
sim_thread_ = std::thread(&App::simulationLoop, this);
@@ -1104,19 +1159,11 @@ void App::stopSimulator() {
11041159

11051160
if (sim_thread_.joinable()) sim_thread_.join();
11061161

1107-
// Keep synchronous decode mode on Windows for startup/runtime stability.
1108-
#ifdef _WIN32
1109-
modem_.setSynchronousMode(true);
1110-
if (virtual_modem_) {
1111-
virtual_modem_->setSynchronousMode(true);
1112-
}
1113-
#else
1114-
// Restore async decode mode for real audio operation
1162+
// Restore async decode mode for real audio operation.
11151163
modem_.setSynchronousMode(false);
11161164
if (virtual_modem_) {
11171165
virtual_modem_->setSynchronousMode(false);
11181166
}
1119-
#endif
11201167

11211168
// Clear buffers
11221169
{
@@ -1192,7 +1239,9 @@ void App::simulationLoop() {
11921239
a_to_b_active = true;
11931240
size_t to_feed = std::min(SAMPLES_PER_TICK, our_channel_buffer.size());
11941241
virtual_modem_->feedAudio(our_channel_buffer.data(), to_feed);
1195-
virtual_modem_->processRxBuffer();
1242+
if (virtual_modem_->isSynchronousMode()) {
1243+
virtual_modem_->processRxBuffer();
1244+
}
11961245
our_channel_buffer.erase(our_channel_buffer.begin(), our_channel_buffer.begin() + to_feed);
11971246
}
11981247

@@ -1218,7 +1267,9 @@ void App::simulationLoop() {
12181267
b_to_a_active = true;
12191268
size_t to_feed = std::min(SAMPLES_PER_TICK, virtual_channel_buffer.size());
12201269
modem_.feedAudio(virtual_channel_buffer.data(), to_feed);
1221-
modem_.processRxBuffer();
1270+
if (modem_.isSynchronousMode()) {
1271+
modem_.processRxBuffer();
1272+
}
12221273
virtual_channel_buffer.erase(virtual_channel_buffer.begin(), virtual_channel_buffer.begin() + to_feed);
12231274
}
12241275

@@ -1249,14 +1300,18 @@ void App::simulationLoop() {
12491300
std::vector<float> silence(IDLE_SAMPLES_PER_TICK, 0.0f);
12501301
auto noise = applyChannelEffects(silence, 0);
12511302
virtual_modem_->feedAudio(noise);
1252-
virtual_modem_->processRxBuffer();
1303+
if (virtual_modem_->isSynchronousMode()) {
1304+
virtual_modem_->processRxBuffer();
1305+
}
12531306
}
12541307
if (!b_to_a_active) {
12551308
// B→A channel idle: evolve fading and feed noise to our modem
12561309
std::vector<float> silence(IDLE_SAMPLES_PER_TICK, 0.0f);
12571310
auto noise = applyChannelEffects(silence, 1);
12581311
modem_.feedAudio(noise);
1259-
modem_.processRxBuffer();
1312+
if (modem_.isSynchronousMode()) {
1313+
modem_.processRxBuffer();
1314+
}
12601315
}
12611316
}
12621317

@@ -1466,14 +1521,6 @@ void App::render() {
14661521
ultra::gui::startupTrace("App", "render-set-output-gain-exit");
14671522
}
14681523

1469-
#ifdef _WIN32
1470-
// Keep decode in synchronous mode on fragile Win10 systems.
1471-
if (!simulation_enabled_ && !modem_.isSynchronousMode()) {
1472-
guiLog("Win startup guard: forcing modem synchronous RX mode");
1473-
modem_.setSynchronousMode(true);
1474-
}
1475-
#endif
1476-
14771524
// Process captured RX audio in the main thread.
14781525
// Avoids feeding modem state directly from SDL callback threads.
14791526
pollRadioRx();
@@ -1687,6 +1734,7 @@ void App::render() {
16871734
// Status bar
16881735
ImGui::Separator();
16891736
auto mstats = defer_monitoring ? LoopbackStats{} : modem_.getStats();
1737+
auto dstats = defer_monitoring ? DecoderStats{} : modem_.getDecoderStats();
16901738
const char* mode_str = simulation_enabled_ ? "SIMULATION" : (ptt_active_ ? "TX" : (radio_rx_enabled_ ? "RX" : "IDLE"));
16911739
char goodput_text[96];
16921740
if (last_effective_goodput_bps_ > 0.0f) {
@@ -1695,9 +1743,12 @@ void App::render() {
16951743
} else {
16961744
snprintf(goodput_text, sizeof(goodput_text), "n/a");
16971745
}
1698-
ImGui::Text("Mode: %s | SNR: %.1f dB | TX: %d | RX: %d | PHY: %d bps | Goodput: %s",
1746+
ImGui::Text("Mode: %s | SNR: %.1f dB | TX: %d | RX: %d | PHY: %d bps | Goodput: %s | RXQ: %.0f ms (pk %.0f) | OF: %llu/%llu",
16991747
mode_str, mstats.snr_db, mstats.frames_sent, mstats.frames_received,
1700-
mstats.throughput_bps, goodput_text);
1748+
mstats.throughput_bps, goodput_text,
1749+
dstats.backlog_ms, dstats.peak_backlog_ms,
1750+
static_cast<unsigned long long>(dstats.buffer_overflows),
1751+
static_cast<unsigned long long>(dstats.overflow_samples_dropped));
17011752

17021753
ImGui::End();
17031754
if (first_render) {
@@ -1907,6 +1958,10 @@ bool App::queueRealTxSamples(const std::vector<float>& samples, const char* cont
19071958
waterfall_->addSamples(samples.data(), samples.size());
19081959
}
19091960

1961+
if (recording_enabled_) {
1962+
recorded_tx_samples_.insert(recorded_tx_samples_.end(), samples.begin(), samples.end());
1963+
}
1964+
19101965
audio_.startPlayback();
19111966
audio_.queueTxSamples(samples);
19121967
return true;
@@ -1925,13 +1980,6 @@ bool App::startRadioRx() {
19251980
return true;
19261981
}
19271982

1928-
#ifdef _WIN32
1929-
if (!modem_.isSynchronousMode()) {
1930-
guiLog("startRadioRx guard: enabling synchronous modem RX mode");
1931-
modem_.setSynchronousMode(true);
1932-
}
1933-
#endif
1934-
19351983
audio_.setInputCaptureMode(
19361984
radio_rx_force_queue_mode_
19371985
? AudioEngine::InputCaptureMode::Queue
@@ -1947,9 +1995,9 @@ bool App::startRadioRx() {
19471995
}
19481996
}
19491997
if (!found) {
1950-
guiLog("startRadioRx: configured input device missing: '%s' (rescan audio devices in Settings)",
1998+
guiLog("startRadioRx: configured input device missing: '%s', falling back to Default",
19511999
input_dev.c_str());
1952-
return false;
2000+
input_dev.clear();
19532001
}
19542002
}
19552003
if (!audio_.openInput(input_dev)) {
@@ -2120,11 +2168,16 @@ void App::pollRadioRx() {
21202168
if (!radio_rx_first_chunk_logged_) {
21212169
guiLog("pollRadioRx: first RX chunk=%zu samples", samples.size());
21222170
}
2171+
if (recording_enabled_) {
2172+
recorded_rx_samples_.insert(recorded_rx_samples_.end(), samples.begin(), samples.end());
2173+
}
21232174
modem_.feedAudio(samples);
21242175
if (!radio_rx_first_chunk_logged_) {
21252176
guiLog("pollRadioRx: first RX chunk fed to modem");
21262177
}
2127-
modem_.processRxBuffer();
2178+
if (modem_.isSynchronousMode()) {
2179+
modem_.processRxBuffer();
2180+
}
21282181
if (!radio_rx_first_chunk_logged_) {
21292182
guiLog("pollRadioRx: first RX chunk modem processing complete");
21302183
radio_rx_first_chunk_logged_ = true;
@@ -2370,21 +2423,18 @@ void App::renderOperateTab() {
23702423
appendRxLogLine("[SIM] Simulation enabled - connect to '" + virtual_callsign_ + "'");
23712424
modem_.reset(); virtual_modem_->reset(); virtual_protocol_.reset();
23722425
if (options_.record_audio) {
2373-
recording_enabled_ = true; recorded_samples_.clear();
2374-
appendRxLogLine("[REC] Recording enabled");
2426+
recording_enabled_ = true;
2427+
appendRxLogLine("[REC] Recording active");
23752428
}
23762429
// Start simulation threads for realistic audio streaming
23772430
startSimulator();
23782431
} else {
23792432
// Stop simulation threads
23802433
stopSimulator();
23812434
appendRxLogLine("[SIM] Simulation disabled");
2382-
if (recording_enabled_) {
2383-
recording_enabled_ = false;
2384-
if (!recorded_samples_.empty()) {
2385-
writeRecordingToFile();
2386-
appendRxLogLine("[REC] Saved: " + options_.record_path);
2387-
}
2435+
if (options_.record_audio) {
2436+
recording_enabled_ = true;
2437+
appendRxLogLine("[REC] Recording continues");
23882438
}
23892439
modem_.reset();
23902440
if (audio_initialized_) {
@@ -2397,7 +2447,10 @@ void App::renderOperateTab() {
23972447
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "'%s' active", virtual_callsign_.c_str());
23982448
if (recording_enabled_) {
23992449
ImGui::SameLine();
2400-
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[REC %.1fs]", recorded_samples_.size() / 48000.0f);
2450+
const size_t total_rec = recorded_samples_.size() +
2451+
recorded_rx_samples_.size() +
2452+
recorded_tx_samples_.size();
2453+
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[REC %.1fs]", total_rec / 48000.0f);
24012454
}
24022455
ImGui::SetNextItemWidth(100);
24032456
ImGui::SliderFloat("SNR", &simulation_snr_db_, 0.0f, 40.0f, "%.0f dB");

src/gui/app.hpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class App {
3535
bool record_audio = false; // -rec: Record all audio to file
3636
bool safe_startup = false; // Defer heavyweight init (audio/sim) until needed
3737
bool disable_waterfall = false; // Skip waterfall construction (startup safety)
38-
std::string record_path = "sim_recording.f32"; // Recording output path
38+
std::string record_path = "sim_recording.f32"; // Recording output base path
3939
};
4040

4141
App(); // Default constructor
@@ -155,8 +155,10 @@ class App {
155155

156156
// Audio recording (requires -rec flag)
157157
bool recording_enabled_ = false; // Currently recording
158-
std::vector<float> recorded_samples_; // Accumulated samples
159-
void writeRecordingToFile(); // Save recording to disk
158+
std::vector<float> recorded_samples_; // Legacy sim capture (post-channel)
159+
std::vector<float> recorded_rx_samples_; // Real RX audio fed to modem
160+
std::vector<float> recorded_tx_samples_; // Real TX audio queued to output
161+
void writeRecordingToFile(); // Save recording buffers to disk
160162
std::string virtual_callsign_ = "SIM"; // Virtual station's callsign
161163

162164
// Virtual station's protocol and modem

src/gui/modem/modem_engine.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@ LoopbackStats ModemEngine::getStats() const {
563563
return stats_;
564564
}
565565

566+
DecoderStats ModemEngine::getDecoderStats() const {
567+
if (streaming_decoder_) {
568+
return streaming_decoder_->getStats();
569+
}
570+
return DecoderStats{};
571+
}
572+
566573
bool ModemEngine::isSynced() const {
567574
if (streaming_decoder_) {
568575
return streaming_decoder_->isSynced();

src/gui/modem/modem_engine.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class ModemEngine {
104104
// STATUS & CALLBACKS
105105
// ========================================================================
106106
LoopbackStats getStats() const;
107+
DecoderStats getDecoderStats() const;
107108
bool isSynced() const;
108109
float getCurrentSNR() const;
109110
float getFadingIndex() const; // Fading index from per-carrier variance (0-1)

0 commit comments

Comments
 (0)