commit 26528dc5db2c4855f9c6ca9c79ad3ab585130291 Merge: 02098c1 1bf881b Author: Mooneer Salem Date: Tue Sep 29 16:54:47 2026 -0700 Merge branch 'main' into radev2-dev commit 1bf881bbbc87a55ef4648e5f22fb5ac8a33a237f Author: Mooneer Salem Date: Tue Sep 29 16:54:29 2026 -0700 libsamplerate: store cached phase weights as float. (#65) The mono sinc path's per-phase weight cache (#61) now stores its tap weights as float instead of double, halving its memory traffic. Products and accumulation stay in double: a float * float product is exact in double, so the only extra rounding is the weights themselves. SRC_SINC_MEDIUM_QUALITY, mono, 10 ms chunks, ms per 60 s of audio (Apple M1 performance core): - 48 kHz -> 16 kHz: 106.8 -> 89.5 (-16%) - 8 kHz -> 48 kHz: 138.0 -> 127.1 (-8%) - 48 kHz -> 8 kHz: 98.5 -> 78.3 (-20%) All 13 libsamplerate tests pass (Release and Debug, snr_bw_test with FFTW), and no snr_bw_test case measures more than 0.3 dB below the all-double version. Accumulating in float too would be about twice as fast again, but fails snr_bw_test (unity-ratio SRC_SINC_MEDIUM_QUALITY: 155.7 dB against a 157 dB requirement), so it isn't used. Existing build directories need _deps/samplerate-* removed so the updated patch can be applied to a clean checkout. Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 Co-authored-by: Claude Opus 5.5 commit 073aabe9408d606846d253c6834679546e142b37 Author: Mooneer Salem Date: Tue Sep 29 13:04:08 2026 -0700 libsamplerate: store cached phase weights as float. The mono sinc path's per-phase weight cache (#61) now stores its tap weights as float instead of double, halving its memory traffic. Products and accumulation stay in double: a float * float product is exact in double, so the only extra rounding is the weights themselves. SRC_SINC_MEDIUM_QUALITY, mono, 10 ms chunks, ms per 60 s of audio (Apple M1 performance core): - 48 kHz -> 16 kHz: 106.8 -> 89.5 (-16%) - 8 kHz -> 48 kHz: 138.0 -> 127.1 (-8%) - 48 kHz -> 8 kHz: 98.5 -> 78.3 (-20%) All 13 libsamplerate tests pass (Release and Debug, snr_bw_test with FFTW), and no snr_bw_test case measures more than 0.3 dB below the all-double version. Accumulating in float too would be about twice as fast again, but fails snr_bw_test (unity-ratio SRC_SINC_MEDIUM_QUALITY: 155.7 dB against a 157 dB requirement), so it isn't used. Existing build directories need _deps/samplerate-* removed so the updated patch can be applied to a clean checkout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 commit 02098c1ce34b6348793f2871a268508977e12389 Merge: 1564dd4 6bec5fb Author: Mooneer Salem Date: Tue Sep 29 09:37:03 2026 -0700 Merge branch 'main' into radev2-dev commit 6bec5fb506f9c0ef4ab55b9a9178cd51957f243d Author: Mooneer Salem Date: Tue Sep 29 09:36:45 2026 -0700 RNNoise: build from our fork with runtime CPU detection everywhere. (#64) Switch RNNoise to github.com/tmiw/rnnoise (pinned), which adds on top of upstream: - AArch64 runtime CPU detection, so generic AArch64 builds (Linux AppImage, Windows on Arm) use the dot product instructions on CPUs that have them, under a single --enable-rtcd option that also covers x86 SSE4.1/AVX2 (--enable-x86-rtcd is kept as an alias). - A faster NEON sparse_cgemv8x4() (four accumulators instead of two; bit-identical output). - The NEON sparse_sgemv8x4() from xiph/rnnoise#234, previously applied here as a patch (it has no effect in the default build, which compiles out the float weights it would be used with). --enable-rtcd is now passed for every architecture, and the local RNNoise patches are removed. Also build RNNoise with -g -O3 on x86 Linux, where gcc then vectorizes its plain C FFT and pitch code (bit-identical output). rnnoise_process_frame() per 10 ms frame, output unchanged on each machine: - x86-64 Linux (AMD EPYC-Milan, gcc 16): 70.0 -> 63.9 us (-9%) - AArch64 Linux with dot product (Ubuntu 24.04 VM on an Apple M1, gcc 13): 135.3 -> 115.6 us (-15%) - macOS (Apple M1): 110.6 -> 101.9 us (-8%) - Raspberry Pi 4 (Cortex-A72, no dot product): unchanged Windows x86-64 and ARM64 were cross-built with llvm-mingw but not run. Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 Co-authored-by: Claude Opus 5.5 commit 82bb03e32864ebec57aef7824f4846a09833d1be Author: Mooneer Salem Date: Tue Sep 29 08:56:34 2026 -0700 RNNoise: build from our fork with runtime CPU detection everywhere. Switch RNNoise to github.com/tmiw/rnnoise (pinned), which adds on top of upstream: - AArch64 runtime CPU detection, so generic AArch64 builds (Linux AppImage, Windows on Arm) use the dot product instructions on CPUs that have them, under a single --enable-rtcd option that also covers x86 SSE4.1/AVX2 (--enable-x86-rtcd is kept as an alias). - A faster NEON sparse_cgemv8x4() (four accumulators instead of two; bit-identical output). - The NEON sparse_sgemv8x4() from xiph/rnnoise#234, previously applied here as a patch (it has no effect in the default build, which compiles out the float weights it would be used with). --enable-rtcd is now passed for every architecture, and the local RNNoise patches are removed. Also build RNNoise with -g -O3 on x86 Linux, where gcc then vectorizes its plain C FFT and pitch code (bit-identical output). rnnoise_process_frame() per 10 ms frame, output unchanged on each machine: - x86-64 Linux (AMD EPYC-Milan, gcc 16): 70.0 -> 63.9 us (-9%) - AArch64 Linux with dot product (Ubuntu 24.04 VM on an Apple M1, gcc 13): 135.3 -> 115.6 us (-15%) - macOS (Apple M1): 110.6 -> 101.9 us (-8%) - Raspberry Pi 4 (Cortex-A72, no dot product): unchanged Windows x86-64 and ARM64 were cross-built with llvm-mingw but not run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 commit 1564dd40a5c29354386e0119f0a3a76f512b2a77 Merge: 03a41d6 5c46604 Author: Mooneer Salem Date: Mon Sep 28 00:25:20 2026 -0700 Merge branch 'main' into radev2-dev commit 5c4660407969543ccd860ba759ef342ba791b340 Author: Mooneer Salem Date: Mon Sep 28 00:25:06 2026 -0700 TcpConnectionHandler: time out and allow cancelling the TLS handshake. (#63) The TLS handshake waited in select() with no timeout and never checked for a pending disconnect. If the server accepted the TCP connection but stalled the handshake, disconnect() (queued behind connectImpl_()) waited until the server closed the socket, 60 s in the case of qso.freedv.org, which delayed FreeDV's shutdown by the same amount. Wait in 100 ms slices instead, stop if a disconnect was requested, and give up after 10 s so the reconnect timer can try again. Waits in the send path now also give up when a disconnect is pending. Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 Co-authored-by: Claude Opus 5.5 commit 6a9699b9be624279c906208609a5443178d031f9 Author: Mooneer Salem Date: Sun Sep 27 22:52:48 2026 -0700 TcpConnectionHandler: time out and allow cancelling the TLS handshake. The TLS handshake waited in select() with no timeout and never checked for a pending disconnect. If the server accepted the TCP connection but stalled the handshake, disconnect() (queued behind connectImpl_()) waited until the server closed the socket, 60 s in the case of qso.freedv.org, which delayed FreeDV's shutdown by the same amount. Wait in 100 ms slices instead, stop if a disconnect was requested, and give up after 10 s so the reconnect timer can try again. Waits in the send path now also give up when a disconnect is pending. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 commit 03a41d6a91f8dd73cfc30033058053ecc1b77cc2 Merge: 390d86e ca9bf57 Author: Mooneer Salem Date: Sat Sep 26 18:06:25 2026 -0700 Merge branch 'main' into radev2-dev commit ca9bf57654e0b07ad5de73668318cd435a2d2dc0 Author: Mooneer Salem Date: Sat Sep 26 18:06:10 2026 -0700 Only use system libraries that can actually be linked. (#62) find_library()/find_package() can return libraries built for the wrong OS or architecture, e.g. host libraries from MacPorts/Homebrew when cross-compiling for Windows, or single-architecture libraries when building macOS universal binaries. Previously the only guard was IsMacOSUniversalBinary(), which only ran for universal builds and used `otool -L` (which lists linked dylibs, not architectures). Replace it with CheckLibraryUsable(), which compiles and links a test program against the found library using the target toolchain. If that fails, fall back to building the dependency ourselves. Applied to OpenSSL, libebur128 and libsamplerate. Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 Co-authored-by: Claude Opus 5.5 commit c70e3dc58384af3224504ce7d2fd05825e6f452b Author: Mooneer Salem Date: Sat Sep 26 17:50:18 2026 -0700 Only use system libraries that can actually be linked. find_library()/find_package() can return libraries built for the wrong OS or architecture, e.g. host libraries from MacPorts/Homebrew when cross-compiling for Windows, or single-architecture libraries when building macOS universal binaries. Previously the only guard was IsMacOSUniversalBinary(), which only ran for universal builds and used `otool -L` (which lists linked dylibs, not architectures). Replace it with CheckLibraryUsable(), which compiles and links a test program against the found library using the target toolchain. If that fails, fall back to building the dependency ourselves. Applied to OpenSSL, libebur128 and libsamplerate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxwHpb9KR3ymB15RFPGtR6 commit 390d86e03e1051cc677b61124e0f8a804597296e Merge: 4857cf9 f02e7e9 Author: Mooneer Salem Date: Wed Sep 23 11:00:06 2026 -0700 Merge branch 'main' into radev2-dev commit f02e7e94da8d35e839d186a7a0c92c9e1b28277d Author: Mooneer Salem Date: Wed Sep 23 10:59:14 2026 -0700 libsamplerate: cache per-phase tap weights in the mono sinc path. (#61) At a fixed conversion ratio, the fractional input position cycles through a small set of values, and the tap weights calc_output_single() interpolates out of coeffs/coeff_deltas depend only on (start_filter_index, increment). Cache them in a 16-slot per-filter LRU so the inner loops become unit-stride dot products instead of a scattered coefficient gather plus a fixed-point to double conversion per tap. The cache is allocated at filter creation (no allocation on the processing path) and sized for ratios down to 1/8. Lower ratios, and streams with more phases than slots (e.g. 44.1kHz <-> 48kHz), fall back to the inline path; an adaptive bypass stops inserting when most lookups miss. Batching and summation order are unchanged, so output is bit-identical to the previous implementation. Claude-Session: https://claude.ai/code/session_01LMBgG3zymVyaeK8vo5arWD Co-authored-by: Claude Opus 5.5 commit 7ef7c0f9cf2066ba895eb974c90ee9b582eea3f9 Author: Mooneer Salem Date: Wed Sep 23 10:35:59 2026 -0700 libsamplerate: cache per-phase tap weights in the mono sinc path. At a fixed conversion ratio, the fractional input position cycles through a small set of values, and the tap weights calc_output_single() interpolates out of coeffs/coeff_deltas depend only on (start_filter_index, increment). Cache them in a 16-slot per-filter LRU so the inner loops become unit-stride dot products instead of a scattered coefficient gather plus a fixed-point to double conversion per tap. The cache is allocated at filter creation (no allocation on the processing path) and sized for ratios down to 1/8. Lower ratios, and streams with more phases than slots (e.g. 44.1kHz <-> 48kHz), fall back to the inline path; an adaptive bypass stops inserting when most lookups miss. Batching and summation order are unchanged, so output is bit-identical to the previous implementation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LMBgG3zymVyaeK8vo5arWD commit 4857cf9e5535adaa46798cecdd2c4bb8897c10d7 Merge: 05a59dc 8b72f8e Author: Mooneer Salem Date: Wed Sep 23 00:49:41 2026 -0700 Merge branch 'main' into radev2-dev commit 8b72f8ebb4bf21d9dca6fc510594cdce853d0e0c Author: Mooneer Salem Date: Wed Sep 23 00:30:37 2026 -0700 Integrate RADEV2 bug fixes into main. (#60) commit 3f2c915811948f01c241e1d26e21e92dbc4ec4f7 Author: Mooneer Salem Date: Wed Sep 23 00:28:41 2026 -0700 Integrate RADEV2 bug fixes into main. commit bfdd1169d1a86723c0ad69d8c7cc0c4f8ec07346 Merge: 77b6c0b 72525d0 Author: Mooneer Salem Date: Tue Sep 22 12:55:51 2026 -0700 Merge branch 'main' into ms-agc-tweaks commit 05a59dcfbf602706884884f3553db971f56ced2b Merge: df71302 72525d0 Author: Mooneer Salem Date: Tue Sep 22 12:55:11 2026 -0700 Merge branch 'main' into radev2-dev commit 72525d06571cca56e6f385527415660347fdfcc3 Author: Mooneer Salem Date: Tue Sep 22 12:54:08 2026 -0700 Fix RNNoise autoconf build on newer Xcode; require macOS 11.0 (#59) On recent Xcode toolchains, CMAKE_C_COMPILER can resolve to the raw absolute path inside XcodeDefault.xctoolchain rather than /usr/bin/cc or `xcrun cc`. When RNNoise's own ./configure invokes that compiler to check it works, the driver no longer auto-discovers the default SDK and linking fails with "ld: library 'System' not found", breaking any full/clean build. Pass -isysroot explicitly using CMAKE_OSX_SYSROOT to fix this. Also raises the minimum macOS deployment target from 10.11 to 11.0 to match the rest of the build. Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi Co-authored-by: Claude Sonnet 5 commit 7e8e9531183afd54560af7fb9b6e3b461d1d67f4 Author: Mooneer Salem Date: Tue Sep 22 12:52:13 2026 -0700 Fix RNNoise autoconf build on newer Xcode; require macOS 11.0 On recent Xcode toolchains, CMAKE_C_COMPILER can resolve to the raw absolute path inside XcodeDefault.xctoolchain rather than /usr/bin/cc or `xcrun cc`. When RNNoise's own ./configure invokes that compiler to check it works, the driver no longer auto-discovers the default SDK and linking fails with "ld: library 'System' not found", breaking any full/clean build. Pass -isysroot explicitly using CMAKE_OSX_SYSROOT to fix this. Also raises the minimum macOS deployment target from 10.11 to 11.0 to match the rest of the build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi commit df71302bd56eb393abc2106ca36662bcda032bdc Merge: 402910b 2c532c0 Author: Mooneer Salem Date: Mon Sep 21 08:32:13 2026 -0700 Merge branch 'main' into radev2-dev commit 2c532c0b870c64bb54d5137b25eb581bfe529f3f Author: Mooneer Salem Date: Sat Sep 19 23:06:46 2026 -0700 Various atomic value cleanup. (#58) commit ee5e45c5e6f3d2c8203bb77061e78caa16e64af4 Author: Mooneer Salem Date: Sat Sep 19 23:00:57 2026 -0700 Various atomic value cleanup. commit 402910b02b773c816beac83929fc00fbdb10f32b Author: Mooneer Salem Date: Sat Sep 19 01:04:10 2026 -0700 Allow linking a locally-built rade_c instead of downloading/building it (#54) Adds RADE_C_SOURCE_DIR/RADE_C_BINARY_DIR cache variables to cmake/BuildRADE.cmake. When both are set, the ExternalProject_Add git-fetch/build step for rade_c is skipped and the rade/opus imported targets point at the user-supplied paths instead, so a developer can iterate on rade_c locally (or supply a prebuilt cross-compiled copy) without waiting on this project's own build to compile it. Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv Co-authored-by: Claude Sonnet 5 commit 034b384a9c8364e2329ecf604c1f34468308cb62 Merge: 3d605c7 360b416 Author: Mooneer Salem Date: Fri Sep 18 15:28:28 2026 +0000 Merge pull request #57 from tmiw/revert-rnnoise-wrapper Revert RNNoise wrapper script -- root cause was the compiler, not CC's shape commit 360b4161e8dc8e15b0a1f321547dbda79479ebdf Author: Mooneer Salem Date: Fri Sep 18 08:28:12 2026 -0700 Revert RNNoise wrapper script -- root cause was the compiler, not CC's shape #56's wrapper-script approach didn't fix the "C compiler cannot create executables" failure either -- it recurred identically in freedv-gui's macOS lint CI job even with a single-token CC pointing at the wrapper, which ruled out CC's structure (two-word string vs. wrapper script) as the actual cause. The real culprit was the specific compiler binary: freedv-gui's lint job used KyleMayes/install-llvm-action's LLVM 20.1, and switching that job to Homebrew's llvm@20 (matching what dist-pgo-instrument/ dist-pgo-build already use) fixed the RNNoise build immediately, with this same wrapper-script CC still in place. So the wrapper adds complexity for no benefit -- reverting to the simpler two-word CC=" " form from #55, which was already verified correct on its own (locally reproduced end-to-end against the exact pinned RNNoise commit and ccache 4.14, and now also empirically confirmed working in CI once the incompatible compiler was swapped out). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi commit 3d605c7449904a89e4eafb5d8e2a7cc98316470e Author: Mooneer Salem Date: Fri Sep 18 05:53:04 2026 +0000 Fix RNNoise ccache wiring: wrapper script instead of two-word CC (#56) #55 broke the macOS lint CI job in freedv-gui: RNNoise's own ./configure failed with "C compiler cannot create executables" once CC/CXX became a two-word string ("ccache "). That's the standard, normally reliable ccache+autotools idiom, and I could not reproduce the failure in isolation -- checked out the exact pinned RNNoise commit, ran ./autogen.sh && ./configure with the exact same CC/CXX shape against both a pass-through stand-in and the real ccache 4.14 binary (matching the CI log's version exactly), and RNNoise's full configure (including its libtool/feature-detection checks, not just a trivial compile test) completed successfully both times. So the two-word form isn't inherently broken, at least not in a way reproducible outside CI's actual parallel-build load. Rather than re-land the same construct and hope it holds up, switch to generating a small wrapper script (exec "$launcher" "$compiler" "$@") and pointing CC/CXX at that instead. This keeps CC/CXX a single, ordinary-looking path in every case, so nothing downstream -- configure, libtool, make -- ever has to parse a compound value, removing that entire class of doubt regardless of what actually went wrong in CI. Verified end-to-end locally: configure succeeds with the wrapper as CC, and an actual `make` compile confirms ccache genuinely gets invoked (cache miss recorded on first compile, as expected). Also confirmed via a standalone CMake test that the Apple-universal branch's generated ExternalProject configure command now carries a clean, single-token CC=/CXX= (no embedded spaces) in both the x86 and arm sub-builds. Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi Co-authored-by: Claude Sonnet 5 commit 5c2867569d1b8ea1c6b08ee2ca12971f8cdcf63f Author: Mooneer Salem Date: Thu Sep 17 22:52:14 2026 -0700 Fix RNNoise ccache wiring: wrapper script instead of two-word CC #55 broke the macOS lint CI job in freedv-gui: RNNoise's own ./configure failed with "C compiler cannot create executables" once CC/CXX became a two-word string ("ccache "). That's the standard, normally reliable ccache+autotools idiom, and I could not reproduce the failure in isolation -- checked out the exact pinned RNNoise commit, ran ./autogen.sh && ./configure with the exact same CC/CXX shape against both a pass-through stand-in and the real ccache 4.14 binary (matching the CI log's version exactly), and RNNoise's full configure (including its libtool/feature-detection checks, not just a trivial compile test) completed successfully both times. So the two-word form isn't inherently broken, at least not in a way reproducible outside CI's actual parallel-build load. Rather than re-land the same construct and hope it holds up, switch to generating a small wrapper script (exec "$launcher" "$compiler" "$@") and pointing CC/CXX at that instead. This keeps CC/CXX a single, ordinary-looking path in every case, so nothing downstream -- configure, libtool, make -- ever has to parse a compound value, removing that entire class of doubt regardless of what actually went wrong in CI. Verified end-to-end locally: configure succeeds with the wrapper as CC, and an actual `make` compile confirms ccache genuinely gets invoked (cache miss recorded on first compile, as expected). Also confirmed via a standalone CMake test that the Apple-universal branch's generated ExternalProject configure command now carries a clean, single-token CC=/CXX= (no embedded spaces) in both the x86 and arm sub-builds. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi commit fd05b1ca6c2aab9ac8e5881446a8f1619794b77f Author: Mooneer Salem Date: Fri Sep 18 04:30:40 2026 +0000 Forward ccache launcher to ExternalProject_Add dependencies (#55) libebur128, rade_c, and RNNoise are each built via ExternalProject_Add, a separate build invocation that CMake just shells out to. That means none of them ever saw CMAKE_C_COMPILER_LAUNCHER/CMAKE_CXX_COMPILER_LAUNCHER (e.g. ccache) from the parent project, so all three compiled uncached regardless of how the top-level freedv-gui/freedv-backend build was configured -- unlike LibreSSL and libsamplerate, which use FetchContent and inherit the launcher automatically. - BuildEbur128.cmake / BuildRADE.cmake: both build a nested CMake project (CMAKE_ARGS), which gets its own fresh CMakeCache.txt, so forward CMAKE_C_COMPILER_LAUNCHER/CMAKE_CXX_COMPILER_LAUNCHER there. - BuildRNNoise.cmake: builds via ./autogen.sh && ./configure && make (autotools), which never even set CC/CXX at all, so bake the launcher into CC/CXX instead -- that's the correct mechanism for a plain ./configure invocation. Note on RADE specifically: this only gets rade_c's own sources cached. Opus is built inside rade_c via its own nested ExternalProject_Add (cmake/BuildOpus.cmake in github.com/freedv/rade_c), which has the same gap but is out of scope here -- rade_c is a separate repo. Verified against real generated ExternalProject output (not just the CMake list/string semantics) for all three: confirmed -DCMAKE_C_COMPILER_LAUNCHER=ccache/-DCMAKE_CXX_COMPILER_LAUNCHER=ccache appear correctly in the nested cmake invocations for Ebur128/RADE, and confirmed CC=ccache clang / CXX=ccache clang++ appear as correctly single, unsplit arguments in RNNoise's generated configure command -- including in the Apple-universal branch, where the shared CONFIGURE_COMMAND gets extended per-arch with additional --host/CFLAGS arguments after CC/CXX are already baked in. Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi Co-authored-by: Claude Sonnet 5 commit 8a37c6d95bed57d46789c07e7a06ca64680bad36 Author: Mooneer Salem Date: Thu Sep 17 21:18:16 2026 -0700 Forward ccache launcher to ExternalProject_Add dependencies libebur128, rade_c, and RNNoise are each built via ExternalProject_Add, a separate build invocation that CMake just shells out to. That means none of them ever saw CMAKE_C_COMPILER_LAUNCHER/CMAKE_CXX_COMPILER_LAUNCHER (e.g. ccache) from the parent project, so all three compiled uncached regardless of how the top-level freedv-gui/freedv-backend build was configured -- unlike LibreSSL and libsamplerate, which use FetchContent and inherit the launcher automatically. - BuildEbur128.cmake / BuildRADE.cmake: both build a nested CMake project (CMAKE_ARGS), which gets its own fresh CMakeCache.txt, so forward CMAKE_C_COMPILER_LAUNCHER/CMAKE_CXX_COMPILER_LAUNCHER there. - BuildRNNoise.cmake: builds via ./autogen.sh && ./configure && make (autotools), which never even set CC/CXX at all, so bake the launcher into CC/CXX instead -- that's the correct mechanism for a plain ./configure invocation. Note on RADE specifically: this only gets rade_c's own sources cached. Opus is built inside rade_c via its own nested ExternalProject_Add (cmake/BuildOpus.cmake in github.com/freedv/rade_c), which has the same gap but is out of scope here -- rade_c is a separate repo. Verified against real generated ExternalProject output (not just the CMake list/string semantics) for all three: confirmed -DCMAKE_C_COMPILER_LAUNCHER=ccache/-DCMAKE_CXX_COMPILER_LAUNCHER=ccache appear correctly in the nested cmake invocations for Ebur128/RADE, and confirmed CC=ccache clang / CXX=ccache clang++ appear as correctly single, unsplit arguments in RNNoise's generated configure command -- including in the Apple-universal branch, where the shared CONFIGURE_COMMAND gets extended per-arch with additional --host/CFLAGS arguments after CC/CXX are already baked in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017BRyLXHuZHy1b9oLt4wJFi commit 11fbe97867c01fd982dc7d1a1bcbae3e7f2b9227 Author: Mooneer Salem Date: Wed Sep 16 20:30:12 2026 -0700 Allow linking a locally-built rade_c instead of downloading/building it Adds RADE_C_SOURCE_DIR/RADE_C_BINARY_DIR cache variables to cmake/BuildRADE.cmake. When both are set, the ExternalProject_Add git-fetch/build step for rade_c is skipped and the rade/opus imported targets point at the user-supplied paths instead, so a developer can iterate on rade_c locally (or supply a prebuilt cross-compiled copy) without waiting on this project's own build to compile it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv commit 19360ae6b6f5384a47288429895eaafe2e8b3fb9 Merge: 50f92b5 8990bd0 Author: Mooneer Salem Date: Tue Sep 15 16:26:48 2026 -0700 Merge branch 'main' into radev2-dev commit 77b6c0b6143d27ec3adbcfeabf0eb7a1cf5926d2 Merge: 6c7e1ac 8990bd0 Author: Mooneer Salem Date: Tue Sep 15 16:23:20 2026 -0700 Merge branch 'main' into ms-agc-tweaks commit 8990bd09ea9f1dbb9585e4c9e53fc89df20ca082 Author: Mooneer Salem Date: Tue Sep 15 16:15:47 2026 -0700 AgcStep: poll ebur128 momentary loudness every 5th block, not every block (#53) libebur128's ebur128_calc_gating_block() recomputes the sum-of-squares over its entire window from scratch on every call -- there's no incremental sliding-window caching in the library at all. AgcStep was calling ebur128_loudness_momentary() (a 400ms window) every 10ms block, i.e. ~40x more work per call than the one new 10ms block actually needs. Profiling a live fullduplex_RADEV1 run attributed ~94% of AgcStep::execute's own sampled time to that single call. Rather than patching libebur128's ring-buffer accumulation (real correctness risk around wraparound geometry, and the same accumulator is shared across other loudness modes/window sizes upstream), this moves the fix to the call site: only measure loudness -- and recompute targetGainDb_ -- every 5th block (~50ms), while still feeding the K-weighting filter (ebur128_add_frames_short) every block. AGC's own attack/release time constants (0.5s/6s) are far slower than 50ms, so nothing is lost. The gain ramp itself still runs every block at full 10ms granularity; only how often the target is refreshed changes. The original "freeze gain during silence" gating behavior is preserved exactly by persisting the last measurement's validity across the skipped blocks (lastMeasurementValid_) rather than needing a fresh measurement every block to know whether to gate. Verified: - AgcStepTest: all 4 cases pass unchanged (loud/quiet convergence, near-silence non-boost, post-reset gain-to-unity). - Live fullduplex_RADEV1 profile: AgcStep's own total dropped from 1295 to 178 sampled instructions (~86%), while every other pipeline step (RNNoiseStep, ResampleStep, RADETransmitStep, RADEReceiveStep, BandwidthExpandStep) stayed flat -- confirms this is a real, isolated effect and not run-to-run noise. Test still passes ("Got 1 sync changes"). Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv Co-authored-by: Claude Sonnet 5 commit 334a39e5c609979a7cc817a406121cc13186b8ce Author: Mooneer Salem Date: Tue Sep 15 14:55:22 2026 -0700 AgcStep: poll ebur128 momentary loudness every 5th block, not every block libebur128's ebur128_calc_gating_block() recomputes the sum-of-squares over its entire window from scratch on every call -- there's no incremental sliding-window caching in the library at all. AgcStep was calling ebur128_loudness_momentary() (a 400ms window) every 10ms block, i.e. ~40x more work per call than the one new 10ms block actually needs. Profiling a live fullduplex_RADEV1 run attributed ~94% of AgcStep::execute's own sampled time to that single call. Rather than patching libebur128's ring-buffer accumulation (real correctness risk around wraparound geometry, and the same accumulator is shared across other loudness modes/window sizes upstream), this moves the fix to the call site: only measure loudness -- and recompute targetGainDb_ -- every 5th block (~50ms), while still feeding the K-weighting filter (ebur128_add_frames_short) every block. AGC's own attack/release time constants (0.5s/6s) are far slower than 50ms, so nothing is lost. The gain ramp itself still runs every block at full 10ms granularity; only how often the target is refreshed changes. The original "freeze gain during silence" gating behavior is preserved exactly by persisting the last measurement's validity across the skipped blocks (lastMeasurementValid_) rather than needing a fresh measurement every block to know whether to gate. Verified: - AgcStepTest: all 4 cases pass unchanged (loud/quiet convergence, near-silence non-boost, post-reset gain-to-unity). - Live fullduplex_RADEV1 profile: AgcStep's own total dropped from 1295 to 178 sampled instructions (~86%), while every other pipeline step (RNNoiseStep, ResampleStep, RADETransmitStep, RADEReceiveStep, BandwidthExpandStep) stayed flat -- confirms this is a real, isolated effect and not run-to-run noise. Test still passes ("Got 1 sync changes"). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv commit 6c7e1ac37f12558dd9a70c251f690f7ebaa70908 Merge: 0d43401 75dc268 Author: Mooneer Salem Date: Tue Sep 15 13:00:04 2026 -0700 Merge branch 'main' into ms-agc-tweaks commit 50f92b552d829ddea60b219e923b8b4ca7709e02 Merge: 8c6f1fe 75dc268 Author: Mooneer Salem Date: Tue Sep 15 12:59:45 2026 -0700 Merge branch 'main' into radev2-dev commit 75dc268f9c472bac0c0aef4bc31e48e23d6981c3 Author: Mooneer Salem Date: Tue Sep 15 12:59:31 2026 -0700 Audio pipeline perf: libsamplerate sinc-mono, AGC/RNNoise FIFO batching, RNNoise NEON sparse GEMV (#52) * Speed up libsamplerate's mono sinc resampler by ~30-42% (#50) FreeDV's audio pipeline resamples every TX/RX block through sinc_mono_vari_process (the SRC_SINC_MEDIUM_QUALITY path ResampleStep uses), and profiling the real RADE TX pipeline showed resampling consuming as much CPU as the RADE neural encoder itself (~47% vs ~43% of the audio thread's busy time). This patches the vendored libsamplerate source (applied automatically via CMake's FetchContent PATCH_COMMAND, not by editing a vendored copy in-tree) with four changes to src_sinc.c, all scoped to the mono path FreeDV actually uses: 1. Hoist float_increment/increment out of the per-sample loop; they only change when src_ratio is actually ramping (the existing fabs() check), but were being rederived every sample regardless. 2. Precompute 1/index_inc once instead of dividing by it every sample. 3. Pass max_filter_index into calc_output_single() instead of recomputing the same fixed-point constant on every call. 4. Precompute a coeff_deltas[] table (coeffs[i+1]-coeffs[i]) once at filter-creation time so per-tap linear interpolation is one FMA instead of a subtract-then-FMA. 5. Restructure the tap-accumulation loop to sum four taps into four independent accumulators instead of one running sum. This breaks the loop-carried dependency chain and, empirically, lets the compiler's instruction scheduler run the four FMA chains on separate FP pipelines (confirmed via disassembly: no actual SIMD instructions on this arm64 target, all the gain is from breaking the serial reduction -- and independently confirmed the same source auto-vectorizes for real with packed SSE mulps/addps when cross-compiled for x86_64). Uses double accumulators throughout, not float: an earlier float32 version passed every functional test but failed libsamplerate's own snr_bw_test qualification suite on the SRC_SINC_MEDIUM_QUALITY unity-ratio case, where the reference implementation itself only clears its 157.00dB requirement by 0.72dB -- tighter than the converter's headline "121dB SNR" spec suggests. Validation: - Bit-exact output vs. unpatched libsamplerate across 48k<->8k and 44.1k<->8k conversions, including ratio-ramping. - libsamplerate's own test suite (simple_test, downsample_test, misc_test, termination_test, callback_test, callback_hang_test, reset_test, clone_test, nullptr_test, float_short_test, multi_channel_test, varispeed_test, snr_bw_test): 13/13 pass, snr_bw_test output byte-identical to unpatched. - ~30-42% faster on a synthetic mono-resample benchmark mirroring ResampleStep's actual call pattern (10ms blocks, fixed ratio), measured via interleaved timing against the unpatched build to cancel out background system load. - Rebuilt FreeDV.app against the patched library and reprofiled the same real TX pipeline (real speech file, actual CoreAudio device I/O, sample-based call-graph profiling): ResampleStep's share of TX-thread CPU dropped from 1061 to 771 profiler samples (-27%) while RADETransmitStep (untouched, used as a control) stayed flat (964 -> 975), and total TX-thread busy time dropped 14% (2278 -> 1962). Output audio statistically unchanged (RMS 0.2408 vs 0.2409); the live pipeline has inherent run-to-run jitter from real-time audio scheduling (confirmed by diffing two runs of the *same* unpatched binary), so bit-exact app-level comparison isn't a valid test here -- the bit-exactness proof is at the library level above. GIT_TAG is pinned to the libsamplerate commit the patch was generated against (0844c20) instead of floating on "master", since a hand-maintained patch and a moving upstream target will eventually diverge. Bumping the pin means regenerating and re-verifying the patch. The patch apply step checks (via `git apply --reverse --check`) whether it's already applied before applying, since FetchContent's PATCH_COMMAND re-runs on every CMake re-configure that touches this dependency, not just the first populate. Only sinc_mono_vari_process/calc_output_single are touched; the stereo/quad/hex/multichannel sinc variants are separate functions and are unaffected. Claude-Session: https://claude.ai/code/session_01PhNwRKm5sPC1vwtQQGuSf8 Co-authored-by: Claude Sonnet 5 * AgcStep/RNNoiseStep: batch FIFO writes instead of one sample at a time Both steps fed the lock-free GenericFIFO one sample per write() call in a tight per-sample loop, each iteration also calling numUsed() to check for a full block -- turning every audio block into O(numInputSamples) atomic-CAS FIFO operations instead of O(numInputSamples / blockSize). RADETransmitStep, RADEReceiveStep, and BandwidthExpandStep already use the correct pattern (bulk-write bounded by numFree(), then drain full blocks in a loop); this applies the same pattern here. Profiling a live full-duplex RADEV1 run (Debug build) attributed ~46% of AgcStep::execute's own sampled time to the per-sample write() call and ~10% to the per-sample numUsed() check, and ~35%/~7% respectively for RNNoiseStep -- i.e. over half of AgcStep's and over 40% of RNNoiseStep's own cost was FIFO bookkeeping, not the actual AGC/ebur128/ WebRTC or RNNoise DSP work. In a RelWithDebInfo (-O2) build the compiler inlines away most of that overhead, so the same lines account for only ~10% (AgcStep) and ~1.6% (RNNoiseStep) of sampled time pre-fix, dropping to ~0% post-fix -- still a real, correct improvement, just a much smaller share of total CPU once optimized. Verified via the fullduplex_RADEV1 integration test (real full-duplex audio through actual CoreAudio devices) before and after: both pass ("Got 1 sync changes"), confirming no functional regression from the batching change, including the large-input-chunk case (chunk size larger than the FIFO's capacity) that AgcStepTest already exercises. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv * RNNoise: NEON-vectorize sparse_sgemv8x4 (pin GIT_TAG, add patch) vec_neon.h's sparse_sgemv8x4() -- used for RNNoise's pruned GRU/dense weight matrices, the dominant cost inside rnnoise_process_frame -- is upstream's own explicitly-marked "Temporarily use unoptimized version": a plain scalar 8-accumulator loop. vec_avx.h's version of the same function is properly AVX2-vectorized (8-wide FMA against the sparse- indexed input). This ports that same algorithm to NEON as two float32x4_t accumulators (128-bit vs AVX's 256-bit), matching the already-vectorized (and unaffected) sgemv8x1() a few lines above it in the same file. Only affects the NEON branch of vec.h's compile-time arch dispatch -- no effect on x86 builds. Verified correct: a standalone interleaved microbenchmark linking against both the patched and unpatched librnnoise.a (same pinned commit, same input, 500 warm-up + 20000 timed calls to rnnoise_process_frame) produced bit-identical checksums on both arm64 macOS (Apple M1) and aarch64 Linux (Raspberry Pi CM4, Cortex-A72). Performance is hardware-dependent and was verified empirically rather than assumed: - Apple M1: no measurable difference (~119.0 vs ~119.5 us/frame across 5 interleaved trials, within noise). The core is wide/superscalar enough to already extract full ILP from the scalar version's 8 independent accumulator variables, so packing the same work into 2 NEON registers doesn't add throughput headroom that wasn't already being used. - Raspberry Pi CM4 (Cortex-A72): a consistent, real improvement -- NEON faster in 8/8 interleaved trials, ~1247.2 vs ~1238.8 us/frame (~0.7%). This core has fewer independent FP/NEON execution ports, so the scalar accumulator trick doesn't fully hide the dependency latency the way it does on M1; vectorizing recovers some of that. GIT_TAG is pinned to the rnnoise commit the patch was generated against (70f1d25) instead of floating on "main", for the same reason the existing libsamplerate patch pins its dependency: a hand-maintained patch and a moving upstream target will otherwise diverge. The patch apply step reuses the existing apply_if_needed.cmake helper (checks via `git apply --reverse --check` whether it's already applied before applying), since ExternalProject's PATCH_COMMAND re-runs on every reconfigure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv --------- Co-authored-by: Claude Sonnet 5 commit 3f876fee733cb408bc7013bea63709ce912e4831 Author: Mooneer Salem Date: Tue Sep 15 12:39:59 2026 -0700 RNNoise: NEON-vectorize sparse_sgemv8x4 (pin GIT_TAG, add patch) vec_neon.h's sparse_sgemv8x4() -- used for RNNoise's pruned GRU/dense weight matrices, the dominant cost inside rnnoise_process_frame -- is upstream's own explicitly-marked "Temporarily use unoptimized version": a plain scalar 8-accumulator loop. vec_avx.h's version of the same function is properly AVX2-vectorized (8-wide FMA against the sparse- indexed input). This ports that same algorithm to NEON as two float32x4_t accumulators (128-bit vs AVX's 256-bit), matching the already-vectorized (and unaffected) sgemv8x1() a few lines above it in the same file. Only affects the NEON branch of vec.h's compile-time arch dispatch -- no effect on x86 builds. Verified correct: a standalone interleaved microbenchmark linking against both the patched and unpatched librnnoise.a (same pinned commit, same input, 500 warm-up + 20000 timed calls to rnnoise_process_frame) produced bit-identical checksums on both arm64 macOS (Apple M1) and aarch64 Linux (Raspberry Pi CM4, Cortex-A72). Performance is hardware-dependent and was verified empirically rather than assumed: - Apple M1: no measurable difference (~119.0 vs ~119.5 us/frame across 5 interleaved trials, within noise). The core is wide/superscalar enough to already extract full ILP from the scalar version's 8 independent accumulator variables, so packing the same work into 2 NEON registers doesn't add throughput headroom that wasn't already being used. - Raspberry Pi CM4 (Cortex-A72): a consistent, real improvement -- NEON faster in 8/8 interleaved trials, ~1247.2 vs ~1238.8 us/frame (~0.7%). This core has fewer independent FP/NEON execution ports, so the scalar accumulator trick doesn't fully hide the dependency latency the way it does on M1; vectorizing recovers some of that. GIT_TAG is pinned to the rnnoise commit the patch was generated against (70f1d25) instead of floating on "main", for the same reason the existing libsamplerate patch pins its dependency: a hand-maintained patch and a moving upstream target will otherwise diverge. The patch apply step reuses the existing apply_if_needed.cmake helper (checks via `git apply --reverse --check` whether it's already applied before applying), since ExternalProject's PATCH_COMMAND re-runs on every reconfigure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv commit 4473dfe8411c6826a2ead7d7ce5df722bc6dff21 Author: Mooneer Salem Date: Tue Sep 15 12:39:26 2026 -0700 AgcStep/RNNoiseStep: batch FIFO writes instead of one sample at a time Both steps fed the lock-free GenericFIFO one sample per write() call in a tight per-sample loop, each iteration also calling numUsed() to check for a full block -- turning every audio block into O(numInputSamples) atomic-CAS FIFO operations instead of O(numInputSamples / blockSize). RADETransmitStep, RADEReceiveStep, and BandwidthExpandStep already use the correct pattern (bulk-write bounded by numFree(), then drain full blocks in a loop); this applies the same pattern here. Profiling a live full-duplex RADEV1 run (Debug build) attributed ~46% of AgcStep::execute's own sampled time to the per-sample write() call and ~10% to the per-sample numUsed() check, and ~35%/~7% respectively for RNNoiseStep -- i.e. over half of AgcStep's and over 40% of RNNoiseStep's own cost was FIFO bookkeeping, not the actual AGC/ebur128/ WebRTC or RNNoise DSP work. In a RelWithDebInfo (-O2) build the compiler inlines away most of that overhead, so the same lines account for only ~10% (AgcStep) and ~1.6% (RNNoiseStep) of sampled time pre-fix, dropping to ~0% post-fix -- still a real, correct improvement, just a much smaller share of total CPU once optimized. Verified via the fullduplex_RADEV1 integration test (real full-duplex audio through actual CoreAudio devices) before and after: both pass ("Got 1 sync changes"), confirming no functional regression from the batching change, including the large-input-chunk case (chunk size larger than the FIFO's capacity) that AgcStepTest already exercises. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018JYqaVNT7DbgmKePHQtNYv commit 56bf8339ad6b5636d7a11f07cb556ef90aff9273 Author: Mooneer Salem Date: Fri Sep 11 12:40:14 2026 -0700 Speed up libsamplerate's mono sinc resampler by ~30-42% (#50) FreeDV's audio pipeline resamples every TX/RX block through sinc_mono_vari_process (the SRC_SINC_MEDIUM_QUALITY path ResampleStep uses), and profiling the real RADE TX pipeline showed resampling consuming as much CPU as the RADE neural encoder itself (~47% vs ~43% of the audio thread's busy time). This patches the vendored libsamplerate source (applied automatically via CMake's FetchContent PATCH_COMMAND, not by editing a vendored copy in-tree) with four changes to src_sinc.c, all scoped to the mono path FreeDV actually uses: 1. Hoist float_increment/increment out of the per-sample loop; they only change when src_ratio is actually ramping (the existing fabs() check), but were being rederived every sample regardless. 2. Precompute 1/index_inc once instead of dividing by it every sample. 3. Pass max_filter_index into calc_output_single() instead of recomputing the same fixed-point constant on every call. 4. Precompute a coeff_deltas[] table (coeffs[i+1]-coeffs[i]) once at filter-creation time so per-tap linear interpolation is one FMA instead of a subtract-then-FMA. 5. Restructure the tap-accumulation loop to sum four taps into four independent accumulators instead of one running sum. This breaks the loop-carried dependency chain and, empirically, lets the compiler's instruction scheduler run the four FMA chains on separate FP pipelines (confirmed via disassembly: no actual SIMD instructions on this arm64 target, all the gain is from breaking the serial reduction -- and independently confirmed the same source auto-vectorizes for real with packed SSE mulps/addps when cross-compiled for x86_64). Uses double accumulators throughout, not float: an earlier float32 version passed every functional test but failed libsamplerate's own snr_bw_test qualification suite on the SRC_SINC_MEDIUM_QUALITY unity-ratio case, where the reference implementation itself only clears its 157.00dB requirement by 0.72dB -- tighter than the converter's headline "121dB SNR" spec suggests. Validation: - Bit-exact output vs. unpatched libsamplerate across 48k<->8k and 44.1k<->8k conversions, including ratio-ramping. - libsamplerate's own test suite (simple_test, downsample_test, misc_test, termination_test, callback_test, callback_hang_test, reset_test, clone_test, nullptr_test, float_short_test, multi_channel_test, varispeed_test, snr_bw_test): 13/13 pass, snr_bw_test output byte-identical to unpatched. - ~30-42% faster on a synthetic mono-resample benchmark mirroring ResampleStep's actual call pattern (10ms blocks, fixed ratio), measured via interleaved timing against the unpatched build to cancel out background system load. - Rebuilt FreeDV.app against the patched library and reprofiled the same real TX pipeline (real speech file, actual CoreAudio device I/O, sample-based call-graph profiling): ResampleStep's share of TX-thread CPU dropped from 1061 to 771 profiler samples (-27%) while RADETransmitStep (untouched, used as a control) stayed flat (964 -> 975), and total TX-thread busy time dropped 14% (2278 -> 1962). Output audio statistically unchanged (RMS 0.2408 vs 0.2409); the live pipeline has inherent run-to-run jitter from real-time audio scheduling (confirmed by diffing two runs of the *same* unpatched binary), so bit-exact app-level comparison isn't a valid test here -- the bit-exactness proof is at the library level above. GIT_TAG is pinned to the libsamplerate commit the patch was generated against (0844c20) instead of floating on "master", since a hand-maintained patch and a moving upstream target will eventually diverge. Bumping the pin means regenerating and re-verifying the patch. The patch apply step checks (via `git apply --reverse --check`) whether it's already applied before applying, since FetchContent's PATCH_COMMAND re-runs on every CMake re-configure that touches this dependency, not just the first populate. Only sinc_mono_vari_process/calc_output_single are touched; the stereo/quad/hex/multichannel sinc variants are separate functions and are unaffected. Claude-Session: https://claude.ai/code/session_01PhNwRKm5sPC1vwtQQGuSf8 Co-authored-by: Claude Sonnet 5 commit 0d43401af8cad76f558f4964a1da3dff14ea7da7 Author: Mooneer Salem Date: Tue Sep 15 00:28:59 2026 -0700 Try different soft clipping formula instead of WebRTC_AGC. commit 8c6f1fec5aae638612fbb94973038011a40a84da Author: Mooneer Salem Date: Sun Sep 13 22:25:57 2026 -0700 Use clock_gettime() instead of timespec_get() to avoid needing UCRT timespec_get() is only declared by mingw-w64's headers when _UCRT is defined, which requires linking libucrt.a/libucrtbase.a alongside the classic msvcrt-targeted rest of the toolchain on distros (e.g. Ubuntu's gcc-mingw-w64 package) that don't ship a UCRT-native triple. That mixing is not safe: statically linking both put a garbage lock-table index into msvcrt's _lock() (its internal CRT-lock/stream-table plumbing isn't ABI-compatible with ucrtbase's, e.g. behind __acrt_iob_func's stream handling), corrupting the next indirect call and crashing -- confirmed via Wine relay tracing and reproduced in under 2 seconds with a ~40-line wx test program unrelated to logging. clock_gettime() avoids the problem entirely: mingw-w64 provides it via winpthreads on the classic msvcrt target, no UCRT involved. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit ba6f1b0c91921fa04d051ef49ead3aef11e19f42 Author: Mooneer Salem Date: Sun Sep 13 18:22:31 2026 -0700 Forward CMAKE_*_STANDARD_LIBRARIES to the build_rade ExternalProject build_rade is a genuinely separate `cmake` invocation, so it re-runs whatever CMAKE_TOOLCHAIN_FILE the parent configure used -- which re-triggers CMake's own platform-default population of CMAKE_C_STANDARD_LIBRARIES/CMAKE_CXX_STANDARD_LIBRARIES (e.g. "-lkernel32 -luser32 ..."), clobbering anything the toolchain file itself tried to add there. This is why lpcnet_demo.exe (built inside build_rade, linking against a further-nested build_opus) kept failing with "undefined reference to `__memcpy_chk'" even after freedv-gui's gcc-mingw-w64 toolchain file added -lssp for exactly that symbol: the addition never survived into build_rade's own separate configure. By the time this file runs, the parent configure has already appended its extra libraries to these same variables (see freedv-gui's CMakeLists.txt, right after project()), so forward the already-correct values through explicitly via CMAKE_ARGS rather than relying on build_rade's own toolchain-file processing to reproduce them. Harmless for the llvm-mingw toolchain (used elsewhere in freedv-gui's CI): CMAKE_C/CXX_STANDARD_LIBRARIES is just CMake's platform default there, so this forwards the same value the nested configure would have computed on its own anyway. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 362516a15391c8103ab6f07fa4cb06713a72e554 Author: Mooneer Salem Date: Sun Sep 13 13:45:04 2026 -0700 Resolve SetThreadDescription dynamically for portability Ubuntu 22.04's gcc-mingw-w64 package ships mingw-w64 headers old enough that / don't declare SetThreadDescription at all (llvm-mingw bundles much newer headers that do), so a direct call fails to compile there: error: 'SetThreadDescription' was not declared in this scope The function is Windows 10 1607+ only regardless of toolchain, so resolving it at runtime via GetProcAddress -- the standard portable pattern for this API -- sidesteps the header gap entirely and avoids depending on a specific mingw-w64 header revision. Found while getting a Windows CI build using Ubuntu's gcc-mingw-w64 (instead of llvm-mingw) working; see drowe67/freedv-gui#1490. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 9eb198f28581c2d75a66279757c153d366948aa5 Merge: a6b8b16 b9235e4 Author: Mooneer Salem Date: Sun Sep 13 12:41:03 2026 -0700 Merge branch 'main' into radev2-dev commit b9235e444023f71020f4daa48161f74ddb4b18cf Author: Mooneer Salem Date: Sun Sep 13 12:40:22 2026 -0700 Fix Windows/llvm-mingw build breaks (libc++, LibreSSL, ebur128 ordering) (#51) * Fix libc++/LibreSSL build breaks on newer llvm-mingw toolchains Surfaced while verifying freedv-gui's Windows CI upgrade to llvm-mingw 20260908 by cross-compiling locally with its macOS build. All three are latent bugs that newer clang/libc++ releases expose: - GenericFIFO.h used std::hardware_constructive_interference_size / std::hardware_destructive_interference_size under an __cpp_lib_hardware_interference_size guard, but never included , which is where libc++ actually declares them. Newer libc++ defines the feature-test macro without transitively pulling in elsewhere in this header's include chain, so the guarded branch was taken but the symbols weren't declared. - rade_text.cpp called std::fabs() while only including (not ). Newer libc++'s shim no longer pulls std::fabs into scope as a side effect. - BuildLibreSSL.cmake: newer clang ships a portable lib/clang//include/endian.h shim so #include always succeeds, even when targeting Windows. LibreSSL's own check_include_files(endian.h HAVE_ENDIAN_H) picks this up and concludes the platform has a native POSIX endian.h, which disables the WIN32 byte-swap fallback macros in its include/compat/endian.h (they're only defined when HAVE_ENDIAN_H is unset) -- but clang's shim doesn't define be32toh()/htobe32()/etc. as functions for little-endian Windows, only as macros, so the LibreSSL build failed with "undeclared function" errors. This one is a real regression risk for freedv-gui's Windows CI: its Ubuntu runners don't have openssl-dev installed, so find_package(OpenSSL) fails there too and LibreSSL gets built from source, hitting this same false detection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk * Fix build_ebur128 missing from the CMake dependency graph src/pipeline/CMakeLists.txt links fdv_audio_pipeline against ${LIBEBUR128}, but when the static-build fallback is used (no system libebur128 found), the top-level CMakeLists.txt had already set LIBEBUR128 to 0 (in the "not usable" branch) before including BuildEbur128.cmake, and BuildEbur128.cmake then only reassigned LIBEBUR128 to a raw file path rather than the "ebur128" imported target it defines. A raw path passed to target_link_libraries carries no build dependency, so nothing in the graph ever forced build_ebur128 to complete before compiling files (e.g. AgcStep.cpp) that #include ebur128.h -- it only happened to work when make's scheduling got lucky. Point LIBEBUR128 at the "ebur128" imported target instead, so linking against it pulls in the target's own add_dependencies(ebur128 build_ebur128) and fdv_audio_pipeline's build (including its object compiles) is correctly ordered after build_ebur128 completes. Verified by clearing all ebur128 build state and rebuilding fdv_audio_pipeline from scratch under -j8 -- previously this reliably failed with "ebur128.h file not found" unless build_ebur128 was built first by hand. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk * Upgrade Windows CI to llvm-mingw 20260908 Matches the llvm-mingw version bump made in freedv-gui's Windows CI, so this repo's own Windows cross-compile job builds with the same toolchain the fixes in this PR were verified against. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk --------- Co-authored-by: Claude Sonnet 5 commit 00d8a88279feb9fd6bba15d7512544c2213ba0d3 Author: Mooneer Salem Date: Sun Sep 13 12:02:18 2026 -0700 Upgrade Windows CI to llvm-mingw 20260908 Matches the llvm-mingw version bump made in freedv-gui's Windows CI, so this repo's own Windows cross-compile job builds with the same toolchain the fixes in this PR were verified against. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 9e9d9dace4bd886e71e464127789d647a1a5f227 Author: Mooneer Salem Date: Sun Sep 13 11:52:42 2026 -0700 Fix build_ebur128 missing from the CMake dependency graph src/pipeline/CMakeLists.txt links fdv_audio_pipeline against ${LIBEBUR128}, but when the static-build fallback is used (no system libebur128 found), the top-level CMakeLists.txt had already set LIBEBUR128 to 0 (in the "not usable" branch) before including BuildEbur128.cmake, and BuildEbur128.cmake then only reassigned LIBEBUR128 to a raw file path rather than the "ebur128" imported target it defines. A raw path passed to target_link_libraries carries no build dependency, so nothing in the graph ever forced build_ebur128 to complete before compiling files (e.g. AgcStep.cpp) that #include ebur128.h -- it only happened to work when make's scheduling got lucky. Point LIBEBUR128 at the "ebur128" imported target instead, so linking against it pulls in the target's own add_dependencies(ebur128 build_ebur128) and fdv_audio_pipeline's build (including its object compiles) is correctly ordered after build_ebur128 completes. Verified by clearing all ebur128 build state and rebuilding fdv_audio_pipeline from scratch under -j8 -- previously this reliably failed with "ebur128.h file not found" unless build_ebur128 was built first by hand. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit a6b8b1612a4ea8a185fb3f132de751bf0ff325a1 Author: Mooneer Salem Date: Sun Sep 13 11:52:42 2026 -0700 Fix build_ebur128 missing from the CMake dependency graph src/pipeline/CMakeLists.txt links fdv_audio_pipeline against ${LIBEBUR128}, but when the static-build fallback is used (no system libebur128 found), the top-level CMakeLists.txt had already set LIBEBUR128 to 0 (in the "not usable" branch) before including BuildEbur128.cmake, and BuildEbur128.cmake then only reassigned LIBEBUR128 to a raw file path rather than the "ebur128" imported target it defines. A raw path passed to target_link_libraries carries no build dependency, so nothing in the graph ever forced build_ebur128 to complete before compiling files (e.g. AgcStep.cpp) that #include ebur128.h -- it only happened to work when make's scheduling got lucky. Point LIBEBUR128 at the "ebur128" imported target instead, so linking against it pulls in the target's own add_dependencies(ebur128 build_ebur128) and fdv_audio_pipeline's build (including its object compiles) is correctly ordered after build_ebur128 completes. Verified by clearing all ebur128 build state and rebuilding fdv_audio_pipeline from scratch under -j8 -- previously this reliably failed with "ebur128.h file not found" unless build_ebur128 was built first by hand. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 88525a69bb37865c6707e50fa5036dadbfb0aea9 Author: Mooneer Salem Date: Sun Sep 13 11:47:22 2026 -0700 Fix libc++/LibreSSL build breaks on newer llvm-mingw toolchains Surfaced while verifying freedv-gui's Windows CI upgrade to llvm-mingw 20260908 by cross-compiling locally with its macOS build. All three are latent bugs that newer clang/libc++ releases expose: - GenericFIFO.h used std::hardware_constructive_interference_size / std::hardware_destructive_interference_size under an __cpp_lib_hardware_interference_size guard, but never included , which is where libc++ actually declares them. Newer libc++ defines the feature-test macro without transitively pulling in elsewhere in this header's include chain, so the guarded branch was taken but the symbols weren't declared. - rade_text.cpp called std::fabs() while only including (not ). Newer libc++'s shim no longer pulls std::fabs into scope as a side effect. - BuildLibreSSL.cmake: newer clang ships a portable lib/clang//include/endian.h shim so #include always succeeds, even when targeting Windows. LibreSSL's own check_include_files(endian.h HAVE_ENDIAN_H) picks this up and concludes the platform has a native POSIX endian.h, which disables the WIN32 byte-swap fallback macros in its include/compat/endian.h (they're only defined when HAVE_ENDIAN_H is unset) -- but clang's shim doesn't define be32toh()/htobe32()/etc. as functions for little-endian Windows, only as macros, so the LibreSSL build failed with "undeclared function" errors. This one is a real regression risk for freedv-gui's Windows CI: its Ubuntu runners don't have openssl-dev installed, so find_package(OpenSSL) fails there too and LibreSSL gets built from source, hitting this same false detection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 88f130bd62644f677c3ecc80f3f705a0359dab63 Author: Mooneer Salem Date: Sun Sep 13 11:47:22 2026 -0700 Fix libc++/LibreSSL build breaks on newer llvm-mingw toolchains Surfaced while verifying freedv-gui's Windows CI upgrade to llvm-mingw 20260908 by cross-compiling locally with its macOS build. All three are latent bugs that newer clang/libc++ releases expose: - GenericFIFO.h used std::hardware_constructive_interference_size / std::hardware_destructive_interference_size under an __cpp_lib_hardware_interference_size guard, but never included , which is where libc++ actually declares them. Newer libc++ defines the feature-test macro without transitively pulling in elsewhere in this header's include chain, so the guarded branch was taken but the symbols weren't declared. - rade_text.cpp called std::fabs() while only including (not ). Newer libc++'s shim no longer pulls std::fabs into scope as a side effect. - BuildLibreSSL.cmake: newer clang ships a portable lib/clang//include/endian.h shim so #include always succeeds, even when targeting Windows. LibreSSL's own check_include_files(endian.h HAVE_ENDIAN_H) picks this up and concludes the platform has a native POSIX endian.h, which disables the WIN32 byte-swap fallback macros in its include/compat/endian.h (they're only defined when HAVE_ENDIAN_H is unset) -- but clang's shim doesn't define be32toh()/htobe32()/etc. as functions for little-endian Windows, only as macros, so the LibreSSL build failed with "undeclared function" errors. This one is a real regression risk for freedv-gui's Windows CI: its Ubuntu runners don't have openssl-dev installed, so find_package(OpenSSL) fails there too and LibreSSL gets built from source, hitting this same false detection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jz1Nz4hmnEzwQ7hjQtCBhk commit 9b929cc760446a5ff93d55e30c19245d3526bdc3 Author: Mooneer Salem Date: Fri Sep 11 12:40:14 2026 -0700 Speed up libsamplerate's mono sinc resampler by ~30-42% (#50) FreeDV's audio pipeline resamples every TX/RX block through sinc_mono_vari_process (the SRC_SINC_MEDIUM_QUALITY path ResampleStep uses), and profiling the real RADE TX pipeline showed resampling consuming as much CPU as the RADE neural encoder itself (~47% vs ~43% of the audio thread's busy time). This patches the vendored libsamplerate source (applied automatically via CMake's FetchContent PATCH_COMMAND, not by editing a vendored copy in-tree) with four changes to src_sinc.c, all scoped to the mono path FreeDV actually uses: 1. Hoist float_increment/increment out of the per-sample loop; they only change when src_ratio is actually ramping (the existing fabs() check), but were being rederived every sample regardless. 2. Precompute 1/index_inc once instead of dividing by it every sample. 3. Pass max_filter_index into calc_output_single() instead of recomputing the same fixed-point constant on every call. 4. Precompute a coeff_deltas[] table (coeffs[i+1]-coeffs[i]) once at filter-creation time so per-tap linear interpolation is one FMA instead of a subtract-then-FMA. 5. Restructure the tap-accumulation loop to sum four taps into four independent accumulators instead of one running sum. This breaks the loop-carried dependency chain and, empirically, lets the compiler's instruction scheduler run the four FMA chains on separate FP pipelines (confirmed via disassembly: no actual SIMD instructions on this arm64 target, all the gain is from breaking the serial reduction -- and independently confirmed the same source auto-vectorizes for real with packed SSE mulps/addps when cross-compiled for x86_64). Uses double accumulators throughout, not float: an earlier float32 version passed every functional test but failed libsamplerate's own snr_bw_test qualification suite on the SRC_SINC_MEDIUM_QUALITY unity-ratio case, where the reference implementation itself only clears its 157.00dB requirement by 0.72dB -- tighter than the converter's headline "121dB SNR" spec suggests. Validation: - Bit-exact output vs. unpatched libsamplerate across 48k<->8k and 44.1k<->8k conversions, including ratio-ramping. - libsamplerate's own test suite (simple_test, downsample_test, misc_test, termination_test, callback_test, callback_hang_test, reset_test, clone_test, nullptr_test, float_short_test, multi_channel_test, varispeed_test, snr_bw_test): 13/13 pass, snr_bw_test output byte-identical to unpatched. - ~30-42% faster on a synthetic mono-resample benchmark mirroring ResampleStep's actual call pattern (10ms blocks, fixed ratio), measured via interleaved timing against the unpatched build to cancel out background system load. - Rebuilt FreeDV.app against the patched library and reprofiled the same real TX pipeline (real speech file, actual CoreAudio device I/O, sample-based call-graph profiling): ResampleStep's share of TX-thread CPU dropped from 1061 to 771 profiler samples (-27%) while RADETransmitStep (untouched, used as a control) stayed flat (964 -> 975), and total TX-thread busy time dropped 14% (2278 -> 1962). Output audio statistically unchanged (RMS 0.2408 vs 0.2409); the live pipeline has inherent run-to-run jitter from real-time audio scheduling (confirmed by diffing two runs of the *same* unpatched binary), so bit-exact app-level comparison isn't a valid test here -- the bit-exactness proof is at the library level above. GIT_TAG is pinned to the libsamplerate commit the patch was generated against (0844c20) instead of floating on "master", since a hand-maintained patch and a moving upstream target will eventually diverge. Bumping the pin means regenerating and re-verifying the patch. The patch apply step checks (via `git apply --reverse --check`) whether it's already applied before applying, since FetchContent's PATCH_COMMAND re-runs on every CMake re-configure that touches this dependency, not just the first populate. Only sinc_mono_vari_process/calc_output_single are touched; the stereo/quad/hex/multichannel sinc variants are separate functions and are unaffected. Claude-Session: https://claude.ai/code/session_01PhNwRKm5sPC1vwtQQGuSf8 Co-authored-by: Claude Sonnet 5 commit db94bdfe23c1077438be1a9e192c9229d649061e Author: Mooneer Salem Date: Fri Sep 11 11:33:01 2026 -0700 Speed up libsamplerate's mono sinc resampler by ~30-42% FreeDV's audio pipeline resamples every TX/RX block through sinc_mono_vari_process (the SRC_SINC_MEDIUM_QUALITY path ResampleStep uses), and profiling the real RADE TX pipeline showed resampling consuming as much CPU as the RADE neural encoder itself (~47% vs ~43% of the audio thread's busy time). This patches the vendored libsamplerate source (applied automatically via CMake's FetchContent PATCH_COMMAND, not by editing a vendored copy in-tree) with four changes to src_sinc.c, all scoped to the mono path FreeDV actually uses: 1. Hoist float_increment/increment out of the per-sample loop; they only change when src_ratio is actually ramping (the existing fabs() check), but were being rederived every sample regardless. 2. Precompute 1/index_inc once instead of dividing by it every sample. 3. Pass max_filter_index into calc_output_single() instead of recomputing the same fixed-point constant on every call. 4. Precompute a coeff_deltas[] table (coeffs[i+1]-coeffs[i]) once at filter-creation time so per-tap linear interpolation is one FMA instead of a subtract-then-FMA. 5. Restructure the tap-accumulation loop to sum four taps into four independent accumulators instead of one running sum. This breaks the loop-carried dependency chain and, empirically, lets the compiler's instruction scheduler run the four FMA chains on separate FP pipelines (confirmed via disassembly: no actual SIMD instructions on this arm64 target, all the gain is from breaking the serial reduction -- and independently confirmed the same source auto-vectorizes for real with packed SSE mulps/addps when cross-compiled for x86_64). Uses double accumulators throughout, not float: an earlier float32 version passed every functional test but failed libsamplerate's own snr_bw_test qualification suite on the SRC_SINC_MEDIUM_QUALITY unity-ratio case, where the reference implementation itself only clears its 157.00dB requirement by 0.72dB -- tighter than the converter's headline "121dB SNR" spec suggests. Validation: - Bit-exact output vs. unpatched libsamplerate across 48k<->8k and 44.1k<->8k conversions, including ratio-ramping. - libsamplerate's own test suite (simple_test, downsample_test, misc_test, termination_test, callback_test, callback_hang_test, reset_test, clone_test, nullptr_test, float_short_test, multi_channel_test, varispeed_test, snr_bw_test): 13/13 pass, snr_bw_test output byte-identical to unpatched. - ~30-42% faster on a synthetic mono-resample benchmark mirroring ResampleStep's actual call pattern (10ms blocks, fixed ratio), measured via interleaved timing against the unpatched build to cancel out background system load. - Rebuilt FreeDV.app against the patched library and reprofiled the same real TX pipeline (real speech file, actual CoreAudio device I/O, sample-based call-graph profiling): ResampleStep's share of TX-thread CPU dropped from 1061 to 771 profiler samples (-27%) while RADETransmitStep (untouched, used as a control) stayed flat (964 -> 975), and total TX-thread busy time dropped 14% (2278 -> 1962). Output audio statistically unchanged (RMS 0.2408 vs 0.2409); the live pipeline has inherent run-to-run jitter from real-time audio scheduling (confirmed by diffing two runs of the *same* unpatched binary), so bit-exact app-level comparison isn't a valid test here -- the bit-exactness proof is at the library level above. GIT_TAG is pinned to the libsamplerate commit the patch was generated against (0844c20) instead of floating on "master", since a hand-maintained patch and a moving upstream target will eventually diverge. Bumping the pin means regenerating and re-verifying the patch. The patch apply step checks (via `git apply --reverse --check`) whether it's already applied before applying, since FetchContent's PATCH_COMMAND re-runs on every CMake re-configure that touches this dependency, not just the first populate. Only sinc_mono_vari_process/calc_output_single are touched; the stereo/quad/hex/multichannel sinc variants are separate functions and are unaffected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PhNwRKm5sPC1vwtQQGuSf8 commit 471a9da7e53233d8df6ef422b1c15bdff3525fe0 Author: Mooneer Salem Date: Thu Sep 10 14:07:04 2026 -0700 Fix typos and omissions. commit 834527331435cd2974c73caf2597fe5547204428 Author: Mooneer Salem Date: Thu Sep 10 13:15:36 2026 -0700 Add temporary flags to enable/disable limiter/leveler. commit 3f9e646eabf0f3b43c292260b1616d30fbc98107 Author: Mooneer Salem Date: Thu Sep 10 13:09:10 2026 -0700 rade_text should be RTSan-safe now. commit 6ea30a560ffc9818401a2cd05746feed6ad1feed Author: Mooneer Salem Date: Wed Sep 9 22:06:05 2026 -0700 Real-time-safe async front end for ulog (#48) * Add real-time-safe async front end for ulog ulog_log() is unsafe to call from audio/real-time threads: it takes a blocking lock via the ulog_set_lock hook, malloc()s a struct tm per event, and writes to stderr through stdio. Pipeline code on the RT path (MinimalTxRxThread, RADETransmitStep, ...) calls log_*() anyway. Add an opt-in async path, enabled with -DULOG_ASYNC: * ulog_set_thread_realtime(bool) marks the calling thread. While set, ulog_log() captures the call into a fixed-size record -- level, file, line, timestamp, and the *unformatted* arguments serialized into an inline byte buffer -- and pushes it onto a bounded lock-free MPSC ring (Vyukov bounded queue). No allocation, no locks, no stdio on the calling thread. * A dedicated consumer thread drains the ring, renders each record with snprintf(), and emits it through ulog's normal output path via the new ulog_log_prerendered(). Ring-full drops are counted and reported. * Every other thread keeps ulog's existing synchronous behavior. The log_*() macros are unchanged. ulog.c changes are additive and gated on ULOG_ASYNC; ulog_Event gains two fields (prerendered, time_is_borrowed) so the define must be seen wherever ulog.h is (handled the same way as ULOG_HAVE_TIME, via add_definitions in src/CMakeLists.txt plus a PUBLIC usage requirement on fdv_util for downstream FetchContent consumers). MinimalTxRxThread marks its RT thread around the real-time section. Adds UlogAsyncTest covering deferred formatting, argument widths, NULL/over-long %s, drop accounting, and multi-producer ordering. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a * ulog_async: fix two clang-tidy warnings-as-errors * ulog.c: clang-analyzer-valist.Uninitialized -- the prerendered path reached print_message()'s vfprintf() with message_format_args never va_start()ed. Route it through a variadic emit_prerendered_() shim that passes the rendered text as a real "%s" argument, so the va_list is genuinely initialised and the normal output path is reused unchanged. Drops the ulog_Event.prerendered field and the print_message() special case; time_is_borrowed stays. * ulog_async.cpp: performance-no-int-to-ptr -- store %p arguments as void* (memcpy round-trip) instead of casting through uintptr_t. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a * ulog_async: make the async path automatic, drop the opt-in calls Route every log_*() call through the ring and start the consumer thread on its own, so nothing in the application (or in freedv-gui) has to be wired up. - ulog_set_thread_realtime() / ulog_async_is_realtime_thread() are gone. ulog.c now checks ulog_async_is_active() instead: true whenever the consumer is running and the caller is not the consumer thread, so every thread's log_*() calls are captured without formatting, locking or stdio -- not just ones that opted in. - A static AsyncLifecycle object starts the consumer before main() and stops it (draining the ring) at process exit. ulog_async_start() / ulog_async_stop() remain for tests and for a deliberate early shutdown, but applications never call them. - The consumer thread marks itself (tl_isConsumer) so its own and any re-entrant log calls take ulog's synchronous path. The windows before start-up and after shutdown fall back to synchronous logging rather than dropping. - ulog_async_start() publishes the semaphore before marking the ring ready and starts the thread last; ulog_async_stop() does a final drain() after the join to catch records enqueued during teardown. - MinimalTxRxThread no longer needs the ulog_set_thread_realtime() calls or the ulog_async.h include. - UlogAsyncTest rewritten for the always-on model: no realtime marking, and a new case covering the synchronous fallback after ulog_async_stop() plus async resuming after ulog_async_start(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a * ulog_async: make ulog_async_flush() wait for emission, not just the counters ringPop() advances g_dequeuePos when it *claims* a cell, before the record is rendered and fprintf'd by emitRecord(). ulog_async_flush() returned as soon as g_enqueuePos == g_dequeuePos, so a caller that read the log right after flush() (every UlogAsyncTest case, plus any shutdown flush) could race the consumer mid-write and miss the tail of the last line. It only showed up on the slower Linux CI runner -- UlogAsyncTest Test 4 caught it. The consumer now drains first, then sets g_consumerIdle once drain() has fully returned (every fprintf complete), parks on the semaphore, and clears it on wake. ulog_async_flush() waits for (ring empty && consumer idle), so it also covers the dropped-records WARN summary that drain() emits. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a * ulog: make ulog.level atomic so ulog_set_level() can't race the readers TSan (freedv-gui WITH_TSAN=1) flagged a data race on the global `ulog`: ulog_set_level() writes ulog.level unlocked (freedv-gui calls it from the settings UI / config load), while ulog_log() and ulog_log_prerendered() read it unlocked on the logging hot path -- the `level < ulog.level` early-out. With the always-on async path every logging thread, plus the consumer thread, is now such a reader. Make just that one field a relaxed atomic (gated on ULOG_ASYNC, and on the toolchain actually having C11 atomics). Relaxed is sufficient: the level is an independent knob, not a gate on other state, and the hot-path read stays a plain load with no fence. Taking the ulog lock for the early-out was not an option -- that is the priority inversion this whole change exists to avoid. Verified with a TSan repro (UI thread spinning ulog_set_level() vs. four threads logging through the async path): reliably races without the change, clean across 20 runs with it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a --------- Co-authored-by: Claude Sonnet 5 commit a10b056d99dab9a0703815729f42914c47c1d7f0 Author: Mooneer Salem Date: Wed Sep 9 19:57:40 2026 -0700 ulog: make ulog.level atomic so ulog_set_level() can't race the readers TSan (freedv-gui WITH_TSAN=1) flagged a data race on the global `ulog`: ulog_set_level() writes ulog.level unlocked (freedv-gui calls it from the settings UI / config load), while ulog_log() and ulog_log_prerendered() read it unlocked on the logging hot path -- the `level < ulog.level` early-out. With the always-on async path every logging thread, plus the consumer thread, is now such a reader. Make just that one field a relaxed atomic (gated on ULOG_ASYNC, and on the toolchain actually having C11 atomics). Relaxed is sufficient: the level is an independent knob, not a gate on other state, and the hot-path read stays a plain load with no fence. Taking the ulog lock for the early-out was not an option -- that is the priority inversion this whole change exists to avoid. Verified with a TSan repro (UI thread spinning ulog_set_level() vs. four threads logging through the async path): reliably races without the change, clean across 20 runs with it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a commit d7bcd489fadfaad0da7b5c95064a4832374a9a36 Author: Mooneer Salem Date: Wed Sep 9 18:18:24 2026 -0700 ulog_async: make ulog_async_flush() wait for emission, not just the counters ringPop() advances g_dequeuePos when it *claims* a cell, before the record is rendered and fprintf'd by emitRecord(). ulog_async_flush() returned as soon as g_enqueuePos == g_dequeuePos, so a caller that read the log right after flush() (every UlogAsyncTest case, plus any shutdown flush) could race the consumer mid-write and miss the tail of the last line. It only showed up on the slower Linux CI runner -- UlogAsyncTest Test 4 caught it. The consumer now drains first, then sets g_consumerIdle once drain() has fully returned (every fprintf complete), parks on the semaphore, and clears it on wake. ulog_async_flush() waits for (ring empty && consumer idle), so it also covers the dropped-records WARN summary that drain() emits. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a commit d143bb947753770a668c455d1277771799bb7f81 Author: Mooneer Salem Date: Wed Sep 9 17:32:03 2026 -0700 ulog_async: make the async path automatic, drop the opt-in calls Route every log_*() call through the ring and start the consumer thread on its own, so nothing in the application (or in freedv-gui) has to be wired up. - ulog_set_thread_realtime() / ulog_async_is_realtime_thread() are gone. ulog.c now checks ulog_async_is_active() instead: true whenever the consumer is running and the caller is not the consumer thread, so every thread's log_*() calls are captured without formatting, locking or stdio -- not just ones that opted in. - A static AsyncLifecycle object starts the consumer before main() and stops it (draining the ring) at process exit. ulog_async_start() / ulog_async_stop() remain for tests and for a deliberate early shutdown, but applications never call them. - The consumer thread marks itself (tl_isConsumer) so its own and any re-entrant log calls take ulog's synchronous path. The windows before start-up and after shutdown fall back to synchronous logging rather than dropping. - ulog_async_start() publishes the semaphore before marking the ring ready and starts the thread last; ulog_async_stop() does a final drain() after the join to catch records enqueued during teardown. - MinimalTxRxThread no longer needs the ulog_set_thread_realtime() calls or the ulog_async.h include. - UlogAsyncTest rewritten for the always-on model: no realtime marking, and a new case covering the synchronous fallback after ulog_async_stop() plus async resuming after ulog_async_start(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a commit 5d413f1b8cba28491eebb93684ff1bf5e178a7b2 Author: Mooneer Salem Date: Tue Sep 8 12:22:26 2026 -0700 Fix unit tests, bugs introduced as part of the PR. commit ee823670ae3bed3d2904e3d65d1d2e058342cf49 Author: Mooneer Salem Date: Tue Sep 8 09:11:28 2026 -0700 Forgot to delete old calculation formula. commit 2955eeb2ed52178bc90a69ec95fe7562af9a9c09 Author: Mooneer Salem Date: Tue Sep 8 09:06:54 2026 -0700 Undo move of limiter. commit a129e8e6038d32bb446aa6222c5743266da3556b Author: Mooneer Salem Date: Tue Sep 8 09:03:06 2026 -0700 Apply tweaks to AGC per https://github.com/drowe67/freedv-gui/pull/1472. Tweaks AGC as follows: * Calculates the applied gain in terms of dB per second (vs. just seconds). 1 dB/sec in either direction to start. * Moves limiter to before ebur128/gain stages. * Removes gain reset at next TX begin. * Adjusts max/min gain to 12 dB. commit 61ed161bc34515e867b24bb7692ed0d426052ab6 Author: Mooneer Salem Date: Mon Sep 7 19:16:34 2026 -0700 ulog_async: fix two clang-tidy warnings-as-errors * ulog.c: clang-analyzer-valist.Uninitialized -- the prerendered path reached print_message()'s vfprintf() with message_format_args never va_start()ed. Route it through a variadic emit_prerendered_() shim that passes the rendered text as a real "%s" argument, so the va_list is genuinely initialised and the normal output path is reused unchanged. Drops the ulog_Event.prerendered field and the print_message() special case; time_is_borrowed stays. * ulog_async.cpp: performance-no-int-to-ptr -- store %p arguments as void* (memcpy round-trip) instead of casting through uintptr_t. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a commit a00b6ba6da13d9ecdc3dc74ab3e5b1d571ec939d Author: Mooneer Salem Date: Mon Sep 7 18:12:23 2026 -0700 Add real-time-safe async front end for ulog ulog_log() is unsafe to call from audio/real-time threads: it takes a blocking lock via the ulog_set_lock hook, malloc()s a struct tm per event, and writes to stderr through stdio. Pipeline code on the RT path (MinimalTxRxThread, RADETransmitStep, ...) calls log_*() anyway. Add an opt-in async path, enabled with -DULOG_ASYNC: * ulog_set_thread_realtime(bool) marks the calling thread. While set, ulog_log() captures the call into a fixed-size record -- level, file, line, timestamp, and the *unformatted* arguments serialized into an inline byte buffer -- and pushes it onto a bounded lock-free MPSC ring (Vyukov bounded queue). No allocation, no locks, no stdio on the calling thread. * A dedicated consumer thread drains the ring, renders each record with snprintf(), and emits it through ulog's normal output path via the new ulog_log_prerendered(). Ring-full drops are counted and reported. * Every other thread keeps ulog's existing synchronous behavior. The log_*() macros are unchanged. ulog.c changes are additive and gated on ULOG_ASYNC; ulog_Event gains two fields (prerendered, time_is_borrowed) so the define must be seen wherever ulog.h is (handled the same way as ULOG_HAVE_TIME, via add_definitions in src/CMakeLists.txt plus a PUBLIC usage requirement on fdv_util for downstream FetchContent consumers). MinimalTxRxThread marks its RT thread around the real-time section. Adds UlogAsyncTest covering deferred formatting, argument widths, NULL/over-long %s, drop accounting, and multi-producer ordering. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AYJXLpaEyPTsegX93QzE1a commit efb00a043c487af386595b0cc759eb2a81eb02d9 Merge: 496dbaa 9cab7df Author: Mooneer Salem Date: Mon Sep 7 15:36:48 2026 -0700 Merge branch 'main' into radev2-dev commit 9cab7df5d7f1e973e6a64e633c90e48ee574f894 Author: Mooneer Salem Date: Mon Sep 7 15:36:35 2026 -0700 Relax memory ordering on non-audio flag/handle atomics. (#47) Match acquire/release ordering to what each atomic actually synchronises, following #46. None of these atomics publishes non-atomic shared state, so relaxed is sufficient - they only need atomicity. Tier 1 (pure flags/counters, no payload under any refactor): * FreeDVReporter::isFullyConnected_ - always accessed under objMutex_, which already provides the ordering. * TcpConnectionHandler / SocketIoClient enableReconnect_ - reconnect policy flag. * TcpConnectionHandler cancelConnect_, ipv4Complete_, ipv6Complete_ - polled in sleep loops; the resolved addrinfo is handed over via std::future. (These were implicitly seq_cst via operator=/operator bool.) * ThreadedObject suppressEnqueue_, isDestroying_, taskCurrentlyExecuting_, numQueued_ - task payload travels through eventQueueMutex_ / dispatch_async; the barrier is objectThread_.join() / waitForAllTasksComplete_. * ThreadedTimer TimerServer::isDestroying_, isRunning_ - timer state and config are guarded by mutex_ / timerMutex_. Tier 2 (safe as long as the current thread lifecycle holds; comments added): * TcpConnectionHandler / UdpHandler socket_, ssl_, sslCtx_ - handed to receiveImpl_() only via the receiveThread_ std::thread creation and torn down after receiveThread_.join(); connectImpl_() / sendImpl_() / disconnectImpl_() run on the same worker thread. The thread create/join edges carry the happens-before; the atomics just prevent torn reads and value caching across receiveImpl_()'s loop. No functional change. On arm64 this drops the ldar/stlr in the reporter TCP receive loop, where socket_ is polled ~15x per iteration. commit c510afa7827befbcd416bb514486f9c2ee6a171e Author: Mooneer Salem Date: Mon Sep 7 15:24:57 2026 -0700 Relax memory ordering on non-audio flag/handle atomics. Match acquire/release ordering to what each atomic actually synchronises, following #46. None of these atomics publishes non-atomic shared state, so relaxed is sufficient - they only need atomicity. Tier 1 (pure flags/counters, no payload under any refactor): * FreeDVReporter::isFullyConnected_ - always accessed under objMutex_, which already provides the ordering. * TcpConnectionHandler / SocketIoClient enableReconnect_ - reconnect policy flag. * TcpConnectionHandler cancelConnect_, ipv4Complete_, ipv6Complete_ - polled in sleep loops; the resolved addrinfo is handed over via std::future. (These were implicitly seq_cst via operator=/operator bool.) * ThreadedObject suppressEnqueue_, isDestroying_, taskCurrentlyExecuting_, numQueued_ - task payload travels through eventQueueMutex_ / dispatch_async; the barrier is objectThread_.join() / waitForAllTasksComplete_. * ThreadedTimer TimerServer::isDestroying_, isRunning_ - timer state and config are guarded by mutex_ / timerMutex_. Tier 2 (safe as long as the current thread lifecycle holds; comments added): * TcpConnectionHandler / UdpHandler socket_, ssl_, sslCtx_ - handed to receiveImpl_() only via the receiveThread_ std::thread creation and torn down after receiveThread_.join(); connectImpl_() / sendImpl_() / disconnectImpl_() run on the same worker thread. The thread create/join edges carry the happens-before; the atomics just prevent torn reads and value caching across receiveImpl_()'s loop. No functional change. On arm64 this drops the ldar/stlr in the reporter TCP receive loop, where socket_ is polled ~15x per iteration. commit 496dbaa1237585d21f75c9bbb6aa358e6f25be06 Merge: 87c3f1a 5a94754 Author: Mooneer Salem Date: Sun Sep 6 23:59:26 2026 -0700 Merge branch 'main' into radev2-dev commit 87c3f1a6622f990bc9a98bb6ad379148f2e094b7 Author: Mooneer Salem Date: Sun Sep 6 23:58:46 2026 -0700 Add documentation for RADEV2 reporting. (#37) * Add documentation for RADEV2 reporting. * Add UdpReporter documentation. commit f25135cebb11544a5a77102e829318a293db59dd Merge: ca83183 25de04d Author: Mooneer Salem Date: Sun Sep 6 23:58:34 2026 -0700 Merge branch 'radev2-dev' into ms-radev2-reporting-doc commit 5a94754b860c0023ac403a13b7269e62c6208da7 Author: Mooneer Salem Date: Sun Sep 6 23:00:00 2026 -0700 GenericFIFO: relax the two cursor loads that synchronise with nothing. (#46) canWrite_() and canRead_() each load both cursors with acquire, but each side only needs acquire on the cursor the *other* side owns: * The producer writes pin_ and reads pout_. Its acquire on pout_ is what stops its buffer writes being hoisted above its observation of free space. * The consumer writes pout_ and reads pin_. Its acquire on pin_ is what pairs with the producer's release CAS and makes the written data visible. Both of those stay exactly as they are. The other two loads -- the producer reading pin_ and the consumer reading pout_ -- read a cursor that only the reading side advances, so there is no other thread's writes for them to acquire. They become relaxed. reset() also writes both cursors from whichever thread calls it, so "only the producer writes pin_" is not literally true. That case is already handled independently of these loads: canWrite_()/canRead_() check resetting_ with acquire first, and a reset that lands after the check bumps the generation, so the CAS in write()/read() fails and the operation is rejected rather than acting on a stale cursor. Relaxing these loads does not weaken that interlock. On arm64 this turns two ldapr into two ldr in the FIFO fast path; the two acquires that carry the actual happens-before edges are untouched (verified in the generated code: 4 ldapr -> 2). No performance claim is attached. GenericFIFO::read/write run per audio callback and per pipeline frame rather than per sample, so any gain is far below what can be measured here. The reason to make the change is that the ordering now marks which loads are synchronisation edges and which are not. On testing, and the limits of it: GenericFIFOTest passes, including the concurrent-reset stress test, both under ThreadSanitizer and in eight plain runs (~14M resets each, zero corruption, zero ordering violations). That is weaker evidence than it looks. As a control I relaxed one of the two *required* acquires -- a genuine bug -- and neither TSAN nor the stress test noticed: TSAN detects unsynchronised access to non-atomic memory, not incorrect orderings between atomics. So the tests confirm nothing was broken in an obvious way, but they cannot confirm the ordering itself. This change rests on the argument above and should be reviewed as such. Claude-Session: https://claude.ai/code/session_01ADMCssMazpW1igtvMHXNWZ Co-authored-by: Claude Opus 5 commit d482d355da54d711223a4cbdb5ec233f7a3dccd6 Author: Mooneer Salem Date: Sun Sep 6 22:40:17 2026 -0700 GenericFIFO: relax the two cursor loads that synchronise with nothing. canWrite_() and canRead_() each load both cursors with acquire, but each side only needs acquire on the cursor the *other* side owns: * The producer writes pin_ and reads pout_. Its acquire on pout_ is what stops its buffer writes being hoisted above its observation of free space. * The consumer writes pout_ and reads pin_. Its acquire on pin_ is what pairs with the producer's release CAS and makes the written data visible. Both of those stay exactly as they are. The other two loads -- the producer reading pin_ and the consumer reading pout_ -- read a cursor that only the reading side advances, so there is no other thread's writes for them to acquire. They become relaxed. reset() also writes both cursors from whichever thread calls it, so "only the producer writes pin_" is not literally true. That case is already handled independently of these loads: canWrite_()/canRead_() check resetting_ with acquire first, and a reset that lands after the check bumps the generation, so the CAS in write()/read() fails and the operation is rejected rather than acting on a stale cursor. Relaxing these loads does not weaken that interlock. On arm64 this turns two ldapr into two ldr in the FIFO fast path; the two acquires that carry the actual happens-before edges are untouched (verified in the generated code: 4 ldapr -> 2). No performance claim is attached. GenericFIFO::read/write run per audio callback and per pipeline frame rather than per sample, so any gain is far below what can be measured here. The reason to make the change is that the ordering now marks which loads are synchronisation edges and which are not. On testing, and the limits of it: GenericFIFOTest passes, including the concurrent-reset stress test, both under ThreadSanitizer and in eight plain runs (~14M resets each, zero corruption, zero ordering violations). That is weaker evidence than it looks. As a control I relaxed one of the two *required* acquires -- a genuine bug -- and neither TSAN nor the stress test noticed: TSAN detects unsynchronised access to non-atomic memory, not incorrect orderings between atomics. So the tests confirm nothing was broken in an obvious way, but they cannot confirm the ordering itself. This change rests on the argument above and should be reviewed as such. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADMCssMazpW1igtvMHXNWZ commit 25de04d15c9d1885a9e274dded9410916ec6912e Author: Mooneer Salem Date: Sun Sep 6 02:00:50 2026 -0700 Switch to dr-tx-bpf branch. commit 3e5ab89c3d607ab5410156413f3df2c78a9e11c9 Merge: e9cd9a1 ecd85af Author: Mooneer Salem Date: Sat Sep 5 17:08:27 2026 -0700 Merge branch 'main' into ms-rade-text-payload-noise-var commit 7d91323b03942f0417536a8bb03dd2b96f462ba0 Author: Mooneer Salem Date: Thu Sep 3 17:02:45 2026 -0700 Switch RADE branch back to main.