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>
373 lines
14 KiB
Python
Executable file
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)
|