From 1b383aea83001bb1dfed200838dd269834d965ff Mon Sep 17 00:00:00 2001 From: Rafael Diniz Date: Mon, 10 Aug 2026 00:21:20 +0100 Subject: [PATCH 1/2] ofdm: do burst acquisition by FFT correlation, not brute force Issue #162 reported 100 % CPU on a Pi 4 with the RX backlog appearing before any client connected or any session existed. Profiling the idle path put 90 % of all RX CPU in one function: ofdm_complex_dot_product, ~514,000 calls per second of audio, from est_timing_and_freq()'s brute-force search over every (frequency hypothesis, timing offset) pair. Every mode measured the same ~6.6 % of a core, because the cost is the search grid and the preamble, not the mode's bandwidth or code. The inner expression is a cross-correlation, so per hypothesis c_w = IFFT( FFT(rx) . conj(FFT(g_w)) ), g_w[i] = known[i] * e^{jwi} and multiplying by e^{jwi} in time is a rotation in frequency, so FFT(g_w) is FFT(known) rotated -- one template transform serves all 21 hypotheses. The transform length is the whole trick and is not free to choose. It must be >= Nrx so no correlation lag wraps onto real data, AND must make fstep an exact number of bins or the rotation is not a rotation. fs/fstep = 1600 but Nrx = 1760, so the smallest workable length is 3200, where 5 Hz is exactly two bins. Checked across every mode in ofdm_mode.c: all land on 1600 or 3200, both factoring into 2s and 5s, comfortable for kiss_fft's mixed radix. Where no suitable length exists the code returns false and the brute-force loop runs -- which is what the fine stage does, its 1 Hz step would want an 8000-point transform to save 35 dot products. CPU, idle decoder on noise (utils/rxcost_bench): a single decoder 6.61 % -> 3.18 % of one x86 core; the dual decoder issue #162 runs, DATAC16 control plus DATAC3 payload, 13.42 % -> 6.02 %. Scaled to a Pi 4 core that is roughly 67-134 % down to 30-60 %. Sensitivity is the constraint, and it does not move. Against a build of trunk (57a4543), utils/acquire_vs_decode output is IDENTICAL -- same acquired and delivered counts on every row -- for DATAC16, DATAC15, DATAC13, DATAC4, DATAC3, DATAC1 and DATAC0. DATAC16 at 100 trials from -13 to -8 dB: 7/2, 26/19, 52/48, 84/78, 99/98, 100/99, matching trunk exactly rather than within noise. test_ofdm_acq is the permanent guard: it runs the real modem over identical audio with each path via the new freedv_set_acq_fft_enable(), across four modes and four SNRs down to -13 dB, and requires the outcomes to agree -- including on the bursts that fail, since agreeing only where the signal is strong would prove nothing. It detects a one-bin (2.5 Hz) error in the frequency rotation, and detects it at -11 dB rather than -4 dB, which is the right signature: high SNR absorbs a sloppy coarse estimate, the fringe does not. Two traps worth recording. The first mutation runs all "passed" and the test looked worthless -- the tests/Makefile rule listed libfreedvdata.a only in the link command, not as a prerequisite, so make never relinked and every run tested the previous build. The rule now names it on the left-hand side. And the prototype that justified this work measured 7.9x, against a hand-written brute-force loop ~3.8x slower than the real one; the honest in-situ figure is 2.2x on the dual decoder. Still on the table: a full 3200-point IFFT is computed per hypothesis and only 220 of 3200 outputs are used (tstep=4). Folding the spectrum into N/4 bins before an 800-point IFFT is exact and should cut the remaining dominant cost about 4x again. Left for a separate change with its own equivalence run. Gate: unit suite, integration (245 s), full Windows cross-build, aarch64. Co-Authored-By: Claude Opus 5 --- modem/freedv/codec2_ofdm.h | 6 ++ modem/freedv/freedv_api.c | 4 + modem/freedv/freedv_api.h | 5 + modem/freedv/ofdm.c | 174 +++++++++++++++++++++++++++++++++++ modem/freedv/ofdm_internal.h | 12 +++ tests/Makefile | 11 ++- tests/modem/test_ofdm_acq.c | 171 ++++++++++++++++++++++++++++++++++ 7 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 tests/modem/test_ofdm_acq.c diff --git a/modem/freedv/codec2_ofdm.h b/modem/freedv/codec2_ofdm.h index a56c740d..ef490079 100644 --- a/modem/freedv/codec2_ofdm.h +++ b/modem/freedv/codec2_ofdm.h @@ -95,6 +95,12 @@ void ofdm_set_foff_est_enable(struct OFDM *, bool); void ofdm_set_phase_est_enable(struct OFDM *, bool); void ofdm_set_phase_est_bandwidth_mode(struct OFDM *ofdm, int val); void ofdm_set_off_est_hz(struct OFDM *, float); + +/* Enable/disable the FFT-accelerated burst acquisition search (default on). + * Both paths compute the same joint timing/frequency argmax; this exists so + * the two can be compared directly, and so a station can fall back if the + * fast path is ever suspected. See est_timing_and_freq_fft() in ofdm.c. */ +void ofdm_set_acq_fft_enable(struct OFDM *, bool); void ofdm_set_sync(struct OFDM *, int); void ofdm_set_tx_bpf(struct OFDM *, bool); void ofdm_set_dpsk(struct OFDM *ofdm, bool val); diff --git a/modem/freedv/freedv_api.c b/modem/freedv/freedv_api.c index 1904fb8d..7c55941e 100644 --- a/modem/freedv/freedv_api.c +++ b/modem/freedv/freedv_api.c @@ -1682,3 +1682,7 @@ unsigned short freedv_gen_crc16(unsigned char *data_p, int length) { void freedv_ofdm_print_info(struct freedv *freedv) { ofdm_print_info(freedv->ofdm); } + +void freedv_set_acq_fft_enable(struct freedv *f, bool val) { + if (f != NULL && f->ofdm != NULL) ofdm_set_acq_fft_enable(f->ofdm, val); +} diff --git a/modem/freedv/freedv_api.h b/modem/freedv/freedv_api.h index 456bb910..c778c969 100644 --- a/modem/freedv/freedv_api.h +++ b/modem/freedv/freedv_api.h @@ -263,6 +263,11 @@ int freedv_rawdata_from_codec_frames(struct freedv *freedv, unsigned char *rawdata, unsigned char *codec_frames); unsigned short freedv_gen_crc16(unsigned char *bytes, int nbytes); + +/* OFDM burst acquisition: enable/disable the FFT-accelerated joint + * timing/frequency search (default enabled). Both paths select the same + * argmax; exposed so the two can be compared and so a station can fall back. */ +void freedv_set_acq_fft_enable(struct freedv *freedv, bool val); void freedv_pack(unsigned char *bytes, unsigned char *bits, int nbits); void freedv_unpack(unsigned char *bits, unsigned char *bytes, int nbits); unsigned short freedv_crc16_unpacked(unsigned char *bits, int nbits); diff --git a/modem/freedv/ofdm.c b/modem/freedv/ofdm.c index 3a408a57..930c9e91 100644 --- a/modem/freedv/ofdm.c +++ b/modem/freedv/ofdm.c @@ -39,6 +39,7 @@ #include "comp.h" #include "debug_alloc.h" #include "filter.h" +#include "kiss_fft.h" #include "machdep.h" #include "ofdm_internal.h" #include "wval.h" @@ -253,6 +254,8 @@ struct OFDM *ofdm_create(const struct OFDM_CONFIG *config) { config->fmax; /* frequency maximum for ofdm acquisition range */ } + ofdm->acq_fft_en = true; /* FFT acquisition search on by default */ + ofdm->rs = (1.0f / ofdm->ts); /* Modulation Symbol Rate */ ofdm->m = (int)(ofdm->fs / ofdm->rs); /* 700D: 144 */ ofdm->ncp = (int)(ofdm->tcp * ofdm->fs); /* 700D: 16 */ @@ -626,6 +629,9 @@ void ofdm_destroy(struct OFDM *ofdm) { if (ofdm->rx_bpf) { deallocate_rx_bpf(ofdm); } + if (ofdm->acq_fwd) KISS_FFT_FREE(ofdm->acq_fwd); + if (ofdm->acq_inv) KISS_FFT_FREE(ofdm->acq_inv); + if (ofdm->acq_buf) FREE(ofdm->acq_buf); FREE(ofdm->pilot_samples); FREE(ofdm->rxbuf); @@ -1154,6 +1160,10 @@ void ofdm_set_phase_est_enable(struct OFDM *ofdm, bool val) { ofdm->phase_est_en = val; } +void ofdm_set_acq_fft_enable(struct OFDM *ofdm, bool val) { + ofdm->acq_fft_en = val; +} + void ofdm_set_off_est_hz(struct OFDM *ofdm, float val) { ofdm->foff_est_hz = val; } @@ -1249,6 +1259,153 @@ int ofdm_sync_search_shorts(struct OFDM *ofdm, short *rxbuf_in, float gain) { return ofdm_sync_search_core(ofdm); } +/* ---- FFT-accelerated joint timing/frequency search (Mercury) ---------- + * + * est_timing_and_freq() below evaluates, for every frequency hypothesis w and + * every timing offset t, + * + * c_w[t] = sum_i rx[t+i] * conj(known[i] * e^{jwi}) + * + * by direct dot product. That is Nfreq * (Ncorr/tstep) * Npsam complex MACs, + * and on an idle receiver -- searching continuously for a preamble that is not + * there -- it measured 90 % of all RX CPU (issue #162: 100 % of a Pi 4 core, + * with the audio backlog starting before any session existed). + * + * The inner expression is a cross-correlation, so for each hypothesis + * + * c_w = IFFT( FFT(rx) . conj(FFT(g_w)) ), g_w[i] = known[i] * e^{jwi} + * + * and multiplying by e^{jwi} in time is a rotation in frequency, so FFT(g_w) + * is FFT(known) rotated by w's bin index. One template transform therefore + * serves every hypothesis. + * + * The transform length is not free. It must be >= Nrx (so no correlation lag + * wraps onto real data) AND must make fstep an exact number of bins (or the + * rotation is not a rotation). fs/fstep = 1600 for the coarse stage but + * Nrx = 1760, so the smallest workable length is 3200, where 5 Hz is exactly + * two bins. Across every mode in ofdm_mode.c this lands on 1600 or 3200, both + * of which factor into 2s and 5s -- comfortable for kiss_fft's mixed radix. + * + * Returns false when no suitable length exists (fstep not dividing fs, or the + * transform getting silly), and the caller falls back to brute force. The + * fine stage does that: its fstep of 1 Hz would demand an 8000-point transform + * to save 35 dot products. + */ + +/* Minimum brute-force work before the transforms are worth their overhead. */ +#define OFDM_ACQ_FFT_MIN_DOTS 512 +/* Refuse to allocate a silly transform. */ +#define OFDM_ACQ_FFT_MAX 16384 + +static bool acq_fft_len(struct OFDM *ofdm, int Nrx, float fstep, int *nfft_out) { + if (fstep <= 0.0f) return false; + double per = (double)ofdm->fs / (double)fstep; /* samples for one fstep bin */ + int base = (int)lrint(per); + if (base <= 0 || fabs(per - (double)base) > 1e-6) return false; /* not exact */ + long n = (long)base * ((Nrx + base - 1) / base); + if (n < Nrx || n > OFDM_ACQ_FFT_MAX) return false; + *nfft_out = (int)n; + return true; +} + +static bool acq_fft_prepare(struct OFDM *ofdm, int nfft) { + if (ofdm->acq_nfft == nfft && ofdm->acq_fwd && ofdm->acq_inv && ofdm->acq_buf) + return true; + + if (ofdm->acq_fwd) { KISS_FFT_FREE(ofdm->acq_fwd); ofdm->acq_fwd = NULL; } + if (ofdm->acq_inv) { KISS_FFT_FREE(ofdm->acq_inv); ofdm->acq_inv = NULL; } + if (ofdm->acq_buf) { FREE(ofdm->acq_buf); ofdm->acq_buf = NULL; } + ofdm->acq_nfft = 0; + + kiss_fft_cfg fwd = kiss_fft_alloc(nfft, 0, NULL, NULL); + kiss_fft_cfg inv = kiss_fft_alloc(nfft, 1, NULL, NULL); + kiss_fft_cpx *buf = (kiss_fft_cpx *)MALLOC(sizeof(kiss_fft_cpx) * 6 * nfft); + if (!fwd || !inv || !buf) { + if (fwd) KISS_FFT_FREE(fwd); + if (inv) KISS_FFT_FREE(inv); + if (buf) FREE(buf); + return false; /* caller falls back to brute force */ + } + ofdm->acq_fwd = fwd; + ofdm->acq_inv = inv; + ofdm->acq_buf = buf; + ofdm->acq_nfft = nfft; + return true; +} + +static bool est_timing_and_freq_fft(struct OFDM *ofdm, int *t_est, + float *foff_est, float *mx_out, + complex float *rx, int Nrx, + complex float *known_samples, int Npsam, + int tstep, float fmin, float fmax, + float fstep) { + if (!ofdm->acq_fft_en) return false; + int Ncorr = Nrx - Npsam + 1; + if (Ncorr <= 0) return false; + + /* Only worth it when the brute-force grid is big; the fine stage is not. */ + int nfreq = (int)floorf((fmax - fmin) / fstep) + 1; + int nt = (Ncorr + tstep - 1) / tstep; + if ((long)nfreq * nt < OFDM_ACQ_FFT_MIN_DOTS) return false; + + int nfft; + if (!acq_fft_len(ofdm, Nrx, fstep, &nfft)) return false; + if (!acq_fft_prepare(ofdm, nfft)) return false; + + kiss_fft_cpx *a = (kiss_fft_cpx *)ofdm->acq_buf; + kiss_fft_cpx *A = a + nfft; + kiss_fft_cpx *b = A + nfft; + kiss_fft_cpx *B = b + nfft; + kiss_fft_cpx *C = B + nfft; + kiss_fft_cpx *c = C + nfft; + + memset(a, 0, sizeof(kiss_fft_cpx) * nfft); + memset(b, 0, sizeof(kiss_fft_cpx) * nfft); + for (int i = 0; i < Nrx; i++) { + a[i].r = crealf(rx[i]); + a[i].i = cimagf(rx[i]); + } + for (int i = 0; i < Npsam; i++) { + b[i].r = crealf(known_samples[i]); + b[i].i = cimagf(known_samples[i]); + } + kiss_fft((kiss_fft_cfg)ofdm->acq_fwd, a, A); + kiss_fft((kiss_fft_cfg)ofdm->acq_fwd, b, B); + + float max_corr_sq = 0.0f; + *t_est = 0; + *foff_est = 0.0f; + + for (float afcoarse = fmin; afcoarse <= fmax; afcoarse += fstep) { + int k0 = (int)lrint((double)nfft * (double)afcoarse / (double)ofdm->fs); + /* C = FFT(rx) * conj(FFT(known) rotated by k0) */ + for (int k = 0; k < nfft; k++) { + int ks = (k - k0) % nfft; + if (ks < 0) ks += nfft; + float br = B[ks].r, bi = B[ks].i; + C[k].r = A[k].r * br + A[k].i * bi; + C[k].i = A[k].i * br - A[k].r * bi; + } + kiss_fft((kiss_fft_cfg)ofdm->acq_inv, C, c); + + /* Compare on magnitude SQUARED: monotonic in |corr|, so the argmax is the + * same one the brute-force loop finds, without a sqrt per candidate. */ + for (int t = 0; t < Ncorr; t += tstep) { + float re = c[t].r, im = c[t].i; + float m2 = re * re + im * im; + if (m2 > max_corr_sq) { + max_corr_sq = m2; + *t_est = t; + *foff_est = afcoarse; + } + } + } + + /* kiss_fft's inverse is unnormalised; undo the 1/N here, once. */ + *mx_out = sqrtf(max_corr_sq) / (float)nfft; + return true; +} + /* Joint estimation of timing and freq used for burst data acquisition */ static float est_timing_and_freq(struct OFDM *ofdm, int *t_est, float *foff_est, @@ -1256,6 +1413,23 @@ static float est_timing_and_freq(struct OFDM *ofdm, int *t_est, float *foff_est, complex float *known_samples, int Npsam, int tstep, float fmin, float fmax, float fstep) { + /* Same search by FFT when the grid is big enough to pay for the transforms + * and an exact-bin length exists; identical (t_est, foff_est, timing_mx). + * Falls through to the direct loop below otherwise. */ + { + float mx_fft; + if (est_timing_and_freq_fft(ofdm, t_est, foff_est, &mx_fft, rx, Nrx, + known_samples, Npsam, tstep, fmin, fmax, + fstep)) { + float mag1 = 0, mag2 = 0; + for (int i = 0; i < Npsam; i++) { + mag1 += cabsf(known_samples[i] * conjf(known_samples[i])); + mag2 += cabsf(rx[i + *t_est] * conjf(rx[i + *t_est])); + } + return mx_fft * mx_fft / (mag1 * mag2 + 1E-12); + } + } + int Ncorr = Nrx - Npsam + 1; float max_corr = 0; *t_est = 0; diff --git a/modem/freedv/ofdm_internal.h b/modem/freedv/ofdm_internal.h index 1131756d..7a9bccdf 100644 --- a/modem/freedv/ofdm_internal.h +++ b/modem/freedv/ofdm_internal.h @@ -255,6 +255,18 @@ struct OFDM { char *codename; float EsNodB; /* EsNo est used for LDPC decoder */ char *state_machine; + + /* --- FFT-accelerated burst acquisition search (Mercury) --- + * The coarse timing/frequency search is a cross-correlation of the receive + * window against the frequency-shifted preamble, and doing it by brute force + * dominated idle RX CPU (measured 90 % of it). Done by FFT instead; these + * are allocated lazily on first use and sized to acq_nfft. NULL/0 means the + * brute-force path is in use, which is always a valid fallback. */ + void *acq_fwd; /* kiss_fft_cfg, forward */ + void *acq_inv; /* kiss_fft_cfg, inverse */ + int acq_nfft; /* transform length, 0 = not initialised */ + void *acq_buf; /* one block: a, A, b, B, C, c (6 * nfft) */ + bool acq_fft_en; /* false forces the brute-force search */ }; /* Prototypes */ diff --git a/tests/Makefile b/tests/Makefile index 93365948..1384f2eb 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -45,7 +45,8 @@ ARQ_STUBS = datalink_arq/arq_test_stubs.c TEST_BINS = test_ring_buffer test_arq_protocol test_arq_timing test_arq_fsm \ test_tcp_interfaces test_resampler test_pcm24 test_arq_olla test_arq_sim \ test_arq_conn test_cfg_utils test_arq_tnc test_channel_busy \ - test_freedv_harq test_sock_wire test_virtual_clock test_watterson + test_freedv_harq test_sock_wire test_virtual_clock test_watterson \ + test_ofdm_acq .PHONY: all test clean @@ -149,6 +150,14 @@ test_virtual_clock: common/test_virtual_clock.c $(UNITY_SRC) ../common/virtual_c test_watterson: common/test_watterson.c $(UNITY_SRC) ../common/watterson.c $(CC) $(CFLAGS) -I.. -o $@ $^ $(LDFLAGS) +# OFDM acquisition: the FFT search must agree with brute force, fringe included. +# libfreedvdata.a is a PREREQUISITE, not just a link argument: leave it off the +# left-hand side and make will not relink when the library changes, which +# silently tests the previous build. +FREEDV_LIB = ../modem/freedv/libfreedvdata.a +test_ofdm_acq: modem/test_ofdm_acq.c $(UNITY_SRC) $(FREEDV_LIB) + $(CC) $(CFLAGS) -I../modem/freedv -o $@ modem/test_ofdm_acq.c $(UNITY_SRC) $(FREEDV_LIB) $(LDFLAGS) -lm + # UI status wire format (embedded struct vs remote JSON must not drift) test_ui_status: gui_interface/test_ui_status.c $(UNITY_SRC) ../gui_interface/ui_status.c $(CC) $(CFLAGS) -I../gui_interface -I../datalink_arq -I../modem -I../common -o $@ $^ $(LDFLAGS) diff --git a/tests/modem/test_ofdm_acq.c b/tests/modem/test_ofdm_acq.c new file mode 100644 index 00000000..dd944fec --- /dev/null +++ b/tests/modem/test_ofdm_acq.c @@ -0,0 +1,171 @@ +/* + * OFDM burst acquisition: FFT search must equal the brute-force search + * + * The joint timing/frequency search in ofdm.c used to evaluate every + * (frequency hypothesis, timing offset) pair with a direct dot product, and on + * an idle receiver that measured 90 % of all RX CPU (issue #162). It is now + * computed as an FFT cross-correlation, which is the same quantity by a + * different route. + * + * "Same quantity by a different route" is exactly the kind of claim that + * rots. Acquisition is what sets the fringe floor for every DATAC mode, so a + * fast path that silently picks a different peak — or picks the right peak + * only on clean signals — would cost sensitivity where we can least afford it + * and would not show up in any functional test. + * + * So: run the real modem over the same audio twice, once with each path, and + * require the outcomes to match exactly. freedv_set_acq_fft_enable() exists + * for this. + * + * Copyright (C) 2026 Rhizomatica + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include +#include +#include +#include + +#include "unity.h" +#include "freedv_api.h" + +#define FS 8000 + +static uint64_t s_rng; +static double urand(void) +{ + s_rng = s_rng * 6364136223846793005ULL + 1442695040888963407ULL; + return (double)((s_rng >> 11) & 0x1FFFFFFFFFFFFFULL) / (double)0x20000000000000ULL; +} +static double gauss(void) +{ + double u1 = urand(), u2 = urand(); + if (u1 < 1e-300) u1 = 1e-300; + return sqrt(-2.0 * log(u1)) * cos(2.0 * M_PI * u2); +} + +void setUp(void) { s_rng = 0xACC0; } +void tearDown(void) { } + +/* Push one noisy burst through a decoder and report what happened. + * acquired: reached full sync. delivered: frame out with the CRC intact. */ +static void run_one(int mode, bool fft_en, double snr_db, unsigned seed, + int *acquired, int *delivered) +{ + struct freedv *tx = freedv_open(mode); + struct freedv *rx = freedv_open(mode); + TEST_ASSERT_NOT_NULL(tx); + TEST_ASSERT_NOT_NULL(rx); + freedv_set_frames_per_burst(tx, 1); + freedv_set_frames_per_burst(rx, 1); + freedv_set_acq_fft_enable(rx, fft_en); + + int nbytes = freedv_get_bits_per_modem_frame(tx) / 8; + int ntx = freedv_get_n_tx_modem_samples(tx); + int npre = freedv_get_n_tx_preamble_modem_samples(tx); + int npost = freedv_get_n_tx_postamble_modem_samples(tx); + int n = npre + ntx + npost; + + short *burst = malloc(sizeof(short) * (size_t)(n + 8)); + unsigned char *payload = malloc((size_t)nbytes); + unsigned char *out = malloc((size_t)nbytes + 8); + TEST_ASSERT_NOT_NULL(burst); + + s_rng = seed; + for (int i = 0; i < nbytes - 2; i++) + payload[i] = (unsigned char)(urand() * 256.0); + unsigned short crc = freedv_gen_crc16(payload, nbytes - 2); + payload[nbytes - 2] = (unsigned char)(crc >> 8); + payload[nbytes - 1] = (unsigned char)(crc & 0xff); + + int k = 0; + k += freedv_rawdatapreambletx(tx, burst + k); + freedv_rawdatatx(tx, burst + k, payload); + k += ntx; + k += freedv_rawdatapostambletx(tx, burst + k); + + /* Normalise then add noise, so signal+noise cannot clip the int16 rail — + * clipping would make the two paths differ for a reason unrelated to the + * search (see utils/acquire_vs_decode). */ + double ps = 0.0, pk = 0.0; + for (int i = 0; i < k; i++) { + ps += (double)burst[i] * burst[i]; + if (fabs((double)burst[i]) > pk) pk = fabs((double)burst[i]); + } + ps /= k; + double g = 4000.0 / pk; + for (int i = 0; i < k; i++) burst[i] = (short)lrint(burst[i] * g); + ps *= g * g; + + double sigma = sqrt(ps / (pow(10.0, snr_db / 10.0) * (3000.0 / (FS / 2.0)))); + for (int i = 0; i < k; i++) { + double v = burst[i] + sigma * gauss(); + if (v > 32767.0) v = 32767.0; + if (v < -32768.0) v = -32768.0; + burst[i] = (short)lrint(v); + } + + freedv_set_sync(rx, FREEDV_SYNC_UNSYNC); + int pos = 0, sync = 0, got = 0; + while (pos < k + n) { + int nin = freedv_nin(rx); + short chunk[8192]; + if (nin <= 0 || nin > (int)(sizeof(chunk) / sizeof(chunk[0]))) break; + for (int i = 0; i < nin; i++) { + if (pos + i < k) chunk[i] = burst[pos + i]; + else { + double v = sigma * gauss(); + if (v > 32767.0) v = 32767.0; + if (v < -32768.0) v = -32768.0; + chunk[i] = (short)lrint(v); + } + } + pos += nin; + int nb = (int)freedv_rawdatarx(rx, out, chunk); + int st = freedv_get_rx_status(rx); + if (st & FREEDV_RX_SYNC) sync = 1; + if (nb > 0 && !(st & FREEDV_RX_BIT_ERRORS)) got = 1; + } + + *acquired = sync; + *delivered = got; + free(burst); free(payload); free(out); + freedv_close(tx); freedv_close(rx); +} + +/* Across modes and down to where acquisition is genuinely failing, the two + * paths must agree burst for burst -- including on the bursts that FAIL. + * Agreeing only where the signal is strong would prove nothing: the whole + * question is whether the fast search holds up at the fringe. */ +void test_acq_fft_matches_brute_force(void) +{ + const int modes[] = { FREEDV_MODE_DATAC16, FREEDV_MODE_DATAC15, + FREEDV_MODE_DATAC4, FREEDV_MODE_DATAC3 }; + const double snrs[] = { -4.0, -9.0, -11.0, -13.0 }; + + for (unsigned m = 0; m < sizeof(modes) / sizeof(modes[0]); m++) { + for (unsigned s = 0; s < sizeof(snrs) / sizeof(snrs[0]); s++) { + for (unsigned trial = 0; trial < 4; trial++) { + unsigned seed = 0x5EED + trial * 977u + s * 31u + m * 7u; + int a_fft, d_fft, a_bf, d_bf; + run_one(modes[m], true, snrs[s], seed, &a_fft, &d_fft); + run_one(modes[m], false, snrs[s], seed, &a_bf, &d_bf); + + char msg[128]; + snprintf(msg, sizeof(msg), + "mode idx %u, SNR %.0f dB, trial %u: acquisition differs", + m, snrs[s], trial); + TEST_ASSERT_EQUAL_INT_MESSAGE(a_bf, a_fft, msg); + TEST_ASSERT_EQUAL_INT_MESSAGE(d_bf, d_fft, msg); + } + } + } +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_acq_fft_matches_brute_force); + return UNITY_END(); +} From 81d81cb8d66a1c0e8b3be7ac1fd8bb247f0707f4 Mon Sep 17 00:00:00 2001 From: Rafael Diniz Date: Tue, 11 Aug 2026 00:10:45 +0100 Subject: [PATCH 2/2] tests: give libfreedvdata.a a rule, not just a prerequisite test_ofdm_acq lists ../modem/freedv/libfreedvdata.a as a prerequisite so make relinks when the library changes -- without that, the guard silently tests the previous build, which is how an earlier mutation check appeared to pass. But tests/Makefile had no rule to *build* it, and CI runs `make -C tests test` on a clean tree without building the main project first: make[1]: *** No rule to make target '../modem/freedv/libfreedvdata.a', needed by 'test_ofdm_acq' It resolved on a developer box only because the top-level make had already produced the archive. Recurse into modem/freedv to build it, kept .PHONY so the sub-make decides freshness rather than a stale timestamp here. Verified by deleting the archive and building tests alone, which is what CI does. --- tests/Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Makefile b/tests/Makefile index 1384f2eb..4bdbeaf4 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -155,6 +155,15 @@ test_watterson: common/test_watterson.c $(UNITY_SRC) ../common/watterson.c # left-hand side and make will not relink when the library changes, which # silently tests the previous build. FREEDV_LIB = ../modem/freedv/libfreedvdata.a + +# ...and it needs a rule, because CI runs `make -C tests test` on a clean tree +# without building the main project first. Without this, the prerequisite above +# is unbuildable there ("No rule to make target") even though it resolves on a +# developer box that has already run the top-level make. +.PHONY: $(FREEDV_LIB) +$(FREEDV_LIB): + $(MAKE) -C ../modem/freedv + test_ofdm_acq: modem/test_ofdm_acq.c $(UNITY_SRC) $(FREEDV_LIB) $(CC) $(CFLAGS) -I../modem/freedv -o $@ modem/test_ofdm_acq.c $(UNITY_SRC) $(FREEDV_LIB) $(LDFLAGS) -lm