bandsaunter/bandsaunter/ui.py
The Dust Council db3e0c79b9 Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:20 -07:00

373 lines
14 KiB
Python
Executable file

"""Live terminal display for a running scan."""
from __future__ import annotations
import select
import sys
import termios
import time
import tty
from collections import deque
from dataclasses import dataclass
import numpy as np
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from .bandplan import fmt_hz
from .recorder import HitRecord
from .scanner import Detection, Scanner
__all__ = ["ScanDisplay", "KeyReader", "print_hit", "print_band_table"]
_SPARK = " ▁▂▃▄▅▆▇█"
def _sparkline(values: np.ndarray, width: int = 60,
lo: float | None = None, hi: float | None = None) -> str:
"""Compress a spectrum into one row of block characters."""
v = np.asarray(values, dtype=np.float64)
if v.size == 0:
return " " * width
if v.size > width:
# Max-reduce rather than average: a narrow carrier must stay visible.
edges = np.linspace(0, v.size, width + 1).astype(int)
v = np.array([v[edges[i]:edges[i + 1]].max() if edges[i + 1] > edges[i]
else v[min(edges[i], v.size - 1)] for i in range(width)])
lo = float(np.percentile(v, 5)) if lo is None else lo
hi = float(np.percentile(v, 99.5)) if hi is None else hi
if hi <= lo:
hi = lo + 1.0
idx = np.clip((v - lo) / (hi - lo) * (len(_SPARK) - 1), 0,
len(_SPARK) - 1).astype(int)
return "".join(_SPARK[i] for i in idx)
class KeyReader:
"""Non-blocking single-key input, restoring the terminal on exit."""
def __init__(self, enabled: bool = True):
self.enabled = enabled and sys.stdin.isatty()
self._old = None
def __enter__(self):
if self.enabled:
try:
self._old = termios.tcgetattr(sys.stdin)
tty.setcbreak(sys.stdin.fileno())
except (termios.error, ValueError):
self.enabled = False
return self
def __exit__(self, *exc):
if self._old is not None:
try:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old)
except (termios.error, ValueError):
pass
return False
def get(self) -> str | None:
if not self.enabled:
return None
try:
r, _, _ = select.select([sys.stdin], [], [], 0)
if r:
return sys.stdin.read(1)
except (OSError, ValueError):
return None
return None
@dataclass
class _RecState:
frequency: float = 0.0
mode: str = ""
elapsed: float = 0.0
snr: float = 0.0
present: bool = False
quiet_for: float = 0.0
active: bool = False
class ScanDisplay:
"""Renders scanner callbacks into a live dashboard.
Attach with ``display.attach(scanner)``; the scanner then drives it.
"""
def __init__(self, scanner: Scanner, console: Console | None = None,
max_hits: int = 12, show_spectrum: bool = True):
self.scanner = scanner
self.console = console or Console()
self.hits: deque[HitRecord] = deque(maxlen=max_hits)
self.messages: deque[str] = deque(maxlen=4)
self.show_spectrum = show_spectrum
self._spark = ""
self._span = ""
self._peak = 0.0
self._step_i = 0
self._n_steps = 1
self._rec = _RecState()
self._last_detection: Detection | None = None
self._dirty = True
# -- callbacks --------------------------------------------------------
def attach(self, scanner: Scanner | None = None) -> None:
s = scanner or self.scanner
cb = s.cb
cb.on_step = self.on_step
cb.on_detection = self.on_detection
cb.on_record_start = self.on_record_start
cb.on_record_tick = self.on_record_tick
cb.on_record_end = self.on_record_end
cb.on_status = self.on_status
cb.on_error = self.on_error
def on_step(self, i, n, step, psd_db, freqs):
self._step_i, self._n_steps = i, n
if self.show_spectrum:
inband = (freqs >= step.low) & (freqs <= step.high)
if np.any(inband):
self._spark = _sparkline(psd_db[inband], width=self._spark_width())
self._peak = float(psd_db[inband].max())
self._span = f"{fmt_hz(step.low)} - {fmt_hz(step.high)}"
self._dirty = True
def on_detection(self, det: Detection):
self._last_detection = det
self._dirty = True
def on_record_start(self, det, rec):
self._rec = _RecState(frequency=rec.frequency, mode=rec.mode,
active=True)
self._dirty = True
def on_record_tick(self, rec, elapsed, present, snr, quiet_for=0.0):
self._rec.elapsed = elapsed
self._rec.present = present
self._rec.snr = snr
self._rec.quiet_for = quiet_for
self._rec.mode = rec.mode
self._dirty = True
def on_record_end(self, hit: HitRecord):
self._rec.active = False
if hit.kept:
self.hits.appendleft(hit)
self._dirty = True
def on_status(self, msg: str):
self.messages.appendleft(msg)
self._dirty = True
def on_error(self, exc: Exception):
self.messages.appendleft(f"[red]{type(exc).__name__}: {exc}[/red]")
self._dirty = True
# -- rendering ---------------------------------------------------------
def _spark_width(self) -> int:
return max(20, min(120, self.console.width - 24))
def _header(self) -> Panel:
d = self.scanner.device
st = d.status() if d else {}
gain = st.get("gain", "?")
gain = f"{gain:.1f} dB" if isinstance(gain, (int, float)) else str(gain)
ds = st.get("direct_sampling", 0)
bits = [
f"[bold]{st.get('tuner', '?')}[/bold]",
f"{st.get('sample_rate', 0)/1e6:.3f} MS/s",
f"gain {gain}",
f"{st.get('ppm', 0):+d} ppm",
]
if ds:
bits.append("[yellow]direct sampling[/yellow]")
if st.get("simulated"):
bits.append("[magenta]SIMULATED[/magenta]")
return Panel(Text.from_markup(" ".join(bits)),
title="receiver", border_style="blue", padding=(0, 1))
def _sweep_panel(self, show_spectrum: bool = True) -> Panel:
s = self.scanner.stats
frac = (self._step_i + 1) / max(1, self._n_steps)
bar_w = max(10, min(40, self.console.width - 60))
filled = int(frac * bar_w)
bar = "[green]" + "" * filled + "[/green]" + \
"[grey37]" + "" * (bar_w - filled) + "[/grey37]"
lines = [
Text.from_markup(
f"{bar} step {self._step_i + 1}/{self._n_steps} "
f"[bold]{self._span}[/bold]"),
]
if show_spectrum and self.show_spectrum and self._spark:
lines.append(Text.from_markup(
f"[cyan]{self._spark}[/cyan] peak {self._peak:6.1f} dBFS"))
state = s.state
colour = {"recording": "red", "sweeping": "green",
"paused": "yellow"}.get(state, "white")
lines.append(Text.from_markup(
f"[{colour}]{state}[/{colour}] cycle {s.cycles + 1} "
f"hits {s.recordings} dropped {s.discarded} "
f"detections {s.detections} up {_dur(s.elapsed)}"))
return Panel(Group(*lines), title="sweep", border_style="blue",
padding=(0, 1))
def _record_panel(self) -> Panel | None:
r = self._rec
if not r.active:
return None
cfg = self.scanner.cfg
limit = cfg.record_seconds
bar_w = max(10, min(30, self.console.width - 70))
frac = min(1.0, r.elapsed / limit) if limit else 0.0
filled = int(frac * bar_w)
bar = ("[red]" + "" * filled + "[/red]" +
"[grey37]" + "" * (bar_w - filled) + "[/grey37]") if limit \
else "[red]recording[/red]"
hang = cfg.hang_seconds
if r.present:
sq = "[green]SIGNAL[/green]"
else:
# Show the gap counting down, so it is obvious the recording is
# being held open across a pause rather than stuck.
sq = f"[yellow]gap {min(r.quiet_for, hang):4.1f}/{hang:g}s[/yellow]"
limit_s = f"/{limit:g}s" if limit else ""
return Panel(
Text.from_markup(
f"[bold red]REC[/bold red] {fmt_hz(r.frequency)} "
f"[{r.mode}] {bar} {r.elapsed:5.1f}{limit_s} "
f"{sq} SNR {r.snr:5.1f} dB"),
border_style="red", padding=(0, 1))
def _layout(self) -> tuple[bool, int, bool]:
"""Decide what fits: ``(spectrum row, hit rows, footer)``.
On a short terminal the optional parts are given up in order --
spectrum, then the hit list, then the key hints -- so the receiver and
sweep panels always fit. A frame taller than the terminal cannot be
redrawn in place, and every refresh would leave another copy of it
behind, which is why the header ends up on screen several times over.
"""
height = self.console.size.height or 24
budget = max(6, height - 1)
rec = 3 if self._rec.active else 0
spectrum = bool(self.show_spectrum and self._spark)
for want_spectrum in ((True, False) if spectrum else (False,)):
for want_footer in (True, False):
base = 3 + (4 if want_spectrum else 3) + rec + \
(3 if want_footer else 0)
rows = budget - base - 4 # 4 = hits panel chrome
if rows >= 1:
return want_spectrum, rows, want_footer
if budget - base >= 0:
return want_spectrum, 0, want_footer
return False, 0, False
def _hit_capacity(self) -> int:
"""How many hit rows fit without pushing the display off the screen.
A display taller than the terminal cannot be redrawn in place, so each
refresh scrolls another copy of it into the scrollback and the header
appears over and over. The number of hits grows as the scan runs,
which is why it starts fine and degrades.
"""
return self._layout()[1]
def _hits_table(self) -> Panel:
t = Table(box=None, expand=True, pad_edge=False, show_edge=False)
t.add_column("time", style="grey62", width=8)
t.add_column("frequency", style="bold cyan", width=15, justify="right")
t.add_column("dur", width=6, justify="right")
t.add_column("SNR", width=7, justify="right")
t.add_column("identified as", ratio=1, overflow="ellipsis")
capacity = self._hit_capacity()
shown = list(self.hits)[:capacity]
for h in shown:
extra = ""
if h.morse_text:
extra = f' [yellow]"{h.morse_text.strip()[:32]}"[/yellow]'
elif h.ctcss_hz:
extra = f" [grey62]CTCSS {h.ctcss_hz:.1f}[/grey62]"
elif h.baud:
extra = f" [grey62]{h.baud:.0f} baud[/grey62]"
conf = h.confidence
colour = "green" if conf >= 0.7 else "yellow" if conf >= 0.45 else "grey62"
t.add_row(
time.strftime("%H:%M:%S", time.localtime(h.started_at)),
fmt_hz(h.frequency),
f"{h.duration:.1f}s",
f"{h.snr_db:.1f}",
Text.from_markup(
f"[{colour}]{h.classification or 'unclassified'}[/{colour}]{extra}"),
)
if not self.hits:
t.add_row("", "", "", "", Text("no signals recorded yet",
style="grey42"))
hidden = len(self.hits) - len(shown)
title = "recorded signals"
if hidden > 0:
title += f" [grey62]({hidden} more above)[/grey62]"
return Panel(t, title=title, border_style="blue", padding=(0, 1))
def _footer(self) -> Panel:
keys = ("[bold]q[/bold] quit [bold]p[/bold] pause "
"[bold]s[/bold] skip [bold]l[/bold] lock out "
"[bold]+/-[/bold] threshold")
msg = self.messages[0] if self.messages else ""
return Panel(Text.from_markup(f"{keys} {msg}"),
border_style="grey37", padding=(0, 1))
def render(self):
spectrum, rows, footer = self._layout()
parts = [self._header(), self._sweep_panel(show_spectrum=spectrum)]
rec = self._record_panel()
if rec is not None:
parts.append(rec)
if rows > 0:
parts.append(self._hits_table())
if footer:
parts.append(self._footer())
return Group(*parts)
def _dur(seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def print_hit(console: Console, hit: HitRecord) -> None:
"""One-line-per-hit output for non-interactive runs."""
if not hit.kept:
return
stamp = time.strftime("%H:%M:%S", time.localtime(hit.started_at))
conf = f"{hit.confidence*100:.0f}%"
line = (f"[grey62]{stamp}[/grey62] [bold cyan]{fmt_hz(hit.frequency):>14}[/bold cyan]"
f" {hit.duration:5.1f}s SNR {hit.snr_db:5.1f} dB "
f"[green]{hit.classification or 'unclassified'}[/green] ({conf})")
console.print(line, highlight=False)
if hit.morse_text:
console.print(f'{"":>26}[yellow]Morse @ {hit.morse_wpm:.0f} WPM: '
f'"{hit.morse_text.strip()}"[/yellow]', highlight=False)
if hit.reasons:
console.print(f'{"":>26}[grey54]{hit.reasons[0]}[/grey54]', highlight=False)
def print_band_table(console: Console, presets, title: str = "band plan") -> None:
t = Table(title=title, box=None, header_style="bold")
t.add_column("key", style="cyan")
t.add_column("name")
t.add_column("range", justify="right")
t.add_column("mode", justify="center")
t.add_column("notes", style="grey62", overflow="fold")
for p in presets:
extent = (f"{len(p.expand())} ranges, {fmt_hz(p.start)}-{fmt_hz(p.stop)}"
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
t.add_row(p.key, p.name, extent, p.mode, p.note)
console.print(t)