Two additions, both about turning a number into something meaningful. A band column. Next to every frequency -- on the live display, in the line-per-hit output, in saunterbrowse's list and details -- is the name of the band it falls in. 421 MHz is the 70 cm amateur band, and being told so is quicker than remembering where the edges are. The names come from the existing preset table, so there is one band plan to keep right rather than two, but naming is not the job that table was shaped for: several presets cover any frequency, some of them whole-tuner sweeps that say nothing. So the candidates are ranked. Sweeps and the "-complete" duplicates are dropped outright. The narrowest of what is left wins, because it says the most -- 146.52 MHz comes back as the 2 m simplex calling channel rather than as the whole 2 m band. Two exceptions where the narrowest would be the wrong answer: ISM yields to the allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and shortwave broadcast yields to amateur where the two overlap, because 3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur, and this plan is documented as Region 2. 6 MHz really is 49 m shortwave and is left alone. The name is written into each capture's sidecar, so it travels with the recording and an edit to the plan later cannot rewrite history, and saunterbrowse searches on it: /70 cm finds the band without anyone having to remember 420-450 MHz. A map. A licence says where its holder is, so a list of callsigns is also a map. Callsigns heard during a scan are now looked up as the transcripts come in, announced on the display, and written to callsigns.kml in the output directory; saunterbrowse --kml builds the same file from recordings already on disk, and the two continue one map rather than starting two. One placemark per station, not one per transmission: the same repeater heard twenty times in an evening is one operator, and twenty pins on one rooftop would say less than one. Each pin carries the callsign, the licensee, the town, the grid square, and every frequency and time it was heard on. The file is read back on open and added to, so later scans build it up rather than replacing it. Where a licence has no coordinates the grid square's centre is used and the placemark says so -- a square is kilometres across where an address is a street. A callsign with no licence at all is still recorded, in a folder that starts switched off, because that a station was heard is worth keeping even when nothing says where. A file already there that is not readable as KML is never overwritten. Also fixed along the way: - The hit list's "no signals recorded yet" placeholder was one cell short of its row, so it landed in the SNR column and wrapped, making the panel taller than the layout had budgeted for and scrolling the display off a short terminal. The identification column can no longer wrap either, which is what _hit_capacity has always assumed. - Licence lookups now record coordinates. The cache is versioned so that entries written before this are asked about again, rather than pinning every station to its grid square for good. - CallsignBook.wait dropped joined threads; an all-night scan calls it after every transcript and the list only ever grew. - Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the real lookup cache. 676 -> 761 tests.
1278 lines
56 KiB
Python
Executable file
1278 lines
56 KiB
Python
Executable file
"""The scan engine.
|
|
|
|
Sweeps the configured ranges, detects energy above a learned noise floor,
|
|
drops onto each hit to record and identify it, then carries on. The two dwell
|
|
rules from the configuration decide when to leave a signal:
|
|
|
|
* ``record_seconds`` -- hard cap on how long one hit may hold the receiver
|
|
* ``hang_seconds`` -- how long the channel must stay quiet before resuming
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from . import dsp
|
|
from .bandplan import fmt_hz, label_for, presets_covering
|
|
from .callsign import CallsignBook, find_callsigns
|
|
from .classify import classify, ssb_alignment
|
|
from .config import ScanConfig, remember_lockouts
|
|
from .demod import make_demodulator
|
|
from .device import RtlSdrDevice, RtlSdrError
|
|
from .kml import KmlLog
|
|
from .morse import decode_morse
|
|
from .quality import Assessment, assess
|
|
from .ranges import Lockout, TuneStep, build_plan
|
|
from .recorder import FrequencyLog, HitRecord, Recording, ScanLog, read_wav
|
|
from .transcribe import TranscriptionWorker, available_engine
|
|
|
|
__all__ = ["Scanner", "Detection", "ScanStats", "ScannerCallbacks"]
|
|
|
|
|
|
@dataclass
|
|
class Detection:
|
|
"""A candidate signal found in one tuner step's spectrum."""
|
|
|
|
frequency: float
|
|
peak_dbfs: float
|
|
snr_db: float
|
|
bandwidth: float
|
|
step: TuneStep
|
|
range_index: int = 0
|
|
|
|
def describe(self) -> str:
|
|
return (f"{fmt_hz(self.frequency)} SNR {self.snr_db:.1f} dB "
|
|
f"BW {fmt_hz(self.bandwidth)}")
|
|
|
|
|
|
@dataclass
|
|
class ScanStats:
|
|
started_at: float = field(default_factory=time.time)
|
|
steps_done: int = 0
|
|
cycles: int = 0
|
|
detections: int = 0
|
|
recordings: int = 0
|
|
discarded: int = 0
|
|
seconds_recorded: float = 0.0
|
|
current_freq: float = 0.0
|
|
current_range: str = ""
|
|
state: str = "idle"
|
|
dropped_samples: int = 0
|
|
truncated: int = 0 # captures cut off mid-transmission
|
|
rejected_by_category: dict = field(default_factory=dict)
|
|
control_channels: dict = field(default_factory=dict) # Hz -> system name
|
|
|
|
@property
|
|
def elapsed(self) -> float:
|
|
return time.time() - self.started_at
|
|
|
|
|
|
@dataclass
|
|
class ScannerCallbacks:
|
|
"""Hooks the UI plugs into. All are optional and must not raise."""
|
|
|
|
on_step: callable = None # (step_index, n_steps, TuneStep, psd_db, freqs)
|
|
on_detection: callable = None # (Detection)
|
|
on_record_start: callable = None # (Detection, Recording)
|
|
on_record_tick: callable = None # (Recording, elapsed, signal_present, snr)
|
|
on_record_end: callable = None # (HitRecord)
|
|
on_record_note: callable = None # (str) -- a label for the live capture
|
|
on_cycle: callable = None # (cycle_number)
|
|
on_status: callable = None # (str)
|
|
on_error: callable = None # (Exception)
|
|
|
|
|
|
class Scanner:
|
|
"""Runs a scan on one device. Drive it with :meth:`run`."""
|
|
|
|
def __init__(self, config: ScanConfig, device: RtlSdrDevice | None = None,
|
|
callbacks: ScannerCallbacks | None = None):
|
|
self.cfg = config
|
|
self.device = device
|
|
self._own_device = device is None
|
|
self.cb = callbacks or ScannerCallbacks()
|
|
self.stats = ScanStats()
|
|
|
|
self.plan: list[TuneStep] = []
|
|
self._floors: dict[int, dsp.NoiseFloorTracker] = {}
|
|
self._recent: dict[int, float] = {} # frequency bucket -> last visit
|
|
self._lockouts: list[tuple[float, float]] = [] # spans, not buckets
|
|
|
|
self._stop = threading.Event()
|
|
self._pause = threading.Event()
|
|
self._skip = threading.Event()
|
|
self._lock = threading.Lock()
|
|
|
|
self.log: ScanLog | None = None
|
|
self.frequency_log: FrequencyLog | None = None
|
|
self.transcriber: TranscriptionWorker | None = None
|
|
self.callsigns: CallsignBook | None = None
|
|
self.kml: KmlLog | None = None
|
|
self.heard: dict[str, int] = {} # callsign -> times heard this run
|
|
self.hits: list[HitRecord] = []
|
|
self.nfft = 1024
|
|
self.detector_bias = 0.0
|
|
self._warned_record_limit = False
|
|
self._bucket = max(1.0, self.cfg.lockout_width)
|
|
|
|
# -- control ---------------------------------------------------------
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
|
|
def pause(self, on: bool = True) -> None:
|
|
self._pause.set() if on else self._pause.clear()
|
|
|
|
@property
|
|
def paused(self) -> bool:
|
|
return self._pause.is_set()
|
|
|
|
def skip(self) -> None:
|
|
"""Abandon the current recording and resume sweeping."""
|
|
self._skip.set()
|
|
|
|
def lockout(self, freq_hz: float) -> None:
|
|
"""Never stop here again -- for this run, and for later ones."""
|
|
entry = Lockout(float(freq_hz))
|
|
with self._lock:
|
|
self._lockouts.append(entry.interval(self.cfg.lockout_width))
|
|
self.cfg.lockout.append(entry)
|
|
|
|
path = remember_lockouts(self.cfg) if self.cfg.save_lockouts else None
|
|
# Announced either way: the lock-out took effect whether or not it was
|
|
# written down, and saying nothing when it was not is how an operator
|
|
# ends up thinking the key did not work.
|
|
self._status(f"locked out {entry.describe()}"
|
|
+ (f", remembered in {path.name}" if path
|
|
else " for this run"))
|
|
|
|
def _key(self, freq_hz: float) -> int:
|
|
return int(round(freq_hz / self._bucket))
|
|
|
|
def _is_locked(self, freq_hz: float) -> bool:
|
|
return any(lo <= freq_hz <= hi for lo, hi in self._lockouts)
|
|
|
|
# -- setup -------------------------------------------------------------
|
|
def prepare(self) -> None:
|
|
errs = self.cfg.validate()
|
|
if errs:
|
|
raise ValueError("configuration problems:\n - " + "\n - ".join(errs))
|
|
|
|
if self.device is None:
|
|
self.device = RtlSdrDevice(
|
|
index=self.cfg.device_index,
|
|
sample_rate=self.cfg.sample_rate,
|
|
gain=self.cfg.gain,
|
|
ppm=self.cfg.ppm,
|
|
agc=self.cfg.agc,
|
|
bias_tee=self.cfg.bias_tee,
|
|
offset_tuning=self.cfg.offset_tuning,
|
|
direct_sampling=self.cfg.direct_sampling,
|
|
)
|
|
self.device.open()
|
|
self.cfg.sample_rate = self.device.sample_rate
|
|
|
|
self.plan = build_plan(self.cfg.ranges, self.device.sample_rate,
|
|
self.cfg.usable_fraction,
|
|
dc_guard=self.cfg.dc_guard_hz,
|
|
tunable=self.device.can_tune)
|
|
if not self.plan:
|
|
raise ValueError("no tunable steps: every range is disabled or empty")
|
|
|
|
# Drop steps the hardware cannot reach rather than failing mid-sweep.
|
|
reachable = [s for s in self.plan if self.device.can_tune(s.center)]
|
|
skipped = len(self.plan) - len(reachable)
|
|
if not reachable:
|
|
raise ValueError(
|
|
"none of the configured frequencies are within this device's "
|
|
"tuning range"
|
|
)
|
|
if skipped:
|
|
self._status(f"skipping {skipped} step(s) outside the tuner's range")
|
|
self.plan = reachable
|
|
|
|
self.nfft = dsp.next_fast_len(
|
|
max(64, int(self.device.sample_rate / max(1.0, self.cfg.resolution_hz)))
|
|
)
|
|
# Offset that noise alone clears with this detector, so the configured
|
|
# threshold means real margin over static rather than over the floor.
|
|
n_samples = self._sweep_samples()
|
|
n_seg = min(64, 1 + max(0, (n_samples - self.nfft)) // max(1, self.nfft // 2))
|
|
self.detector_bias = (self.cfg.detector_bias_db
|
|
if self.cfg.detector_bias_db is not None
|
|
else dsp.detector_bias_db(
|
|
n_seg, self.nfft,
|
|
"max" if self.cfg.detector == "peak" else "mean"))
|
|
# A single frequency is widened by the lock-out width; a span was
|
|
# given a width by whoever wrote it and is taken as it stands.
|
|
self._lockouts = [Lockout.coerce(f).interval(self.cfg.lockout_width)
|
|
for f in self.cfg.lockout]
|
|
|
|
root = Path(self.cfg.output_dir).expanduser()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
self.log = ScanLog(root, self.cfg.log_file)
|
|
if self.cfg.transcribe:
|
|
engine = (available_engine()
|
|
if self.cfg.transcribe_engine in ("", "auto")
|
|
else self.cfg.transcribe_engine)
|
|
if engine is None:
|
|
self._status(
|
|
"transcription is on but no speech recogniser is "
|
|
"installed — see `bandsaunter transcribe --engines`")
|
|
else:
|
|
self.transcriber = TranscriptionWorker(
|
|
engine=self.cfg.transcribe_engine,
|
|
model=self.cfg.transcribe_model,
|
|
language=self.cfg.transcribe_language,
|
|
on_done=self._on_transcript,
|
|
on_error=self._error)
|
|
self.transcriber.start()
|
|
self._status(f"transcribing speech with {engine}")
|
|
# Callsigns come out of transcripts, so both of these are
|
|
# only worth setting up where there will be transcripts.
|
|
self.callsigns = CallsignBook(online=self.cfg.callsign_lookup)
|
|
if self.cfg.kml_file:
|
|
self.kml = KmlLog(
|
|
root / self.cfg.kml_file,
|
|
title="bandsaunter — stations heard",
|
|
description="Every callsign heard during a scan, "
|
|
"placed where its licence says it is.")
|
|
|
|
if self.cfg.combine_by_frequency:
|
|
self.frequency_log = FrequencyLog(
|
|
root, tolerance_hz=self.cfg.combine_tolerance_hz,
|
|
announce=self.cfg.announce_timestamps,
|
|
announce_frequency=self.cfg.announce_frequency,
|
|
engine=self.cfg.announce_engine)
|
|
|
|
def _status(self, msg: str) -> None:
|
|
if self.cb.on_status:
|
|
try:
|
|
self.cb.on_status(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
def _error(self, exc: Exception) -> None:
|
|
if self.cb.on_error:
|
|
try:
|
|
self.cb.on_error(exc)
|
|
except Exception:
|
|
pass
|
|
|
|
# -- sweeping ----------------------------------------------------------
|
|
@property
|
|
def bin_hz(self) -> float:
|
|
return self.device.sample_rate / self.nfft
|
|
|
|
def _sweep_samples(self) -> int:
|
|
return max(self.nfft * 4,
|
|
int(self.device.sample_rate * self.cfg.dwell_seconds))
|
|
|
|
def measure_step(self, step: TuneStep):
|
|
"""Tune, capture and measure one step.
|
|
|
|
Returns ``(freqs_hz, psd_db, excess_db, margin_db)`` where ``excess``
|
|
is dB above the noise floor (what gets reported as SNR) and ``margin``
|
|
is the extra headroom a detection must clear on top of the configured
|
|
threshold, derived from how much the noise in that bin scatters.
|
|
"""
|
|
self.device.tune(step.center)
|
|
x = self.device.read_samples(self._sweep_samples(), flush=True)
|
|
# Strip the RTL2832's constant DC offset before it becomes a spike.
|
|
x = x - x.mean()
|
|
freqs_norm, psd = dsp.welch_psd(
|
|
x, self.nfft, combine="max" if self.cfg.detector == "peak" else "mean")
|
|
psd_db = dsp.db(psd)
|
|
freqs = step.center + freqs_norm * self.device.sample_rate
|
|
|
|
if self.cfg.adaptive_floor:
|
|
tracker = self._floors.get(step.index)
|
|
if tracker is None:
|
|
tracker = self._floors[step.index] = dsp.NoiseFloorTracker()
|
|
tracker.update(psd_db)
|
|
excess = tracker.excess(psd_db)
|
|
else:
|
|
excess = psd_db - dsp.noise_floor_curve(psd_db)
|
|
return freqs, psd_db, excess, self.detector_bias
|
|
|
|
def find_detections(self, step: TuneStep, freqs: np.ndarray,
|
|
psd_db: np.ndarray, excess: np.ndarray,
|
|
threshold_db: float,
|
|
margin: np.ndarray | float = 0.0) -> list[Detection]:
|
|
"""Group bins above threshold into distinct signals."""
|
|
n = freqs.size
|
|
mask = np.ones(n, dtype=bool)
|
|
|
|
# Only trust the flat middle of the capture, and only the slice of it
|
|
# this step is responsible for.
|
|
mask &= np.abs(freqs - step.center) <= 0.45 * self.device.sample_rate
|
|
mask &= (freqs >= step.low) & (freqs <= step.high)
|
|
# Belt and braces: the planner already parks the LO outside the covered
|
|
# span, but never trust bins next to DC.
|
|
dc_guard = max(2.0 * self.bin_hz, 0.5 * self.cfg.dc_guard_hz)
|
|
mask &= np.abs(freqs - step.center) > dc_guard
|
|
|
|
hot = mask & (excess > (threshold_db + margin))
|
|
if not np.any(hot):
|
|
return []
|
|
|
|
idx = np.flatnonzero(hot)
|
|
groups: list[list[int]] = [[idx[0]]]
|
|
for i in idx[1:]:
|
|
if i - groups[-1][-1] <= 2: # bridge single-bin dropouts
|
|
groups[-1].append(i)
|
|
else:
|
|
groups.append([i])
|
|
|
|
dets = []
|
|
for g in groups:
|
|
lo_i, hi_i = g[0], g[-1]
|
|
band = slice(lo_i, hi_i + 1)
|
|
weights = np.maximum(excess[band], 0.0)
|
|
if weights.sum() <= 0:
|
|
continue
|
|
centroid = float(np.average(freqs[band], weights=weights))
|
|
peak_i = lo_i + int(np.argmax(psd_db[band]))
|
|
bw = max(self.bin_hz, (hi_i - lo_i + 1) * self.bin_hz)
|
|
dets.append(Detection(
|
|
frequency=centroid,
|
|
peak_dbfs=float(psd_db[peak_i]),
|
|
snr_db=float(excess[peak_i]),
|
|
bandwidth=bw,
|
|
step=step,
|
|
range_index=step.range_index,
|
|
))
|
|
|
|
dets.sort(key=lambda d: d.snr_db, reverse=True)
|
|
return dets[: max(1, self.cfg.max_detections_per_step)]
|
|
|
|
def _should_visit(self, det: Detection) -> bool:
|
|
if self._is_locked(det.frequency):
|
|
return False
|
|
last = self._recent.get(self._key(det.frequency))
|
|
if last is not None and (time.time() - last) < self.cfg.revisit_seconds:
|
|
return False
|
|
return True
|
|
|
|
# -- capture ------------------------------------------------------------
|
|
def _measure_bandwidth(self, x: np.ndarray, hint_bw: float) -> float:
|
|
"""Fine-resolution occupied bandwidth of a signal already at DC.
|
|
|
|
The sweep runs at a few kHz per bin, which cannot tell a CW carrier
|
|
from an SSB channel. One short probe at ~60 Hz resolution can.
|
|
"""
|
|
nfft = min(32768, dsp.next_fast_len(
|
|
int(self.device.sample_rate / 100.0)))
|
|
if x.size < nfft:
|
|
return hint_bw
|
|
_, psd = dsp.welch_psd(x, nfft)
|
|
psd_db = dsp.db(psd)
|
|
bin_hz = self.device.sample_rate / nfft
|
|
freqs = np.fft.fftshift(
|
|
np.fft.fftfreq(nfft, 1.0 / self.device.sample_rate))
|
|
win = np.abs(freqs) <= max(hint_bw * 3.0, 30_000.0)
|
|
if not np.any(win):
|
|
return hint_bw
|
|
floor = float(np.percentile(psd_db[win], 25.0))
|
|
peak = float(psd_db[win].max())
|
|
# Relative to the noise floor *and* to the peak. On a strong signal a
|
|
# floor-relative threshold alone follows the skirts a long way out and
|
|
# reports a channel several times wider than it is -- which then opens
|
|
# the demodulator far too wide and fills the audio with noise.
|
|
hot = win & (psd_db > max(floor + 10.0, peak - 26.0))
|
|
if not np.any(hot):
|
|
return hint_bw
|
|
idx = np.flatnonzero(hot)
|
|
# Percentiles of the occupied positions, not their extremes: a single
|
|
# stray bin out at the edge of the window would otherwise stretch the
|
|
# measurement by an order of magnitude, and the demodulator opened to
|
|
# match would hear nothing but the carrier.
|
|
lo = float(np.percentile(idx, 2.0))
|
|
hi = float(np.percentile(idx, 98.0))
|
|
return max(bin_hz, float((hi - lo + 1.0) * bin_hz))
|
|
|
|
def _capture_plan(self, det: Detection,
|
|
fine_bw: float | None = None) -> tuple[str, float]:
|
|
"""Pick the demodulator and bandwidth for a hit."""
|
|
rng = self.cfg.ranges[det.range_index] if \
|
|
det.range_index < len(self.cfg.ranges) else None
|
|
if rng is not None:
|
|
mode = rng.resolved_mode(det.frequency)
|
|
bw = rng.resolved_bandwidth(det.frequency)
|
|
else:
|
|
covering = presets_covering(det.frequency)
|
|
best = min(covering, key=lambda p: p.span) if covering else None
|
|
mode = best.mode if best else "nfm"
|
|
bw = best.bandwidth if best else 12_500.0
|
|
measured = fine_bw if fine_bw is not None else det.bandwidth
|
|
# A very narrow signal inside an SSB segment is a CW carrier, and an
|
|
# SSB filter would reject it outright (it sits at DC once centred).
|
|
# Switching to the CW demodulator puts a beat note back on it.
|
|
if mode in ("usb", "lsb", "ssb") and measured < 1_200.0:
|
|
return "cw", 800.0
|
|
# Never demodulate narrower than the signal actually measured.
|
|
bw = max(bw, min(measured * 1.5, self.device.sample_rate * 0.4))
|
|
return mode, bw
|
|
|
|
def _demod_from_signal(self, probe: np.ndarray, freq_hz: float,
|
|
fallback: str, bw: float,
|
|
signal_bw: float = 0.0,
|
|
snr_db: float = 0.0) -> str:
|
|
"""Choose the demodulator from what the signal physically is.
|
|
|
|
Three measurements settle it, and all three are ratios that hold
|
|
steady over a fraction of a second regardless of what is being said:
|
|
|
|
* how much the envelope varies,
|
|
* how far the instantaneous frequency swings,
|
|
* how much of the power sits in a single carrier bin.
|
|
|
|
Running the full classifier on the probe was tried and is not
|
|
reliable here -- over a fraction of a second, speech makes any
|
|
modulation look bursty, and AM came back as on-off keying while FM
|
|
came back as AM. The band plan is kept whenever the evidence is not
|
|
decisive, since it is usually right about what a band carries.
|
|
"""
|
|
if probe.size < 4096 or snr_db < 12.0:
|
|
return fallback
|
|
|
|
# Narrow to the signal's own bandwidth first. Across the full 2 MHz
|
|
# capture a narrow channel is a sliver, and the measurements describe
|
|
# the surrounding noise instead of the signal.
|
|
rate = float(self.device.sample_rate)
|
|
target = max(4.0 * (signal_bw or bw), 16_000.0)
|
|
factor = 1
|
|
while factor * 2 <= 256 and rate / (factor * 2) >= target:
|
|
factor *= 2
|
|
if factor > 1:
|
|
try:
|
|
probe = dsp.DecimationChain(factor)(probe)
|
|
rate = rate / factor
|
|
except Exception:
|
|
return fallback
|
|
if probe.size < 4096:
|
|
return fallback
|
|
|
|
env = np.abs(probe)
|
|
mean_env = float(env.mean())
|
|
if mean_env <= 0:
|
|
return fallback
|
|
env_cv = float(env.std() / mean_env)
|
|
|
|
ifreq = dsp.instantaneous_frequency(probe, rate)
|
|
strong = env[1:] > 0.5 * mean_env
|
|
sel = ifreq[strong] if strong.sum() > 64 else ifreq
|
|
fdev_rms = float(np.std(sel))
|
|
|
|
_, psd = dsp.welch_psd(probe, 1024)
|
|
total = float(psd.sum())
|
|
carrier_ratio = float(psd.max() / total) if total > 0 else 0.0
|
|
|
|
measured_bw = signal_bw or bw
|
|
# Deliberately absolute, not scaled by the measured bandwidth: that
|
|
# measurement is an estimate, and dividing by it turned a stable
|
|
# decision into one that moved with the estimate's error.
|
|
if env_cv < 0.15 and fdev_rms > 200.0:
|
|
# Constant envelope with the tone swinging: frequency modulation,
|
|
# which is also how FSK is conventionally listened to.
|
|
return "wfm" if measured_bw > 50_000 else "nfm"
|
|
if 0.05 < env_cv < 0.6 and carrier_ratio > 0.25 and fdev_rms < 200.0:
|
|
# A surviving carrier, a varying envelope, a tone that stays put.
|
|
return "am"
|
|
if carrier_ratio < 0.15 and env_cv > 0.6:
|
|
# No carrier left, and the envelope carrying the whole signal.
|
|
# Which sideband is settled later, by measurement; the band plan
|
|
# is a better guess than the HF convention in the meantime, since
|
|
# it knows the segments the convention gets wrong -- 60 m and the
|
|
# HF utility bands are upper sideband well below 10 MHz.
|
|
if fallback in ("usb", "lsb"):
|
|
return fallback
|
|
return "lsb" if freq_hz < 10_000_000 else "usb"
|
|
return fallback
|
|
|
|
# A sideband read this weakly is no better than the band plan's word.
|
|
_SIDEBAND_CONFIDENCE = 0.12
|
|
|
|
def _align_ssb(self, x: np.ndarray, mode: str,
|
|
det: Detection, fine_bw: float) -> tuple[str, float]:
|
|
"""Put an SSB capture on the suppressed carrier rather than the voice.
|
|
|
|
Detection reports the centroid of the energy, which is what every
|
|
other mode wants: an FM discriminator and an AM envelope detector do
|
|
not care where in their passband the signal sits. SSB is demodulated
|
|
by a filter that opens at the carrier, so centring on the middle of
|
|
the voice cuts off its lower half and shifts the rest down by a
|
|
couple of kilohertz -- the mistuned sound that makes SSB unusable
|
|
rather than merely imperfect.
|
|
|
|
Returns the sideband to demodulate and the carrier's offset in hertz.
|
|
"""
|
|
if mode not in ("usb", "lsb", "ssb"):
|
|
return mode, 0.0
|
|
hint = mode if mode in ("usb", "lsb") else ""
|
|
al = ssb_alignment(x, self.device.sample_rate, fine_bw)
|
|
if al is None:
|
|
return hint or "usb", 0.0
|
|
if al.confidence >= self._SIDEBAND_CONFIDENCE or not hint:
|
|
sideband = al.sideband
|
|
else:
|
|
sideband = hint
|
|
offset = al.carrier_for(sideband)
|
|
# A correction larger than the signal itself means the band was read
|
|
# wrong, and moving that far would tune away from it.
|
|
if abs(offset) > max(4_000.0, al.width_hz):
|
|
return sideband, 0.0
|
|
det.frequency += offset
|
|
return sideband, offset
|
|
|
|
def _lo_offset(self, freq: float, bw: float) -> float:
|
|
"""How far to offset the LO so the DC spike misses the signal."""
|
|
if self.device.direct_sampling_mode != 0:
|
|
return 0.0
|
|
offset = self.device.sample_rate * 0.25
|
|
if not self.device.can_tune(freq - offset):
|
|
return 0.0
|
|
if offset < bw:
|
|
return 0.0
|
|
return offset
|
|
|
|
def _band_snr(self, x: np.ndarray, bw: float) -> float:
|
|
"""In-band vs guard-band power, in dB -- the squelch metric.
|
|
|
|
Comparing the signal against the noise *right next to it* makes the
|
|
squelch immune to gain changes and to the overall band noise level.
|
|
"""
|
|
nfft = 512
|
|
if x.size < nfft * 2:
|
|
return 0.0
|
|
_, psd = dsp.welch_psd(x, nfft, max_segments=16)
|
|
bin_hz = self.device.sample_rate / nfft
|
|
freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / self.device.sample_rate))
|
|
half = max(bin_hz, bw / 2.0)
|
|
inband = np.abs(freqs) <= half
|
|
guard = (np.abs(freqs) > 2.0 * half) & \
|
|
(np.abs(freqs) <= self.device.sample_rate * 0.45)
|
|
if not np.any(inband) or not np.any(guard):
|
|
return 0.0
|
|
sig = float(np.mean(psd[inband]))
|
|
noise = float(np.median(psd[guard]))
|
|
if noise <= 0:
|
|
return 0.0
|
|
return float(10.0 * math.log10(max(sig / noise, 1e-12)))
|
|
|
|
def capture(self, det: Detection) -> HitRecord | None:
|
|
"""Drop onto a detection: record it, then work out what it is."""
|
|
cfg = self.cfg
|
|
mode, bw = self._capture_plan(det)
|
|
offset = self._lo_offset(det.frequency, bw)
|
|
|
|
self.device.tune(det.frequency - offset)
|
|
mixer = dsp.Mixer(offset, self.device.sample_rate) if offset else None
|
|
|
|
# Probe before committing to a demodulator. The sweep's bin width is
|
|
# far too coarse to tell narrow modes apart, and the band plan only
|
|
# says what a frequency is *usually* used for -- an AM signal inside a
|
|
# band listed as FM would otherwise be recorded through the wrong
|
|
# detector, producing audio that is useless to listen to and
|
|
# impossible to judge for content.
|
|
# Stream from here on, so the probe and the recording are one
|
|
# continuous capture with no gap between them.
|
|
streamed = False
|
|
try:
|
|
self.device.start_stream()
|
|
streamed = True
|
|
except (RtlSdrError, AttributeError) as exc:
|
|
if isinstance(exc, RtlSdrError):
|
|
self._error(exc)
|
|
|
|
probe = None
|
|
carrier_offset = 0.0
|
|
try:
|
|
n_probe = int(self.device.sample_rate * cfg.probe_seconds)
|
|
probe = (self.device.read_stream(n_probe) if streamed
|
|
else self.device.read_samples(n_probe, flush=True))
|
|
if mixer is not None:
|
|
probe = mixer(probe)
|
|
# Strip the receiver's DC offset for the *measurements* only, and
|
|
# do it on the pre-mix signal. That offset sits at the tuned
|
|
# frequency, which the mixer moves a quarter of the sample rate
|
|
# away; subtracting the mean after mixing would instead delete
|
|
# whatever landed at DC -- the signal's own carrier, which is the
|
|
# very thing that identifies AM.
|
|
measured = probe - probe.mean() if mixer is None else probe
|
|
fine_bw = self._measure_bandwidth(measured, bw)
|
|
mode, bw = self._capture_plan(det, fine_bw)
|
|
det.bandwidth = fine_bw
|
|
mode = self._demod_from_signal(measured, det.frequency, mode, bw,
|
|
signal_bw=fine_bw,
|
|
snr_db=det.snr_db)
|
|
mode, carrier_offset = self._align_ssb(measured, mode, det, fine_bw)
|
|
except RtlSdrError as exc:
|
|
self._error(exc)
|
|
|
|
demod = make_demodulator(mode, self.device.sample_rate, bw,
|
|
cfg.audio_rate,
|
|
**({"carrier_offset_hz": carrier_offset}
|
|
if carrier_offset else {}))
|
|
|
|
rec = Recording(
|
|
root=Path(cfg.output_dir).expanduser(),
|
|
frequency=det.frequency, mode=mode,
|
|
audio_rate=demod.audio_rate, iq_rate=demod.if_rate,
|
|
save_audio=cfg.save_audio, save_iq=cfg.save_iq,
|
|
iq_format=cfg.iq_format, range_label=det.step.range_label,
|
|
)
|
|
if self.cb.on_record_start:
|
|
try:
|
|
self.cb.on_record_start(det, rec)
|
|
except Exception:
|
|
pass
|
|
|
|
block = max(4096, int(self.device.sample_rate * 0.05))
|
|
block -= block % 512
|
|
squelch_on = cfg.threshold_db
|
|
squelch_off = max(1.0, cfg.threshold_db - cfg.squelch_margin_db)
|
|
|
|
# Elapsed time is counted from samples consumed, not the wall clock:
|
|
# "record for X seconds" then means X seconds of signal even if the
|
|
# host stalls, and the recorded file length matches the setting exactly.
|
|
fs = float(self.device.sample_rate)
|
|
start = time.time()
|
|
elapsed = 0.0
|
|
last_signal_at = 0.0
|
|
last_content_at = 0.0
|
|
ever_signal = False
|
|
was_present = False
|
|
signal_since = 0.0 # start of the current unbroken run
|
|
content_ever = False
|
|
checks_done = 0
|
|
continuous_for = 0.0
|
|
peak_snr = det.snr_db
|
|
stop_reason = "unknown"
|
|
next_check = cfg.verify_seconds
|
|
rejected_early = None
|
|
quiet_for = 0.0
|
|
cut_short = False
|
|
self.stats.state = "recording"
|
|
self._recent[self._key(det.frequency)] = start
|
|
|
|
# The probe is real, contiguous signal -- play it into the recording
|
|
# rather than discarding it. Throwing it away costs the opening of
|
|
# every transmission, which for a short over is most of it.
|
|
pending = []
|
|
if probe is not None and probe.size:
|
|
pending = [probe[i:i + block] for i in range(0, probe.size, block)]
|
|
|
|
try:
|
|
while True:
|
|
if self._stop.is_set():
|
|
stop_reason = "scan stopped"
|
|
break
|
|
if self._skip.is_set():
|
|
self._skip.clear()
|
|
stop_reason = "skipped by operator"
|
|
break
|
|
|
|
if pending:
|
|
x = pending.pop(0)
|
|
else:
|
|
x = (self.device.read_stream(block) if streamed
|
|
else self.device.read_samples(block))
|
|
if mixer is not None:
|
|
x = mixer(x)
|
|
elapsed += x.size / fs
|
|
|
|
snr = self._band_snr(x, bw)
|
|
present = snr >= (squelch_off if ever_signal else squelch_on)
|
|
if present:
|
|
if not was_present:
|
|
signal_since = elapsed - x.size / fs
|
|
last_signal_at = elapsed
|
|
ever_signal = True
|
|
peak_snr = max(peak_snr, snr)
|
|
was_present = present
|
|
# How long the carrier has been up without a break. A
|
|
# trunking control channel never lets this reset; a
|
|
# conversation on the same system always does.
|
|
continuous_for = elapsed - signal_since if present else 0.0
|
|
|
|
audio, iq = demod.step(x)
|
|
rec.write_audio(audio, present=present, at=elapsed)
|
|
rec.write_iq(iq)
|
|
|
|
if self.cb.on_record_tick:
|
|
try:
|
|
self.cb.on_record_tick(rec, elapsed, present, snr,
|
|
quiet_for)
|
|
except Exception:
|
|
pass
|
|
|
|
# Judge the content while it is still arriving so static and
|
|
# interference are dropped after a second or two instead of
|
|
# holding the receiver for the full record time. The check
|
|
# also runs with the content gate off, because spotting a
|
|
# control channel is worth doing whether or not anything is
|
|
# being thrown away for lack of content; in that case its only
|
|
# power is to end the capture, never to reject one.
|
|
if ((cfg.require_signal or cfg.skip_control) and cfg.classify
|
|
and ever_signal and elapsed >= next_check):
|
|
# Check more often once a gap has opened, so a channel
|
|
# held open by static is released promptly instead of
|
|
# waiting out a full verify interval.
|
|
in_gap = content_ever and \
|
|
(elapsed - last_content_at) > cfg.hang_seconds
|
|
next_check = elapsed + (0.5 if in_gap else cfg.verify_seconds)
|
|
window = max(2.0, cfg.verify_seconds)
|
|
_, _, verdict = self._analyse(
|
|
rec, demod, det.frequency, peak_snr,
|
|
since=max(0.0, elapsed - window),
|
|
continuous_for=continuous_for)
|
|
if verdict is not None and cfg.require_signal:
|
|
checks_done += 1
|
|
if verdict is not None:
|
|
if verdict.category == "trunk":
|
|
# A positive identification, not a failure to find
|
|
# content: there is nothing to hear on a control
|
|
# channel however long we wait, so leave now
|
|
# rather than serving out verify_max_seconds.
|
|
rejected_early = verdict
|
|
stop_reason = verdict.reason
|
|
self._note_control(det.frequency, verdict)
|
|
break
|
|
if not cfg.require_signal:
|
|
pass # the gate is off; nothing to judge
|
|
elif verdict.accept:
|
|
content_ever = True
|
|
last_content_at = elapsed
|
|
elif not content_ever:
|
|
# Static has no structure at all -- abandon at
|
|
# once. Anything else gets until
|
|
# verify_max_seconds to show some content.
|
|
# Once a capture *has* produced content it is
|
|
# never abandoned this way: a two-way exchange
|
|
# goes quiet between overs, and cutting it off
|
|
# mid-conversation is exactly wrong.
|
|
if (verdict.noise_likeness > 0.85
|
|
or elapsed >= cfg.verify_max_seconds):
|
|
rejected_early = verdict
|
|
stop_reason = (f"no signal content: "
|
|
f"{verdict.reason}")
|
|
break
|
|
|
|
if cfg.record_seconds and elapsed >= cfg.record_seconds:
|
|
stop_reason = f"reached the {cfg.record_seconds:g} s record limit"
|
|
if present:
|
|
# Cut off mid-transmission. This is the one case where
|
|
# the record limit is almost certainly not what the
|
|
# operator wanted, and it is invisible otherwise: the
|
|
# file simply ends.
|
|
cut_short = True
|
|
break
|
|
if elapsed >= cfg.max_record_seconds > 0:
|
|
stop_reason = (f"hit the {cfg.max_record_seconds:g} s safety "
|
|
"limit")
|
|
break
|
|
|
|
# The gap that ends a capture is measured from the last real
|
|
# signal, not merely from the last time the squelch opened.
|
|
# Static and interference break squelch too, and letting them
|
|
# reset the timer would hold the receiver on a dead channel.
|
|
quiet_for = elapsed - last_signal_at
|
|
if content_ever and checks_done:
|
|
quiet_for = min(quiet_for,
|
|
elapsed - last_content_at
|
|
+ min(cfg.verify_seconds, 1.0))
|
|
content_gap = elapsed - last_content_at
|
|
if content_gap >= cfg.hang_seconds + min(cfg.verify_seconds,
|
|
1.0):
|
|
stop_reason = (f"no signal for {cfg.hang_seconds:g} s "
|
|
"(only silence, static or interference)")
|
|
break
|
|
if ever_signal and (elapsed - last_signal_at) >= cfg.hang_seconds:
|
|
stop_reason = f"channel quiet for {cfg.hang_seconds:g} s"
|
|
break
|
|
if not ever_signal and elapsed >= max(0.4, min(2.0, cfg.hang_seconds)):
|
|
stop_reason = "signal gone before recording started"
|
|
break
|
|
except RtlSdrError as exc:
|
|
stop_reason = f"device error: {exc}"
|
|
self._error(exc)
|
|
finally:
|
|
if streamed:
|
|
dropped_before = getattr(self.device, "dropped_samples", 0)
|
|
try:
|
|
self.device.stop_stream()
|
|
except Exception:
|
|
pass
|
|
if dropped_before:
|
|
self.stats.dropped_samples += dropped_before
|
|
self._status(
|
|
f"host fell behind: {dropped_before} samples dropped "
|
|
"during this capture")
|
|
rec.close()
|
|
self.stats.state = "sweeping"
|
|
if cut_short:
|
|
self.stats.truncated += 1
|
|
if not self._warned_record_limit:
|
|
self._warned_record_limit = True
|
|
self._status(
|
|
f"cut off at the {cfg.record_seconds:g} s record limit "
|
|
f"while still transmitting — set 'Record for' to 0 "
|
|
f"(--record 0) to follow a transmission to its end")
|
|
|
|
duration = rec.duration
|
|
band = label_for(det.frequency)
|
|
hit = HitRecord(
|
|
frequency=det.frequency,
|
|
started_at=rec.started_at,
|
|
started_iso=time.strftime("%Y-%m-%dT%H:%M:%S",
|
|
time.localtime(rec.started_at)),
|
|
duration=round(duration, 3),
|
|
peak_dbfs=round(det.peak_dbfs, 2),
|
|
snr_db=round(peak_snr, 2),
|
|
bandwidth=det.bandwidth,
|
|
mode=mode,
|
|
range_label=det.step.range_label,
|
|
band_labels=band.names,
|
|
band=band.name,
|
|
directory=str(rec.dir),
|
|
filename=rec.stem,
|
|
stop_reason=stop_reason,
|
|
)
|
|
|
|
too_short = duration < cfg.min_record_seconds
|
|
if too_short or not ever_signal or rejected_early is not None:
|
|
hit.kept = False
|
|
if rejected_early is not None:
|
|
hit.category = rejected_early.category
|
|
hit.signal_score = round(rejected_early.score, 3)
|
|
hit.content_reason = rejected_early.reason
|
|
hit.noise_likeness = round(rejected_early.noise_likeness, 3)
|
|
hit.voice_score = round(rejected_early.voice.score, 3)
|
|
control = rejected_early.control
|
|
if control is not None:
|
|
hit.classification = control.describe()
|
|
hit.family = "control"
|
|
hit.baud = control.baud
|
|
hit.confidence = control.confidence
|
|
hit.reasons = [control.reason]
|
|
elif not ever_signal:
|
|
hit.category = "vanished"
|
|
hit.stop_reason = "false trigger (no signal after retuning)"
|
|
elif too_short:
|
|
hit.category = hit.category or "too short"
|
|
self._reject(rec, hit)
|
|
return hit
|
|
|
|
verdict = self._identify(rec, hit, demod, continuous_for=continuous_for)
|
|
if verdict is not None and verdict.category == "trunk":
|
|
# Recognised only at the end -- a short capture, or one that ran
|
|
# before the first content check. Same outcome, said the same way.
|
|
hit.kept = False
|
|
hit.stop_reason = verdict.reason
|
|
self._note_control(det.frequency, verdict)
|
|
self._reject(rec, hit)
|
|
return hit
|
|
if cfg.require_signal and verdict is not None and not verdict.accept:
|
|
hit.kept = False
|
|
hit.stop_reason = f"no signal content: {verdict.reason}"
|
|
self._reject(rec, hit)
|
|
return hit
|
|
|
|
# Name the files after what the signal turned out to be, not after the
|
|
# demodulator that was guessed before listening to it.
|
|
rec.rename_for(hit.family or hit.mode)
|
|
hit.filename = rec.stem
|
|
hit.audio_path = str(rec.audio_path) if cfg.save_audio else ""
|
|
hit.iq_path = str(rec.iq_path) if cfg.save_iq else ""
|
|
|
|
# Add this transmission to the running file for its frequency, after
|
|
# a spoken timestamp, so one channel plays back as one recording.
|
|
if self.frequency_log is not None and cfg.save_audio and \
|
|
rec.audio_path.exists():
|
|
try:
|
|
combined = self.frequency_log.add_file(
|
|
hit.frequency, rec.audio_path,
|
|
when=datetime.fromtimestamp(rec.started_at))
|
|
hit.combined_path = str(combined)
|
|
if not cfg.combine_keep_individual:
|
|
rec.audio_path.unlink()
|
|
hit.audio_path = ""
|
|
except (OSError, ValueError) as exc:
|
|
self._error(exc)
|
|
|
|
hit.meta_path = str(rec.write_metadata(hit))
|
|
# Queued after the sidecar exists, so the transcriber can record the
|
|
# result in it -- and record nothing when there was no speech.
|
|
self._submit_transcription(rec, hit)
|
|
self.stats.recordings += 1
|
|
self.stats.seconds_recorded += duration
|
|
self.hits.append(hit)
|
|
if self.log:
|
|
try:
|
|
self.log.append(hit)
|
|
except OSError as exc:
|
|
self._error(exc)
|
|
if self.cb.on_record_end:
|
|
try:
|
|
self.cb.on_record_end(hit)
|
|
except Exception:
|
|
pass
|
|
return hit
|
|
|
|
def _note_control(self, freq_hz: float, verdict) -> None:
|
|
"""Announce a trunking control channel, and optionally lock it out.
|
|
|
|
Said out loud every time rather than once: an operator watching a
|
|
band wants to know *which* frequencies are control channels, and
|
|
there is usually more than one.
|
|
"""
|
|
control = getattr(verdict, "control", None)
|
|
name = control.system if control is not None else "trunked system"
|
|
baud = f", {control.baud:.0f} baud" if control is not None else ""
|
|
self.stats.control_channels[round(freq_hz)] = name
|
|
self._status(f"TRUNK {fmt_hz(freq_hz)} -- {name} control channel"
|
|
f"{baud} -- skipping")
|
|
if self.cb.on_record_note:
|
|
try:
|
|
self.cb.on_record_note(f"TRUNK: {name}")
|
|
except Exception:
|
|
pass
|
|
if self.cfg.lockout_control and not self._is_locked(freq_hz):
|
|
self.lockout(freq_hz)
|
|
|
|
def _reject(self, rec: Recording, hit: HitRecord) -> None:
|
|
"""Throw away a capture and everything it wrote."""
|
|
rec.discard()
|
|
self.stats.discarded += 1
|
|
self.stats.rejected_by_category[hit.category] = \
|
|
self.stats.rejected_by_category.get(hit.category, 0) + 1
|
|
if self.cb.on_record_end:
|
|
try:
|
|
self.cb.on_record_end(hit)
|
|
except Exception:
|
|
pass
|
|
|
|
def _analyse(self, rec: Recording, demod, freq_hz: float, snr_db: float,
|
|
final: bool = False, since: float | None = None,
|
|
continuous_for: float = 0.0):
|
|
"""Classify what has been captured so far and judge whether to keep it.
|
|
|
|
Returns ``(classification, morse, assessment)``. Called periodically
|
|
during a capture so that static and interference can be abandoned
|
|
early, and once more at the end to make the final decision.
|
|
"""
|
|
# While the capture is running the check has to be cheap; once it has
|
|
# finished the stream is stopped and a longer look costs nothing.
|
|
iq_limit = 0 if final else min(int(2.0 * demod.if_rate), 200_000)
|
|
iq = rec.classification_iq(limit=iq_limit)
|
|
if iq.size < 2048:
|
|
return None, None, None
|
|
try:
|
|
cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=snr_db,
|
|
continuous_for=continuous_for,
|
|
control_seconds=self.cfg.control_seconds)
|
|
except Exception as exc:
|
|
self._error(exc)
|
|
return None, None, None
|
|
|
|
morse = None
|
|
if self.cfg.decode_morse and cls.family in ("cw", "ook", "carrier"):
|
|
morse = self._decode_cw(iq, demod)
|
|
|
|
# Judge the audio that was actually recorded, and only that. An
|
|
# earlier version also demodulated a second way and kept whichever
|
|
# scored higher; that is cherry-picking, and on noise one of the two
|
|
# will flatter it by chance. It was the main source of false keeps.
|
|
if final or since is None:
|
|
audio = rec.classification_audio(
|
|
limit=0 if final else min(int(4.0 * rec.audio_rate), 96_000))
|
|
else:
|
|
audio = rec.active_since(since)
|
|
return cls, morse, self._verdict(cls, audio, rec.audio_rate, morse,
|
|
freq_hz)
|
|
|
|
def _verdict(self, cls, audio, audio_rate, morse,
|
|
freq_hz: float = 0.0) -> Assessment:
|
|
cfg = self.cfg
|
|
return assess(cls, audio, audio_rate, morse=morse, freq_hz=freq_hz,
|
|
min_voice=cfg.min_voice_score,
|
|
accept=tuple(cfg.accept),
|
|
min_score=cfg.min_signal_score,
|
|
skip_control=cfg.skip_control)
|
|
|
|
def _decode_cw(self, iq: np.ndarray, demod):
|
|
"""Run a dedicated CW detector over the captured IQ.
|
|
|
|
Independent of the recording mode: a keyed carrier is inaudible
|
|
through an FM or SSB detector, so Morse would otherwise be missed.
|
|
"""
|
|
try:
|
|
cw = make_demodulator("cw", int(demod.if_rate), 800.0,
|
|
self.cfg.audio_rate)
|
|
return decode_morse(cw.process(iq), cw.audio_rate)
|
|
except Exception as exc:
|
|
self._error(exc)
|
|
return None
|
|
|
|
def _submit_transcription(self, rec: Recording, hit: HitRecord) -> None:
|
|
"""Queue a voice capture for speech recognition.
|
|
|
|
Only voice: running a recogniser over Morse or a data burst wastes
|
|
seconds per capture and produces nothing.
|
|
"""
|
|
worker = self.transcriber
|
|
if worker is None or hit.category != "voice":
|
|
return
|
|
if hit.duration < self.cfg.transcribe_min_seconds:
|
|
return
|
|
|
|
audio, rate = None, rec.audio_rate
|
|
if rec.audio_path.exists():
|
|
try:
|
|
audio, rate = read_wav(rec.audio_path)
|
|
except (OSError, ValueError) as exc:
|
|
self._error(exc)
|
|
if audio is None or not audio.size:
|
|
audio = rec.classification_audio(active_only=False)
|
|
if audio is None or not audio.size:
|
|
return
|
|
|
|
# Beside the recording, sharing its name. When captures are being
|
|
# combined by frequency there is one recording per frequency, so the
|
|
# transcripts are appended to one file per frequency too.
|
|
if self.frequency_log is not None and hit.combined_path:
|
|
path = Path(hit.combined_path).with_suffix("")
|
|
path = path.with_name(path.name + "_transcription.txt")
|
|
append = True
|
|
else:
|
|
path = rec.dir / f"{rec.stem}_transcription.txt"
|
|
append = False
|
|
worker.submit(audio, rate, path,
|
|
datetime.fromtimestamp(rec.started_at), hit.frequency,
|
|
append=append,
|
|
meta_path=Path(hit.meta_path) if hit.meta_path else None,
|
|
recording=rec.audio_path.name)
|
|
|
|
def _on_transcript(self, path, result, job) -> None:
|
|
"""Pull callsigns out of a finished transcript and map them.
|
|
|
|
Runs on the transcription thread, well off the scan loop, so the
|
|
licence lookups it waits on cannot delay the sweep. Everything here
|
|
is best-effort: a scan must not fail because a website did not
|
|
answer.
|
|
"""
|
|
book = self.callsigns
|
|
if book is None:
|
|
return
|
|
try:
|
|
calls = find_callsigns(result.text)
|
|
if not calls:
|
|
return
|
|
entries = book.get_all(calls)
|
|
# The lookups were started by get_all and run in their own
|
|
# threads; waiting here is what turns "pending" into a name. A
|
|
# short wait, because a slow answer is not worth holding the
|
|
# queue for -- the callsign is already in the cache request and
|
|
# will be filled in by the time the next one is looked up.
|
|
book.wait(timeout=8.0)
|
|
book.save()
|
|
when = job.when.timestamp()
|
|
band = label_for(job.frequency).name
|
|
before = len(self.kml) if self.kml is not None else 0
|
|
changed = False
|
|
for entry in entries:
|
|
self.heard[entry.call] = self.heard.get(entry.call, 0) + 1
|
|
self._announce_callsign(entry)
|
|
if self.kml is not None:
|
|
changed |= self.kml.add(entry, job.frequency, when, band,
|
|
job.recording)
|
|
if changed and self.kml is not None:
|
|
written = self.kml.save()
|
|
# Only when the map gained a station. Every over of a long
|
|
# net adds a line to a pin that is already there, and saying
|
|
# so each time would push everything else off the status
|
|
# line.
|
|
if written is not None and len(self.kml) > before:
|
|
self._status(f"{len(self.kml)} station(s) on the map in "
|
|
f"{written.name}")
|
|
except Exception as exc:
|
|
self._error(exc)
|
|
|
|
def _announce_callsign(self, entry) -> None:
|
|
"""Say on the display who has just identified themselves."""
|
|
if self.heard.get(entry.call, 0) > 1:
|
|
return # already said, and saying it every over is noise
|
|
summary = entry.summary()
|
|
self._status(f"heard {entry.call}"
|
|
+ (f" — {summary}" if summary and
|
|
entry.status == "found" else ""))
|
|
|
|
def _identify(self, rec: Recording, hit: HitRecord, demod,
|
|
analysis=None, continuous_for: float = 0.0) -> Assessment | None:
|
|
"""Attach the classification, Morse text and content verdict to a hit."""
|
|
if not self.cfg.classify:
|
|
return None
|
|
cls, morse, verdict = analysis if analysis is not None else \
|
|
self._analyse(rec, demod, hit.frequency, hit.snr_db, final=True,
|
|
continuous_for=continuous_for)
|
|
if cls is None:
|
|
return None
|
|
|
|
hit.classification = cls.label
|
|
hit.family = cls.family
|
|
hit.confidence = cls.confidence
|
|
hit.reasons = list(cls.reasons)
|
|
hit.alternatives = list(cls.alternatives)
|
|
f = cls.features
|
|
if f is not None:
|
|
hit.ctcss_hz = f.ctcss_hz
|
|
hit.baud = f.baud
|
|
hit.bandwidth = f.bandwidth or hit.bandwidth
|
|
hit.features = {
|
|
k: (round(v, 4) if isinstance(v, float) else v)
|
|
for k, v in f.__dict__.items()
|
|
if k != "extras" and not k.startswith("_")
|
|
}
|
|
|
|
if morse is not None and morse.is_morse:
|
|
hit.morse_text = morse.text
|
|
hit.morse_wpm = round(morse.wpm, 1)
|
|
hit.classification = f"CW / Morse at {morse.wpm:.0f} WPM"
|
|
hit.family = "cw"
|
|
hit.confidence = max(hit.confidence, morse.confidence)
|
|
hit.reasons.insert(0, f'decoded Morse: "{morse.text.strip()}"')
|
|
elif morse is not None and cls.family == "cw":
|
|
hit.reasons.append(
|
|
"keyed carrier but the Morse timing did not resolve")
|
|
|
|
if verdict is not None and verdict.category == "voice" and \
|
|
hit.family not in ("nfm", "wfm", "am", "ssb"):
|
|
# The content check found speech, so the modulation label was
|
|
# wrong. Speech on a quiet FM channel is easy to mistake for
|
|
# two-level FSK -- during the pauses between phrases the only
|
|
# thing left modulating the carrier is the CTCSS tone, whose
|
|
# discriminator output has a peak at each end of its swing.
|
|
# Name it after what was actually heard.
|
|
spoken = {"nfm": ("Narrowband FM voice", "nfm"),
|
|
"wfm": ("Wideband FM broadcast", "wfm"),
|
|
"am": ("AM voice", "am"),
|
|
"usb": ("SSB voice (USB)", "ssb"),
|
|
"lsb": ("SSB voice (LSB)", "ssb")}
|
|
label, fam = spoken.get(hit.mode, ("Voice transmission", "nfm"))
|
|
if hit.ctcss_hz:
|
|
label += f" (CTCSS {hit.ctcss_hz:.1f} Hz)"
|
|
hit.reasons.insert(0, f"identified from its audio; the modulation "
|
|
f"measurements alone suggested "
|
|
f"{hit.classification.lower()}")
|
|
hit.classification = label
|
|
hit.family = fam
|
|
|
|
if verdict is not None:
|
|
hit.category = verdict.category
|
|
hit.signal_score = round(verdict.score, 3)
|
|
hit.content_reason = verdict.reason
|
|
hit.voice_score = round(verdict.voice.score, 3)
|
|
hit.noise_likeness = round(verdict.noise_likeness, 3)
|
|
return verdict
|
|
|
|
# -- main loop -----------------------------------------------------------
|
|
def run(self) -> ScanStats:
|
|
if not self.plan:
|
|
self.prepare()
|
|
self.stats = ScanStats()
|
|
self.stats.state = "sweeping"
|
|
cfg = self.cfg
|
|
deadline = (self.stats.started_at + cfg.max_runtime_seconds
|
|
if cfg.max_runtime_seconds else None)
|
|
|
|
try:
|
|
while not self._stop.is_set():
|
|
for i, step in enumerate(self.plan):
|
|
if self._stop.is_set():
|
|
break
|
|
while self._pause.is_set() and not self._stop.is_set():
|
|
self.stats.state = "paused"
|
|
time.sleep(0.1)
|
|
if self._stop.is_set():
|
|
break
|
|
self.stats.state = "sweeping"
|
|
|
|
if deadline and time.time() >= deadline:
|
|
self._status("runtime limit reached")
|
|
self._stop.set()
|
|
break
|
|
|
|
try:
|
|
freqs, psd_db, excess, margin = self.measure_step(step)
|
|
except RtlSdrError as exc:
|
|
self._error(exc)
|
|
time.sleep(0.2)
|
|
continue
|
|
|
|
self.stats.steps_done += 1
|
|
self.stats.current_freq = step.center
|
|
self.stats.current_range = step.range_label
|
|
if self.cb.on_step:
|
|
try:
|
|
self.cb.on_step(i, len(self.plan), step, psd_db, freqs)
|
|
except Exception:
|
|
pass
|
|
|
|
rng = cfg.ranges[step.range_index] \
|
|
if step.range_index < len(cfg.ranges) else None
|
|
thr = (rng.threshold_db if rng and rng.threshold_db is not None
|
|
else cfg.threshold_db)
|
|
|
|
for det in self.find_detections(step, freqs, psd_db,
|
|
excess, thr, margin):
|
|
if self._stop.is_set():
|
|
break
|
|
if not self._should_visit(det):
|
|
continue
|
|
self.stats.detections += 1
|
|
if self.cb.on_detection:
|
|
try:
|
|
self.cb.on_detection(det)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.capture(det)
|
|
except RtlSdrError as exc:
|
|
self._error(exc)
|
|
# Carry on through the rest of this step's detections.
|
|
# Their spectrum reading is now stale, but each one is
|
|
# re-checked against the squelch after retuning, so a
|
|
# signal that has since dropped is discarded cheaply
|
|
# instead of starving the weaker hits in this step.
|
|
|
|
self.stats.cycles += 1
|
|
if self.cb.on_cycle:
|
|
try:
|
|
self.cb.on_cycle(self.stats.cycles)
|
|
except Exception:
|
|
pass
|
|
if cfg.max_cycles and self.stats.cycles >= cfg.max_cycles:
|
|
break
|
|
finally:
|
|
self.stats.state = "stopped"
|
|
if self.transcriber is not None:
|
|
pending = self.transcriber.pending
|
|
if pending:
|
|
self._status(f"finishing {pending} transcription(s)")
|
|
self.transcriber.close()
|
|
# After the worker has stopped, so the last transcript's callsigns
|
|
# are on the map before it is written for the last time.
|
|
if self.callsigns is not None:
|
|
self.callsigns.save()
|
|
if self.kml is not None:
|
|
self.kml.save()
|
|
if self._own_device and self.device is not None:
|
|
self.device.close()
|
|
return self.stats
|