Skip to content

Commit 8ce635c

Browse files
committed
hackrf one hardware dedup, pre0full modularity
1 parent 722c416 commit 8ce635c

4 files changed

Lines changed: 147 additions & 2 deletions

File tree

‎sdr_dsp/src/sdr_dsp/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
frequency_shift, tune_to_baseband, remove_dc,
2626
power_dbfs, power_dbm, Calibration, compute_cal_offset, agc, AGC, channelize,
2727
channelize_bank, snr_db, occupied_bandwidth, find_bursts, estimate_cfo,
28+
estimate_fm_cfo, correct_fm_cfo,
2829
fm_demod, am_demod, ook_envelope, ook_slice,
2930
edges, estimate_symbol_rate, slice_to_symbols, sample_symbols, deemphasis,
3031
build_frame, find_frames, crc16,
@@ -77,6 +78,7 @@ def __getattr__(name):
7778
"power_dbfs", "power_dbm", "Calibration", "compute_cal_offset", "agc", "AGC",
7879
"channelize", "channelize_bank", "snr_db", "occupied_bandwidth",
7980
"find_bursts", "estimate_cfo",
81+
"estimate_fm_cfo", "correct_fm_cfo",
8082
"fm_demod", "am_demod", "ook_envelope", "ook_slice",
8183
"edges", "estimate_symbol_rate", "slice_to_symbols", "sample_symbols", "deemphasis",
8284
"build_frame", "find_frames", "crc16",

