@@ -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
801823void 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" );
0 commit comments