@@ -9,6 +9,146 @@ that have hooks, but have not been implemented yet.
99
1010
1111
12+ ## 2026-09-25 — Gain search made device-agnostic: GainModel (patch 0022)
13+
14+ Lifted the HackRF-specific gain logic into a ` GainModel ` abstraction so auto-gain
15+ works on any SDR, not just the HackRF One. ` search_gain ` is now a thin wrapper;
16+ the engine underneath drives an arbitrary radio. Suite: 797 → 808 passed.
17+
18+ - ** ` GainStage ` + ` GainModel ` ** describe a radio's gain as named stages, each
19+ "frontend" (improves SNR: RF amp, LNA, a tuner's single gain) or "level"
20+ (sets ADC level after SNR: the HackRF VGA). Factories: ` hackrf_gain_model() `
21+ (LNA + optional +14 dB amp + VGA) and ` rtlsdr_gain_model() ` (one gain knob,
22+ no separate level stage).
23+ - ** ` search_gain_model(probe, model, quality=…) ` ** is the general engine: the
24+ same two-stage search (front end for SNR at minimum level, then walk the level
25+ stage for the ADC window) over any model. When a radio has no separate level
26+ stage, it walks the SNR-chosen knob itself rather than pretending a second knob
27+ exists -- the honest single-knob behavior.
28+ - ** ` search_gain ` is now a wrapper** over the engine with the HackRF model,
29+ keeping the ` probe(lna, vga, amp) ` signature and ` {lna,vga,amp,…} ` result
30+ unchanged; the 13 existing ` test_gain_and_health ` tests pass untouched, which
31+ is the fidelity proof.
32+ - ** The SNR-first guarantee now holds on a second radio.** New
33+ ` tests/test_gain_model.py ` (11 tests) drives a single-knob (RTL-SDR-like) mock
34+ tuner: it converges in-window on a decent antenna, reports ` too_weak ` on a
35+ DEAD antenna (does not bless amplified noise -- the whole point of the
36+ SNR-first design), reports ` clipping ` on overload, and stays on the gain grid.
37+ The same engine still enables the amp for a weak HackRF antenna.
38+
39+ Caveat: the single-knob path is validated against a mock tuner model, not a real
40+ RTL-SDR (none on hand). A coarse gain grid can leave the level just shy of the
41+ window near a boundary; the search reports that honestly rather than faking
42+ success. This closes the one device-coupling the modularity docs called out --
43+ RX, TX, and gain search are all radio-agnostic now.
44+
45+ ## 2026-09-25 (earlier) — Modularity kit: prove the library isn't HackRF-locked (patch 0021)
46+
47+ Made device-independence concrete AND enforced, so "any SDR works" is a test
48+ result rather than a claim. Suite: 785 → 797 passed.
49+
50+ - ** ` tests/test_modularity.py ` (7 tests)** -- the guard with teeth. It AST-scans
51+ ` src/sdr_dsp/ ` and FAILS if any core module imports a device SDK (hackrfpy,
52+ pyrtlsdr, SoapySDR, uhd, …); a deliberately smuggled ` import hackrfpy ` was
53+ confirmed to trip it. It also drives the full pipeline (PSD, pilot check) from
54+ a NON-HackRF source, so the structural guarantee is checked both ways.
55+ - ** ` examples/sdr_adapter_template.py ` ** -- a copy-and-fill adapter (IQSource +
56+ guarded TXSink) with the four device-specific spots marked, sample-format
57+ normalization (cu8, the RTL-SDR wire format) as the worked example, and a
58+ built-in fake device so it self-demonstrates end to end with no hardware.
59+ - ** ` docs/ADDING_AN_SDR.md ` ** -- the porting process and record: the steps, the
60+ normalization table, the "free vs. needs-work" split (RX/TX free; auto-gain
61+ WAS the one HackRF-shaped piece, since fixed in 0022), and a per-SDR
62+ porting-record table (HackRF + template filled, RTL-SDR stubbed to copy).
63+ - ** ` docs/MODULARITY.md ` ** (written just before the kit) -- the HAL layer model
64+ (core / ` IQSource ` +` TXSink ` Protocols / device adapter / hardware) with a
65+ diagram and the sample-format normalization table.
66+
67+ ## 2026-09-24 (later) — Typing gate tightened; py.typed made honest (patches 0019–0020)
68+
69+ The package ships ` src/sdr_dsp/py.typed ` , but the pyproject comment still claimed
70+ it did not and kept the strict flags off. Corrected the record and took a real
71+ step toward typed. Suite: 785 (unchanged); mypy now stricter.
72+
73+ - ** ` check_untyped_defs ` enabled** in ` [tool.mypy] ` -- mypy now type-checks the
74+ BODIES of unannotated functions (catches real bugs without demanding
75+ signatures everywhere). Fixed the 10 issues it surfaced: empty-container
76+ annotations (` bits: list[int] = [] ` in psk/qam, deques in arq, dicts in
77+ drivers/pipeline), a ` results ` refactor in the pipeline (drop the ` list|None `
78+ union that the append site couldn't prove safe), and int-casting the edge
79+ indices in ` find_bursts ` so the spans list is uniformly typed.
80+ - ** pyproject comment corrected** : py.typed IS shipped; the full strict
81+ disallow-untyped flags remain the per-module annotation campaign (~ 350 errors
82+ under ` --strict ` today), turned on as each module is annotated.
83+ - ** ` [project.urls] ` ** (patch 0019): Homepage / Repository / Issues, now that
84+ the repo is public.
85+
86+ ## 2026-09-24 — TX examples across the sink seam (patch 0018)
87+
88+ ` examples/transmit_examples.py ` : modulate the library's own schemes as framed
89+ packets and key them through a ` TXSink ` , proving ` demod(modulate(x)) == x ` across
90+ the device boundary in software. The last big no-hardware chunk before the
91+ two-SDR bench test. Suite: 756 → 785 passed.
92+
93+ - ** OOK / FSK / BPSK / QPSK, framed** (` build_frame ` → modulate → sink → read
94+ back → demod → ` find_frames ` ), all recovered with a valid CRC. Offline by
95+ default via ` LoopbackSink ` ; the example self-checks (nonzero exit if any scheme
96+ fails to round-trip). ` --sink hackrf ` drives the guarded ` HackRFSink ` to the
97+ device seam and stops there -- the actual radiation is the bench step.
98+ - ** RX chains are the hardware-robust ones** from the demod docstrings and
99+ ` two_station_link ` : smoothed auto-threshold FSK, transition-phase symbol
100+ decimation via ` sample_symbols ` , guard-padded bursts. QPSK decimates at known
101+ symbol centers on the clean loopback (unknown-delay timing recovery is the
102+ Phase E two-SDR concern, noted in the code).
103+ - ** 24 offline tests** (` test_transmit_examples.py ` ): every scheme × four
104+ payloads, ` key_out ` genuinely drives the sink protocol, a non-buffering
105+ device-like sink is handled without crashing, and the ` HackRFSink ` seam is
106+ confirmed guarded (` armed=False ` , refuses to transmit).
107+
108+ ## 2026-09-24 (earlier) — FM carrier-offset estimate & correct (patch 0017)
109+
110+ New core capability: ` estimate_fm_cfo ` / ` correct_fm_cfo ` , validated on the real
111+ 103.7 MHz capture's genuine crystal offset. Suite: 746 → 756 passed.
112+
113+ - ** Method: the discriminator DC term.** Under a constant carrier offset Δf the
114+ differential discriminator picks up that same Δf at every sample, while the
115+ program audio averages to zero -- so the MEAN of the demodulated instantaneous
116+ frequency IS the offset. Channelized first (like ` fm_pilot_excess_db ` ) so
117+ out-of-channel energy doesn't corrupt the mean; impulsive discriminator clicks
118+ are trimmed before averaging.
119+ - ** NOT the pilot.** With a differential discriminator a carrier offset adds a DC
120+ term but does NOT move the 19 kHz pilot (differentiation is invariant to a
121+ constant frequency shift), so the pilot carries no CFO information -- the trap
122+ the TODO's "or the pilot frequency" wording invited. A test pins this so nobody
123+ "improves" the estimator into it; the pilot's real role is a presence gate.
124+ - ** ` correct_fm_cfo ` ** reuses ` frequency_shift(-cfo) ` and returns
125+ ` (corrected_iq, cfo_hz) ` so the applied correction is visible; estimation never
126+ mutates data (separate call), matching ` estimate_cfo ` 's contract.
127+ - ** Validated:** synthetic offsets (±500 Hz to ±12 kHz) recovered to <10 Hz with
128+ correct sign; the real capture carries −315 Hz (~ 3 ppm) corrected to −2 Hz,
129+ stable across the two halves (a real offset, not noise). 10 tests
130+ (` test_cfo.py ` ).
131+
132+ ## 2026-09-23 (later) — Sub-8 Msps warning dedup; lint gate re-green (patches 0015–0016)
133+
134+ Two hygiene fixes after the CI landing. Suite: 744 → 746 passed.
135+
136+ - ** The sub-8 Msps warning now dedupes for real (0016).** ` dedup_warnings ` used
137+ ` redirect_stderr ` , but hackrfpy emits the notice through
138+ ` logging.getLogger("hackrfpy").warning() ` ; once anything installs a
139+ ` StreamHandler ` bound to the real stderr (a plain ` logging.basicConfig ` does
140+ it), ` redirect_stderr ` never sees the record and the same line printed once per
141+ probe (~ 16× per station in preflight). Fixed at the logging layer: a
142+ ` logging.Filter ` on the hackrfpy logger drops repeat WARNING+ records before
143+ any handler, bound or not; INFO progress chatter is left alone. Two regression
144+ tests cover the bound-handler path the old code missed.
145+ - ** Lint gate re-greened (0015).** Three ruff errors had slipped into two
146+ untracked diagnostic scripts that never passed the gate --
147+ ` tools/diagnose_rate.py ` (unused numpy import; a lambda assignment) and
148+ ` tools/diagnose_path.py ` (missing trailing newline). Fixed in-style (fix, not
149+ a blanket per-file-ignore, matching ` preflight_collection ` ), so CI's ruff step
150+ is green again.
151+
12152## 2026-09-23 — CI pipeline, typing gate, RNG determinism (+ the real flake)
13153
14154Made the pipeline trustworthy: a CI workflow, a green mypy gate, deterministic
0 commit comments