‎sdr_dsp/src/sdr_dsp/core/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
from .gain_search import search_gain, peak_counts
3131
from .measure import (power_dbfs, snr_db, occupied_bandwidth, capture_health,
3232
fm_pilot_excess_db,
33-
find_bursts, estimate_cfo)
33+
find_bursts, estimate_cfo, estimate_fm_cfo,
34+
correct_fm_cfo)
3435
from .detect import matched_filter, detect_peak, correlate, convolve
3536
from .sync import carrier_recovery, symbol_sync, LoopDiagnostics
3637
from .demod import (fm_demod, am_demod, dsb_sc_demod, cw_decode,
@@ -64,6 +65,7 @@
6465
"rrc_taps", "upsample", "pulse_shape", "snr_db", "occupied_bandwidth", "capture_health",
6566
"fm_pilot_excess_db", "search_gain", "peak_counts",
6667
"find_bursts", "estimate_cfo",
68+
"estimate_fm_cfo", "correct_fm_cfo",
6769
"fm_demod", "am_demod", "ook_envelope", "ook_slice",
6870
"instantaneous_phase", "instantaneous_frequency",
6971
"fsk_demod", "fsk_demod_nlevel", "ssb_demod", "dsb_sc_demod", "cw_decode",

‎sdr_dsp/src/sdr_dsp/core/measure.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,95 @@ def estimate_cfo(iq, sample_rate, nfft=None):
320320
spec = np.abs(np.fft.fftshift(np.fft.fft(iq[:nfft], nfft)))
321321
freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / sample_rate))
322322
return float(freqs[int(np.argmax(spec))])
323+
324+
325+
def estimate_fm_cfo(iq, sample_rate, *, channel_bw=100_000.0, trim_hz=None):
326+
"""Estimate a broadcast-FM capture's carrier frequency offset, in Hz.
327+
328+
Method: the phase discriminator's DC term. Under a constant carrier
329+
offset df, ``angle(x[n] * conj(x[n-1]))`` picks up that same df at every
330+
sample, while the program audio (the wanted FM message) averages to zero
331+
over a long window. So the MEAN of the demodulated instantaneous frequency
332+
IS the carrier offset. This is the fine, accurate estimator for FM, and is
333+
distinct from ``estimate_cfo``: that one finds where energy sits in the
334+
passband (a coarse, spread-out measurement for a wideband FM signal),
335+
whereas this recovers the sub-kHz crystal offset a real receiver must
336+
correct.
337+
338+
The capture is channelized (lowpass + decimate to the FM channel) before
339+
discriminating, for exactly the reason ``fm_pilot_excess_db`` documents: a
340+
discriminator fed the full capture bandwidth is dominated by out-of-channel
341+
noise and adjacent stations, and the mean of that is meaningless. On the
342+
channelized signal the mean is the offset.
343+
344+
Why NOT the 19 kHz pilot: with a differential discriminator a carrier
345+
offset adds a DC term but does NOT move the pilot -- differentiation is
346+
invariant to a constant frequency shift -- so the pilot's position carries
347+
no CFO information (this is the trap the TODO's "or the pilot frequency"
348+
wording invites). The pilot's real role here is a presence gate: confirm a
349+
station is actually there with ``fm_pilot_excess_db`` before trusting this
350+
number, because the mean of noise is not a carrier offset.
351+
352+
Args:
353+
channel_bw: FM channel half-not-needed; used only to size the click
354+
trim. Broadcast FM is ~100 kHz half-bandwidth of program content.
355+
trim_hz: discriminator "clicks" (phase wraps in deep fades / low SNR)
356+
are impulsive outliers that bias a plain mean. Samples whose
357+
demodulated frequency exceeds trim_hz in magnitude are dropped
358+
before averaging. Default 1.5 * channel_bw keeps the full +/-75 kHz
359+
deviation swing while rejecting the near-+/-Nyquist click spikes.
360+
Pass None to disable trimming.
361+
362+
Returns the offset in Hz (positive = the signal sits above the tuned
363+
center), or None when the capture is too short or the rate too low to
364+
channelize to the FM channel.
365+
366+
OUR code.
367+
"""
368+
iq = np.asarray(iq, dtype=np.complex64)
369+
if sample_rate <= 4.0 * channel_bw or iq.size < 4096:
370+
return None
371+
# Channelize to the FM channel, mirroring fm_pilot_excess_db: lowpass and
372+
# decimate to ~500 kSps before discriminating so out-of-channel energy
373+
# does not pollute the mean. Taps scale with the decimation.
374+
decim = max(1, int(sample_rate // 480_000))
375+
if decim > 1:
376+
from .filters import design_lowpass, fir_apply
377+
taps = design_lowpass(120_000.0, sample_rate, num_taps=8 * decim + 1)
378+
iq = fir_apply(iq, taps)[::decim]
379+
sample_rate = sample_rate / decim
380+
if iq.size < 2:
381+
return None
382+
# phase discriminator -> instantaneous frequency in Hz
383+
inst_hz = (np.angle(iq[1:] * np.conj(iq[:-1])).astype(np.float64)
384+
* sample_rate / (2.0 * np.pi))
385+
trim = 1.5 * float(channel_bw) if trim_hz is None else float(trim_hz)
386+
if trim > 0:
387+
keep = np.abs(inst_hz) <= trim
388+
if keep.any():
389+
inst_hz = inst_hz[keep]
390+
if inst_hz.size == 0:
391+
return None
392+
return float(np.mean(inst_hz))
393+
394+
395+
def correct_fm_cfo(iq, sample_rate, cfo_hz=None, **kwargs):
396+
"""Remove a broadcast-FM carrier frequency offset. OUR code.
397+
398+
If ``cfo_hz`` is None it is measured with ``estimate_fm_cfo`` (any keyword
399+
arguments are forwarded to it). The correction is a frequency shift by
400+
``-cfo_hz`` -- ``frequency_shift(iq, -cfo_hz, sample_rate)`` -- which brings
401+
the carrier back to center. Estimating and correcting are separated on
402+
purpose (``estimate_fm_cfo`` never mutates data), and this is the
403+
convenience that does both in one call for the common case.
404+
405+
Returns ``(corrected_iq, cfo_hz)`` so the applied correction is visible to
406+
the caller. When the offset cannot be estimated (capture too short or rate
407+
too low), returns the input unchanged with ``cfo_hz = 0.0``.
408+
"""
409+
if cfo_hz is None:
410+
cfo_hz = estimate_fm_cfo(iq, sample_rate, **kwargs)
411+
if cfo_hz is None:
412+
return np.asarray(iq, dtype=np.complex64), 0.0
413+
from .mixing import frequency_shift
414+
return frequency_shift(iq, -float(cfo_hz), sample_rate), float(cfo_hz)

‎sdr_dsp/src/sdr_dsp/sources/probe.py‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import contextlib
3131
import io
32+
import logging
3233
import os
3334
import sys
3435
import tempfile
@@ -67,16 +68,64 @@ def flush(self):
6768
self._real.flush()
6869

6970

71+
class _WarnDedupFilter(logging.Filter):
72+
"""Drop repeated WARNING+ records from a logger, once per process.
73+
74+
Attached to hackrfpy's logger by ``dedup_warnings``. Runs at record-
75+
dispatch time, before handlers, so it collapses duplicates regardless of
76+
how the record eventually reaches stderr. Keeps its own memory (separate
77+
from the stream wrapper's ``_seen_warnings``) so the first, legitimate
78+
occurrence is never mistaken for a repeat by the other layer.
79+
"""
80+
81+
def __init__(self) -> None:
82+
super().__init__()
83+
self._seen: set = set()
84+
85+
def filter(self, record: logging.LogRecord) -> bool:
86+
if record.levelno < logging.WARNING:
87+
return True # leave INFO/progress chatter untouched
88+
msg = record.getMessage()
89+
if msg in self._seen:
90+
return False
91+
self._seen.add(msg)
92+
return True
93+
94+
95+
_HACKRFPY_LOG = logging.getLogger("hackrfpy")
96+
_hackrfpy_warn_dedup = _WarnDedupFilter()
97+
98+
7099
@contextlib.contextmanager
71100
def dedup_warnings():
72-
"""Suppress repeated "[!] ..." warning lines on stdout/stderr."""
101+
"""Suppress repeated "[!] ..." warning lines on stdout/stderr.
102+
103+
Warnings reach the console two different ways, so this collapses both:
104+
105+
* hackrfpy emits its "[!] ..." notices through
106+
``logging.getLogger("hackrfpy").warning(...)``. A logging ``Filter`` on
107+
that logger drops repeat records *before they reach any handler*, so it
108+
works whether the record is written by a ``StreamHandler`` bound to the
109+
real ``sys.stderr`` or by logging's last-resort handler. The stream
110+
redirect below only ever caught the last-resort case -- once anything
111+
installs a bound handler (a plain ``logging.basicConfig`` in a tool is
112+
enough), ``redirect_stderr`` no longer sees the record and the same
113+
sub-8-Msps line printed once per probe (~16x per station in preflight).
114+
The filter is scoped to WARNING+ so INFO progress chatter on the same
115+
logger (which legitimately repeats per probe) is left alone.
116+
* Anything written straight to stdout/stderr (direct ``print`` of a
117+
"[!] " line) is line-filtered by the stream wrapper, preserving that
118+
contract for callers that don't go through logging.
119+
"""
73120
out, err = _DedupWarnings(sys.stdout), _DedupWarnings(sys.stderr)
121+
_HACKRFPY_LOG.addFilter(_hackrfpy_warn_dedup)
74122
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
75123
try:
76124
yield
77125
finally:
78126
out.flush()
79127
err.flush()
128+
_HACKRFPY_LOG.removeFilter(_hackrfpy_warn_dedup)
80129

81130

82131
def load_ci8(path, max_samples=None):

0 commit comments

Comments
 (0)