Skip to content

Commit 1e8aaf4

Browse files
committed
persistent waterfall example
1 parent 83b69fb commit 1e8aaf4

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#! /usr/bin/python3
2+
##--------------------------------------------------------------------\
3+
# hackrfpy 'examples/waterfall_persistent.py'
4+
# Single-frequency spectrum waterfall over time, driven by a PERSISTENT
5+
# receiver. Needs the [plotting] extra.
6+
#
7+
# This is the COMPLEMENT to waterfall_realtime.py, not a replacement:
8+
# - waterfall_realtime.py : SWEEP-based. x-axis = frequency across a wide
9+
# band; shows WHERE activity is across the spectrum.
10+
# - waterfall_persistent.py (this) : RECEIVER-based. Locks ONE center
11+
# frequency, FFTs each block; x-axis = frequency WITHIN the capture
12+
# bandwidth; shows how one channel evolves over time at full rate.
13+
#
14+
# It uses open_receiver() so the hackrf_transfer process spins up once and
15+
# streams continuously -- the natural fit for a live single-frequency view.
16+
# Read-only / receive-only; the receiver is reaped on exit.
17+
#
18+
# Usage:
19+
# uv run python examples/waterfall_persistent.py --freq 100e6 --rate 8e6
20+
# uv run python examples/waterfall_persistent.py --freq 2437e6 --rate 10e6
21+
##--------------------------------------------------------------------\
22+
import argparse
23+
import sys
24+
import numpy as np
25+
import matplotlib.pyplot as plt
26+
from matplotlib import colors
27+
from hackrfpy import HackRF
28+
29+
# ---- aesthetic: same signals-monitor identity as the sweep waterfall ------
30+
BG = "#0a0e14"
31+
PANEL = "#0d1320"
32+
GRID = "#1c2738"
33+
ACCENT = "#36e0c8"
34+
TEXT = "#9fb3c8"
35+
TEXTDIM = "#52617a"
36+
CMAP = "turbo"
37+
38+
plt.rcParams.update({
39+
"figure.facecolor": BG, "axes.facecolor": PANEL, "savefig.facecolor": BG,
40+
"font.family": "monospace", "text.color": TEXT, "axes.edgecolor": GRID,
41+
"axes.labelcolor": TEXT, "xtick.color": TEXTDIM, "ytick.color": TEXTDIM,
42+
"axes.linewidth": 0.8,
43+
})
44+
45+
46+
def main():
47+
p = argparse.ArgumentParser(description="Single-freq waterfall (persistent RX).")
48+
p.add_argument("--freq", default="100e6", help="center frequency Hz")
49+
p.add_argument("--rate", default="8e6", help="sample rate sps")
50+
p.add_argument("--fft", type=int, default=1024, help="FFT size (bins)")
51+
p.add_argument("--rows", type=int, default=400, help="time history depth")
52+
p.add_argument("--tools-dir", default=None)
53+
args = p.parse_args()
54+
freq, rate, nfft, rows = float(args.freq), float(args.rate), args.fft, args.rows
55+
56+
h = HackRF(tools_dir=args.tools_dir)
57+
det = h.detect()
58+
if not det["ready"]:
59+
print(f"no usable HackRF: {det['problem']}", file=sys.stderr)
60+
return 1
61+
62+
# color scale in dBFS-ish FFT magnitude; fixed so colors are stable
63+
DB_FLOOR, DB_CEIL = -90, -20
64+
win = np.hanning(nfft).astype(np.float32)
65+
66+
def spectrum(iq):
67+
# Average several FFT frames across the big block for a smoother line,
68+
# then suppress the center DC / LO-leakage spike that every direct-
69+
# conversion SDR shows at 0 Hz (the bright line dead-center). We
70+
# replace the few central bins with their neighbors so a real signal
71+
# isn't hidden under the artifact.
72+
nframes = max(1, len(iq) // nfft)
73+
acc = np.zeros(nfft, dtype=np.float64)
74+
for k in range(nframes):
75+
seg = iq[k * nfft:(k + 1) * nfft]
76+
if len(seg) < nfft:
77+
break
78+
sp = np.fft.fftshift(np.fft.fft(seg * win))
79+
acc += (np.abs(sp) / nfft) ** 2
80+
acc /= nframes
81+
db = 10 * np.log10(acc + 1e-12)
82+
# flatten the DC spike: overwrite the center +/-2 bins with a neighbor
83+
c = nfft // 2
84+
db[c - 2:c + 3] = db[c + 3]
85+
return db
86+
87+
fig, ax = plt.subplots(figsize=(11, 6))
88+
try:
89+
fig.canvas.manager.set_window_title(
90+
"hackrfpy :: persistent-RX waterfall")
91+
except Exception:
92+
pass
93+
94+
history = []
95+
img = None
96+
cbar = None
97+
# frequency axis: center +/- rate/2, in MHz
98+
f_lo = (freq - rate / 2) / 1e6
99+
f_hi = (freq + rate / 2) / 1e6
100+
101+
print(f"[*] persistent waterfall @ {freq/1e6:g} MHz, {rate/1e6:g} Msps, "
102+
f"{nfft}-pt FFT (close window or Ctrl-C to stop)")
103+
104+
try:
105+
with h.open_receiver(freq, rate) as rx:
106+
# Read a LARGE block per iteration so the pipe drains fast enough
107+
# to keep hackrf_transfer streaming (reading only nfft=1024 samples
108+
# per loop, with a redraw each time, stalls the device at 10 Msps).
109+
# Each big block becomes one averaged waterfall row.
110+
block_samples = 262144
111+
while True:
112+
iq = rx.read(block_samples)
113+
if len(iq) < nfft:
114+
break
115+
history.append(spectrum(iq))
116+
history = history[-rows:]
117+
arr = np.array(history)[::-1] # newest on top
118+
119+
if img is None:
120+
img = ax.imshow(
121+
arr, aspect="auto", cmap=CMAP,
122+
norm=colors.Normalize(vmin=DB_FLOOR, vmax=DB_CEIL),
123+
interpolation="nearest", origin="upper",
124+
extent=[f_lo, f_hi, 0, len(arr)])
125+
cbar = fig.colorbar(img, ax=ax, pad=0.01, fraction=0.046)
126+
cbar.set_label("magnitude (dB)", color=TEXT, fontsize=9)
127+
cbar.outline.set_edgecolor(GRID)
128+
plt.setp(plt.getp(cbar.ax, "yticklabels"), color=TEXTDIM)
129+
ax.set_title(
130+
f"SINGLE-FREQ WATERFALL \u2014 {freq/1e6:g} MHz "
131+
f"\u00b1 {rate/2e6:g} MHz",
132+
color=ACCENT, fontsize=13, fontweight="bold",
133+
loc="left", pad=12, family="monospace")
134+
ax.set_xlabel("frequency (MHz)", fontsize=9)
135+
ax.set_ylabel("time (newest at top)", fontsize=9)
136+
ax.set_yticks([])
137+
for s in ax.spines.values():
138+
s.set_color(GRID)
139+
fig.tight_layout()
140+
else:
141+
img.set_data(arr)
142+
img.set_extent([f_lo, f_hi, 0, len(arr)])
143+
plt.pause(0.001)
144+
if not plt.fignum_exists(fig.number):
145+
break
146+
except KeyboardInterrupt:
147+
pass
148+
print("[*] stopped")
149+
return 0
150+
151+
152+
if __name__ == "__main__":
153+
sys.exit(main())

0 commit comments

Comments
 (0)