Hexadecimal is a true answer to "what did that say" and not a useful one. This is the work of turning the rest of what a receiver hears into something a person can read, and most of it is pictures. PICTURES Three of the things on the air are images rather than sounds, and all three arrive as the audio a scan already records: SSTV 14.230 and 144.5 MHz Martin M1/M2, Scottie S1/S2/DX, Robot 36/72 APT 137-138 MHz the NOAA weather satellites HF fax 2-20 MHz, sideband the marine weather charts Each is written from its published specification, and the generators used to test them are written from the same specification without reference to the decoders -- so a picture that comes back matching the one that went in is evidence about the format. Every SSTV mode reproduces its published line time exactly, which is worth failing a test over: a line a few milliseconds long walks the picture off the screen inside ten lines. Against synthetic transmissions at 30 dB SNR, SSTV is 96-98% of pixels exact, APT correlates at 0.97 and fax at 0.998; all three still read at 6-12 dB. None of the three is guessed at, and that is what makes it safe to try them on every recording. SSTV needs its VIS header, APT needs both line syncs at the right distance from each other, fax needs the phasing signal. No false pictures in 295 attempts over noise, tones, speech and swept whistles. Two things had to be got right beyond the arithmetic. A band-pass does not switch between two tones, it slides between them, so every edge is measured at the midpoint of the slide rather than at the first sample past a threshold -- the earlier version was reading the coarse search stride back as the edge and shifting Martin M1 sideways by a whole colour bar. And a picture now keeps its capture whatever the content check made of it: a satellite is a steady tone with a wobble on it and SSTV is a whistle, so both were being discarded as "no signal content" having already been recognised. PNG is written here rather than pulled in from Pillow. A scanner that cannot start because an imaging library is missing is worse than one that cannot draw. saunterbrowse marks a picture in the list, gives its path in full -- wrapped rather than cut off, because half a path opens nothing -- and moves or deletes the PNGs with the recording. o prints the picture's path, not the audio's. GRIB is not a modulation and is not pretended to be one. It is the format weather models are published in and it travels by satellite link and by e-mail; where a decoded byte stream begins with its magic number it is named, and that is all. AIRCRAFT `bandsaunter adsb` parks the receiver on 1090 MHz and reads Mode S extended squitter: address, callsign, altitude, position, speed. A command of its own because a megabit a second will not go through a channel twelve and a half kilohertz wide. Every frame carries a 24-bit checksum so there is no threshold anywhere in it -- with one trap, which is that a frame of all zeros satisfies that checksum and silence is exactly that. Positions round trip exactly through compact position reporting, and a pair straddling a longitude-zone boundary is refused rather than resolved against two grids. METERS AND SENSORS Itron ERT utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named rather than reported as hex, and neither is believed without its own checksum -- BCH(255,239) for the meter, a checksum and four parity bits for the sensor. Both are implemented from published descriptions and checked against frames built from the same descriptions, which proves the framing and the arithmetic and is not the same as having held a meter. HEX INTO WORDS Everything else that decodes to bits now gets its fields named where the shape is standard, its text read out where there is text, and its bytes laid out in groups with the printable characters beside them. The text search is where the care went, because printability is not evidence. Forty framings of each packet, and seven-bit values printable three in four, meant a bar set on printability called 64% of random payloads text. Real text is nearly all one case where random letters are half and half, two fifths vowels where random is a fifth, and mostly alphanumeric where random draws punctuation one time in four. Together: under 0.5%, measured in the suite. CALLSIGNS The licensed address is recorded in full -- the street, not merely the town -- and goes into the KML with everything else. US amateur records are public by law and carry it; holding it and not saying so is worse than either showing it or not asking, and --no-lookup asks for none of it. Also here: Morse is decoded again from the whole recording where the capture was made in cw mode. The first pass works from the classifier's buffer, which holds a few seconds -- enough to say "this is Morse", not enough to catch a callsign whole between two word gaps, so a beacon repeating every eight seconds through an eight-second window was never identified. And classify._psk_order took the logarithm of zero on a silent block. 1318 tests, up from 1161. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1129 lines
60 KiB
Python
1129 lines
60 KiB
Python
"""One description of every setting, shared by the command line and the TUI.
|
|
|
|
Both front ends are generated from this table, so a setting cannot exist in
|
|
one and be missing from the other, and the help text is written once.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, fields, replace
|
|
|
|
from .bandplan import fmt_hz
|
|
from .quality import CATEGORIES as CONTENT_CATEGORIES
|
|
|
|
__all__ = ["Setting", "SETTINGS", "GROUPS", "SettingError", "by_key",
|
|
"in_group", "parse_value", "format_value", "add_arguments",
|
|
"apply_args", "search"]
|
|
|
|
|
|
class SettingError(ValueError):
|
|
"""Raised when a value the user typed cannot be used."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Setting:
|
|
key: str # attribute on ScanConfig
|
|
label: str
|
|
group: str
|
|
kind: str # see _parse dispatch below
|
|
help: str # one line, shown in lists and --help
|
|
detail: str = "" # paragraph, shown by the built-in help
|
|
choices: tuple[str, ...] = ()
|
|
unit: str = ""
|
|
minimum: float | None = None
|
|
maximum: float | None = None
|
|
flags: tuple[str, ...] = () # value flags, or the "on" flags for a bool
|
|
off_flags: tuple[str, ...] = () # bool flags that turn it off
|
|
metavar: str = ""
|
|
example: str = ""
|
|
guidance: str = "" # plain words: what it is, when to change it
|
|
|
|
@property
|
|
def dest(self) -> str:
|
|
return self.key
|
|
|
|
def describe_range(self) -> str:
|
|
if self.choices:
|
|
return "one of: " + ", ".join(self.choices)
|
|
bits = []
|
|
if self.minimum is not None:
|
|
bits.append(f"at least {self.minimum:g}")
|
|
if self.maximum is not None:
|
|
bits.append(f"at most {self.maximum:g}")
|
|
return ", ".join(bits)
|
|
|
|
|
|
S = Setting
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The table. Order within a group is the order the TUI shows them.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TABLE: tuple[Setting, ...] = (
|
|
|
|
# -- dwell -------------------------------------------------------------
|
|
S("record_seconds", "Record for", "Dwell and recording", "float",
|
|
"longest one signal may hold the receiver (0 = no limit)",
|
|
"The hard cap on a single capture. Reached even if the signal is still "
|
|
"transmitting. Set it to 0 to stay for as long as the transmission "
|
|
"lasts, which is what you want for capturing a whole conversation; "
|
|
"'Absolute limit' still bounds it.",
|
|
unit="s", minimum=0.0, flags=("--record", "--record-seconds"),
|
|
metavar="SEC", example="30"),
|
|
S("hang_seconds", "Wait for quiet", "Dwell and recording", "float",
|
|
"quiet time before the sweep resumes",
|
|
"How long the channel must stay quiet before the transmission is "
|
|
"treated as over. Gaps shorter than this are recorded straight "
|
|
"through, so a two-way exchange stays in one file across the pauses "
|
|
"between overs. 'Quiet' means no real signal: silence, static and "
|
|
"interference all count towards it.",
|
|
unit="s", minimum=0.0, flags=("--hang", "--hang-seconds"),
|
|
metavar="SEC", example="3"),
|
|
S("max_record_seconds", "Absolute limit", "Dwell and recording", "float",
|
|
"ceiling on one capture, even when 'Record for' is 0",
|
|
"A safety stop so an unlimited capture cannot run until the disk is "
|
|
"full. 0 removes it entirely.",
|
|
unit="s", minimum=0.0, flags=("--max-record",), metavar="SEC",
|
|
example="900"),
|
|
S("min_record_seconds", "Discard shorter than", "Dwell and recording",
|
|
"float", "throw away captures shorter than this",
|
|
"Brief noise spikes that open the squelch for a moment leave nothing "
|
|
"behind on disk.",
|
|
unit="s", minimum=0.0, flags=("--min-record",), metavar="SEC",
|
|
example="0.5"),
|
|
S("revisit_seconds", "Ignore again for", "Dwell and recording", "float",
|
|
"hold-off before the same frequency can be recorded again",
|
|
"Stops one busy repeater monopolising the sweep.",
|
|
unit="s", minimum=0.0, flags=("--revisit",), metavar="SEC",
|
|
example="8"),
|
|
|
|
# -- detection ----------------------------------------------------------
|
|
S("threshold_db", "Squelch threshold", "Detection", "float",
|
|
"margin over the noise before a signal counts as present",
|
|
"Measured in dB above the noise, not above the noise floor: the offset "
|
|
"that noise alone clears with the current detector is worked out and "
|
|
"added automatically, so this number is real headroom. Raise it if the "
|
|
"scan stops on too much; lower it to catch weaker signals.",
|
|
unit="dB", minimum=0.5, flags=("-t", "--threshold"), metavar="DB",
|
|
example="10"),
|
|
S("dwell_seconds", "Dwell per step", "Detection", "float",
|
|
"how long the sweep listens at each tuner position",
|
|
"Longer dwells catch briefer transmissions but slow the sweep. The "
|
|
"time for one full pass is roughly this times the number of steps.",
|
|
unit="s", minimum=0.005, maximum=5.0, flags=("--dwell",),
|
|
metavar="SEC", example="0.05"),
|
|
S("resolution_hz", "Sweep resolution", "Detection", "float",
|
|
"FFT bin width while sweeping",
|
|
"Finer resolution separates close signals and lowers the noise in each "
|
|
"bin, at the cost of a larger transform per step.",
|
|
unit="Hz", minimum=100.0, flags=("--resolution",), metavar="HZ",
|
|
example="3000"),
|
|
S("detector", "Detector", "Detection", "choice",
|
|
"peak-hold catches bursts; averaging is quieter",
|
|
"Peak-hold keeps the strongest value each bin reached during the "
|
|
"dwell, so a keyed or bursty transmission that is off for part of the "
|
|
"dwell is still found. Averaging gives a smoother estimate of a steady "
|
|
"signal.",
|
|
choices=("peak", "avg"), flags=("--detector",), metavar="MODE"),
|
|
S("detector_bias_db", "Detector bias", "Detection", "opt_float",
|
|
"override the computed noise-peak offset (blank = automatic)",
|
|
"Peak-hold makes noise alone ride several dB above the measured floor. "
|
|
"That offset is derived from the detector and added to the threshold "
|
|
"automatically. Set a number here only to override it.",
|
|
unit="dB", minimum=0.0, flags=("--detector-bias",), metavar="DB"),
|
|
S("squelch_margin_db", "Squelch hysteresis", "Detection", "float",
|
|
"how far below the threshold a signal may drop before it counts as gone",
|
|
"Stops a signal sitting exactly on the threshold from chattering the "
|
|
"squelch open and shut.",
|
|
unit="dB", minimum=0.0, flags=("--squelch-margin",), metavar="DB"),
|
|
S("adaptive_floor", "Smooth the noise floor", "Detection", "bool",
|
|
"average the measured floor across sweeps",
|
|
"Averaging the per-bin floor over successive passes stops the "
|
|
"threshold jittering with the randomness of a single short capture. "
|
|
"The floor itself comes from a percentile that steps over signals, so "
|
|
"this cannot hide a station.",
|
|
flags=("--adaptive-floor",), off_flags=("--no-adaptive-floor",)),
|
|
S("max_detections_per_step", "Signals per step", "Detection", "int",
|
|
"how many separate signals one tuner position may report",
|
|
"A step covering a busy stretch of spectrum can hold several signals. "
|
|
"Each is visited in turn, strongest first.",
|
|
minimum=1, flags=("--max-detections",), metavar="N"),
|
|
S("probe_seconds", "Probe length", "Detection", "float",
|
|
"look at a signal for this long before choosing a demodulator",
|
|
"The demodulator is chosen from what the signal physically is rather "
|
|
"than from the band plan alone. The probe is played into the recording "
|
|
"rather than discarded, so lengthening it does not lose audio.",
|
|
unit="s", minimum=0.05, maximum=3.0, flags=("--probe",), metavar="SEC"),
|
|
|
|
# -- content gate --------------------------------------------------------
|
|
S("require_signal", "Check for content", "What counts as a signal", "bool",
|
|
"only keep captures that carry voice, CW or data",
|
|
"A power threshold cannot tell a transmission from a hump of "
|
|
"interference. With this on, every capture is checked for content and "
|
|
"anything that turns out to be static, hum or a bare carrier is "
|
|
"deleted. Turning it off records anything above the squelch.",
|
|
flags=("--require-signal",),
|
|
off_flags=("--keep-everything", "--no-require-signal")),
|
|
S("accept", "Record these", "What counts as a signal", "accept_list",
|
|
"which kinds of content are worth keeping",
|
|
"voice - speech structure in the demodulated audio. "
|
|
"cw - a keyed carrier whose timing resolves as Morse. "
|
|
"digital - an identified keying scheme with a steady symbol rate. "
|
|
"trunk - a trunked system's control channel. "
|
|
"carrier - a steady unmodulated carrier. "
|
|
"noise - everything else.",
|
|
choices=CONTENT_CATEGORIES, flags=("--accept",), metavar="LIST",
|
|
example="voice,cw,digital"),
|
|
S("min_signal_score", "Minimum confidence", "What counts as a signal",
|
|
"float", "confidence needed to keep a capture",
|
|
"How sure the content check must be before a recording is kept. "
|
|
"Between 0 and 1.",
|
|
minimum=0.0, maximum=1.0, flags=("--min-signal-score",), metavar="N"),
|
|
S("min_voice_score", "Minimum speech score", "What counts as a signal",
|
|
"float", "how speech-like audio must be to count as voice",
|
|
"Speech is recognised by a pitch track that drifts the way intonation "
|
|
"does, pauses between phrases, syllable-rate modulation and moving "
|
|
"formants. Lower this if real voice is being missed; raise it if "
|
|
"noise is getting through. Between 0 and 1.",
|
|
minimum=0.0, maximum=1.0, flags=("--min-voice-score",), metavar="N"),
|
|
S("verify_seconds", "Re-check every", "What counts as a signal", "float",
|
|
"how often a live capture is re-examined",
|
|
"The content check runs while the capture is still going, so "
|
|
"interference is dropped after a second or two rather than holding the "
|
|
"receiver for the whole record time.",
|
|
unit="s", minimum=0.2, flags=("--verify-every",), metavar="SEC"),
|
|
S("verify_max_seconds", "Give up after", "What counts as a signal",
|
|
"float", "how long a capture has to show any content",
|
|
"A capture that has produced no recognisable content by this point is "
|
|
"abandoned. Once a capture *has* produced content it is never "
|
|
"abandoned this way, so a pause between overs cannot cut a "
|
|
"conversation short.",
|
|
unit="s", minimum=0.5, flags=("--verify-max",), metavar="SEC"),
|
|
|
|
# -- trunking -------------------------------------------------------------
|
|
S("skip_control", "Skip trunk control channels", "Trunking", "bool",
|
|
"spot a trunked system's control channel and move straight on",
|
|
"A trunked radio system keeps one channel transmitting data day and "
|
|
"night so the radios know where each conversation has been put. There "
|
|
"is no speech on it and it never stops.",
|
|
flags=("--skip-control",),
|
|
off_flags=("--keep-control", "--no-skip-control")),
|
|
S("control_seconds", "Control channel patience", "Trunking", "float",
|
|
"unbroken carrier a digital-voice look-alike must hold",
|
|
"P25, DMR and NXDN control channels have the same shape as a digital "
|
|
"voice call on the same system. A carrier still running unbroken after "
|
|
"this long is a control channel, because a conversation would have "
|
|
"paused by now.",
|
|
unit="s", minimum=1.0, flags=("--control-seconds",), metavar="SEC"),
|
|
S("lockout_control", "Lock out control channels", "Trunking", "bool",
|
|
"add every control channel found to the lock-out list",
|
|
"Off by default: a control channel is cheap to recognise and skip, so "
|
|
"there is no need to write it down. Turn it on to stop revisiting it "
|
|
"at all.",
|
|
flags=("--lockout-control",),
|
|
off_flags=("--no-lockout-control",)),
|
|
|
|
# -- receiver -------------------------------------------------------------
|
|
S("device_index", "Device index", "Receiver", "int",
|
|
"which dongle to use when more than one is attached",
|
|
"See `bandsaunter devices` for the list.",
|
|
minimum=0, flags=("-d", "--device"), metavar="N"),
|
|
S("sample_rate", "Sample rate", "Receiver", "int",
|
|
"capture rate; sets how much spectrum one step covers",
|
|
"The RTL2832 only locks 225001-300000 Hz and 900001-3200000 Hz. "
|
|
"2.048 MS/s is a good default: wide enough to sweep quickly and slow "
|
|
"enough that the host keeps up.",
|
|
unit="Hz", flags=("--sample-rate",), metavar="HZ", example="2048000"),
|
|
S("gain", "Tuner gain", "Receiver", "gain",
|
|
"gain in dB, or 'auto' for the tuner's own control",
|
|
"Manual gain is usually better for scanning: automatic gain rises in "
|
|
"quiet parts of the band and amplifies noise until it breaks squelch. "
|
|
"`bandsaunter devices --test` lists the steps this tuner supports.",
|
|
unit="dB", flags=("-g", "--gain"), metavar="DB|auto"),
|
|
S("ppm", "Frequency correction", "Receiver", "int",
|
|
"crystal error correction",
|
|
"Cheap dongles can be tens of ppm off. A wrong value shifts every "
|
|
"measured frequency.",
|
|
unit="ppm", flags=("--ppm",), metavar="N"),
|
|
S("agc", "RTL2832 AGC", "Receiver", "bool",
|
|
"the demodulator chip's own automatic gain",
|
|
"Separate from the tuner gain above. Usually best left off.",
|
|
flags=("--agc",), off_flags=("--no-agc",)),
|
|
S("bias_tee", "Bias tee", "Receiver", "bool",
|
|
"put DC on the antenna port to power an external amplifier",
|
|
"Only enable this if you know the hardware supports it and nothing on "
|
|
"the antenna port would be damaged by DC.",
|
|
flags=("--bias-tee",), off_flags=("--no-bias-tee",)),
|
|
S("offset_tuning", "Offset tuning", "Receiver", "bool",
|
|
"shift the tuner's own local oscillator (E4000 tuners only)",
|
|
"Has no effect on the common R820T. The scanner already offsets its "
|
|
"local oscillator digitally to keep the DC spike off the signal.",
|
|
flags=("--offset-tuning",), off_flags=("--no-offset-tuning",)),
|
|
S("direct_sampling", "Direct sampling", "Receiver", "direct",
|
|
"HF reception below 24 MHz: auto, 0 off, 1 I branch, 2 Q branch",
|
|
"Below about 24 MHz the tuner cannot reach, so the signal is fed "
|
|
"straight into the digitiser. 'auto' switches it on for frequencies "
|
|
"the tuner cannot reach and off again above them. Most dongles use the "
|
|
"Q branch. There is no front-end filtering or gain in this mode.",
|
|
choices=("auto", "0", "1", "2"), flags=("--direct-sampling",),
|
|
metavar="MODE"),
|
|
S("usable_fraction", "Usable bandwidth", "Receiver", "float",
|
|
"fraction of the sample rate each step covers",
|
|
"The edges of a capture sit in the anti-alias roll-off, so only the "
|
|
"middle is searched. Raising this sweeps faster but reaches further "
|
|
"into the roll-off.",
|
|
minimum=0.1, maximum=0.95, flags=("--usable-fraction",), metavar="N"),
|
|
S("dc_guard_hz", "DC guard", "Receiver", "float",
|
|
"how far the local oscillator is parked below each step",
|
|
"An RTL2832 always shows a spike at whatever it is tuned to. Parking "
|
|
"the oscillator below the span being searched keeps that spike out of "
|
|
"the results.",
|
|
unit="Hz", minimum=0.0, flags=("--dc-guard",), metavar="HZ"),
|
|
|
|
# -- output ---------------------------------------------------------------
|
|
S("output_dir", "Output directory", "Output", "path",
|
|
"where recordings and logs are written",
|
|
"Files are named yyyy-mm-dd_hh.mm.ss_frequency_modulation and all live "
|
|
"in this one directory. A relative path is taken from wherever the "
|
|
"scan is started.",
|
|
flags=("-o", "--output"), metavar="DIR"),
|
|
S("save_audio", "Save audio", "Output", "bool",
|
|
"write a WAV of the demodulated audio",
|
|
"16-bit mono PCM at the demodulator's natural rate.",
|
|
flags=("--audio",), off_flags=("--no-audio",)),
|
|
S("save_iq", "Save raw IQ", "Output", "bool",
|
|
"also write the raw complex samples and a SigMF sidecar",
|
|
"Large, but lets a capture be re-analysed later or opened in other SDR "
|
|
"tools. Roughly 8 bytes per sample at the intermediate rate.",
|
|
flags=("--iq", "--save-iq"), off_flags=("--no-iq",)),
|
|
S("iq_format", "IQ format", "Output", "choice",
|
|
"sample format for raw IQ files",
|
|
"cf32 is 32-bit floats, the easiest to work with. cs16 is 16-bit "
|
|
"integers and half the size.",
|
|
choices=("cf32", "cs16"), flags=("--iq-format",), metavar="FMT"),
|
|
S("audio_rate", "Audio rate", "Output", "int",
|
|
"preferred audio sample rate",
|
|
"The nearest rate the decimation chain can reach exactly is used, so "
|
|
"the recorded rate may differ slightly. Broadcast FM is given at least "
|
|
"32 kHz regardless.",
|
|
unit="Hz", minimum=4000, flags=("--audio-rate",), metavar="HZ"),
|
|
S("classify", "Identify signals", "Output", "bool",
|
|
"work out what kind of signal each capture is",
|
|
"Turning this off also turns off the content check, since that is what "
|
|
"decides whether a capture is worth keeping.",
|
|
flags=("--classify",), off_flags=("--no-classify",)),
|
|
S("decode_images", "Decode pictures", "Output", "bool",
|
|
"save SSTV, weather satellite and shortwave fax pictures as PNG",
|
|
"Some transmissions are pictures rather than sounds. This looks for "
|
|
"them in every recording and writes what it finds beside the audio.",
|
|
flags=("--images",), off_flags=("--no-images",)),
|
|
S("decode_morse", "Decode CW to text", "Output", "bool",
|
|
"decode keyed carriers as Morse",
|
|
"Speed is measured from the signal, so nothing needs configuring. "
|
|
"Anything from about 8 to 40 WPM reads reliably.",
|
|
flags=("--morse",), off_flags=("--no-morse",)),
|
|
S("combine_by_frequency", "Combine by frequency", "Combining", "bool",
|
|
"collect every transmission on one frequency into a single file",
|
|
"Instead of a file per transmission, each frequency gets one file that "
|
|
"later receptions are appended to as the scan continues, so a whole "
|
|
"watch on a channel plays back as one recording. The file stays valid "
|
|
"while the scan runs, so it can be opened at any time.",
|
|
flags=("--combine",), off_flags=("--no-combine",)),
|
|
S("announce_timestamps", "Speak the time", "Combining", "bool",
|
|
"insert a spoken date and time before each transmission",
|
|
"Each recording is preceded by its date and time read aloud, so the "
|
|
"combined file says when everything was heard. Uses an installed "
|
|
"text-to-speech program if there is one, and a built-in synthesiser "
|
|
"otherwise, so it works with nothing else installed.",
|
|
flags=("--announce",), off_flags=("--no-announce",)),
|
|
S("announce_frequency", "Speak the frequency", "Combining", "bool",
|
|
"also read the frequency out with the timestamp",
|
|
"Useful when several combined files are played back together, or when "
|
|
"the tuned frequency drifts between receptions.",
|
|
flags=("--announce-frequency",), off_flags=("--no-announce-frequency",)),
|
|
S("announce_engine", "Speech engine", "Combining", "text",
|
|
"which text-to-speech to use: auto, builtin, or a program name",
|
|
"'auto' uses the first of espeak-ng, espeak, pico2wave, flite or say "
|
|
"that is installed, and falls back to the built-in formant "
|
|
"synthesiser. 'builtin' always uses the built-in one.",
|
|
flags=("--announce-engine",), metavar="NAME", example="auto"),
|
|
S("combine_tolerance_hz", "Same-frequency tolerance", "Combining", "float",
|
|
"how far apart two receptions may be and still count as one frequency",
|
|
"Detections wander by a few hundred hertz, and a channel is usually "
|
|
"wider than that, so receptions within this distance of each other are "
|
|
"written to the same file.",
|
|
unit="Hz", minimum=0.0, flags=("--combine-tolerance",), metavar="HZ"),
|
|
S("combine_keep_individual", "Keep separate files too", "Combining",
|
|
"bool", "also keep the one-file-per-transmission recordings",
|
|
"Off by default when combining, so each transmission exists in one "
|
|
"place. The .json describing each capture is written either way.",
|
|
flags=("--keep-individual",), off_flags=("--no-keep-individual",)),
|
|
|
|
S("transcribe", "Transcribe speech", "Transcription", "bool",
|
|
"write out what was said in each voice transmission",
|
|
"Recordings the content check identified as voice are passed to a "
|
|
"speech recogniser, and the text is written beside the recording as "
|
|
"<name>_transcription.txt. Recognition needs an installed engine and "
|
|
"takes seconds per capture, so it runs on its own thread and never "
|
|
"holds up the scan.",
|
|
flags=("--transcribe",), off_flags=("--no-transcribe",)),
|
|
S("transcribe_engine", "Recogniser", "Transcription", "text",
|
|
"which speech recogniser to use, or auto",
|
|
"'auto' picks the first installed of faster-whisper, whisper, "
|
|
"whisper-cli, vosk, pocketsphinx. Whisper handles the noise and "
|
|
"clipping of radio audio far better than the smaller recognisers, "
|
|
"which were trained on clean speech. Run `bandsaunter transcribe --engines` "
|
|
"to see what is installed.",
|
|
flags=("--transcribe-engine",), metavar="NAME", example="auto"),
|
|
S("transcribe_model", "Model", "Transcription", "text",
|
|
"model the recogniser should load",
|
|
"For whisper this is a size: tiny.en, base.en, small.en, medium.en. "
|
|
"Larger is more accurate and slower. For vosk it is a path to an "
|
|
"unpacked model directory.",
|
|
flags=("--transcribe-model",), metavar="NAME", example="base.en"),
|
|
S("transcribe_language", "Language", "Transcription", "text",
|
|
"language to expect, or blank to detect it",
|
|
"Fixing the language is worth doing: on a short noisy clip automatic "
|
|
"detection often guesses wrong and the transcript comes back as "
|
|
"nonsense in another language.",
|
|
flags=("--transcribe-language",), metavar="CODE", example="en"),
|
|
S("transcribe_min_seconds", "Skip clips shorter than", "Transcription",
|
|
"float", "do not bother transcribing very short captures",
|
|
"A fragment of a word costs as much to recognise as a sentence and "
|
|
"rarely produces anything useful.",
|
|
unit="s", minimum=0.0, flags=("--transcribe-min",), metavar="SEC"),
|
|
|
|
# -- callsigns ---------------------------------------------------------
|
|
S("decode_data", "Decode data signals", "Output", "bool",
|
|
"read the packets out of anything carrying data",
|
|
"On-off keyed remotes and sensors, two-level FSK, POCSAG paging and "
|
|
"APRS packet are all read down to their bits, and named where the "
|
|
"framing gives them away. Costs a fraction of a second per capture "
|
|
"and only runs on captures the classifier called data.",
|
|
flags=("--decode-data",), off_flags=("--no-decode-data",)),
|
|
|
|
S("callsign_lookup", "Look callsigns up", "Callsigns", "bool",
|
|
"ask the licence database who a callsign belongs to",
|
|
"Callsigns heard in a transcript are looked up in the FCC's published "
|
|
"licence data, which gives the licensee's name, town and coordinates. "
|
|
"The callsign is the only thing sent, and answers are cached, so a net "
|
|
"logged night after night is looked up once. Turn it off to keep the "
|
|
"scan entirely offline; callsigns are still found and still named by "
|
|
"country from their prefix.",
|
|
flags=("--callsign-lookup",),
|
|
off_flags=("--no-callsign-lookup", "--offline-callsigns")),
|
|
S("kml_file", "Map file", "Callsigns", "text",
|
|
"KML map of where the stations heard are licensed (blank = none)",
|
|
"Written inside the output directory and added to as the scan runs: "
|
|
"one pin per station, holding the callsign, the licensee, the town and "
|
|
"every frequency and time it was heard on. Opens in Google Earth, "
|
|
"QGIS, Marble and OsmAnd. A later scan adds to the same map rather "
|
|
"than starting a new one.",
|
|
flags=("--kml",), metavar="NAME", example="callsigns.kml"),
|
|
|
|
S("log_file", "Log file", "Output", "text",
|
|
"name of the run log inside the output directory",
|
|
"Written as JSON lines, with a matching .csv alongside it.",
|
|
flags=("--log-file",), metavar="NAME"),
|
|
|
|
# -- run control ----------------------------------------------------------
|
|
S("max_cycles", "Stop after sweeps", "Run control", "int",
|
|
"stop after this many full passes (0 = run until stopped)",
|
|
"", minimum=0, flags=("--cycles",), metavar="N"),
|
|
S("max_runtime_seconds", "Stop after time", "Run control", "float",
|
|
"stop after this long (0 = run until stopped)",
|
|
"", unit="s", minimum=0.0, flags=("--duration",), metavar="SEC"),
|
|
S("lockout", "Locked-out frequencies", "Run control", "lockout_list",
|
|
"never stop on these frequencies",
|
|
"Useful for a local pager transmitter or a birdie the receiver makes "
|
|
"itself. Separate several with commas, and give a span as a pair: "
|
|
"`162.55M, 450M-455M, 88 MHz to 108 MHz`. A single frequency is "
|
|
"widened by the lock-out width below; a span is taken exactly as "
|
|
"written. The lock-out key during a scan adds the current frequency "
|
|
"here, and it is remembered for later runs.",
|
|
flags=("--lockout",), metavar="FREQ|RANGE",
|
|
example="162.55M, 450M-455M"),
|
|
S("lockout_width", "Lock-out width", "Run control", "float",
|
|
"how wide a locked-out frequency is",
|
|
"A signal within half this distance of a locked-out frequency is "
|
|
"ignored.",
|
|
unit="Hz", minimum=1.0, flags=("--lockout-width",), metavar="HZ"),
|
|
S("quiet", "Quiet output", "Run control", "bool",
|
|
"print errors only",
|
|
"", flags=("--quiet",), off_flags=("--no-quiet",)),
|
|
S("save_lockouts", "Remember lock-outs", "Run control", "bool",
|
|
"keep frequencies locked out during a scan",
|
|
"The lock-out key writes the frequency back to the settings file it "
|
|
"came from, so a birdie stays locked out on every later run instead of "
|
|
"having to be locked out again. Only that one setting is written back: "
|
|
"options passed on the command line for a single run stay one-off. "
|
|
"Turn this off to keep lock-outs for the current run only.",
|
|
flags=("--save-lockouts",), off_flags=("--no-save-lockouts",)),
|
|
S("plain", "Plain display", "Run control", "bool",
|
|
"print one line per hit instead of the live display",
|
|
"The live display redraws a spectrum and a table in place, which wants "
|
|
"a real terminal. Plain output prints a line per recording instead, "
|
|
"which is what you want over ssh, in a log, or piped to another "
|
|
"program. It is chosen automatically when output is not a terminal.",
|
|
flags=("--plain",), off_flags=("--no-plain",)),
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plain-language guidance: what a setting is in everyday terms, and why
|
|
# someone who does not speak radio might turn it up, down, on or off. Kept
|
|
# apart from the table above so that table stays readable. Shown by the
|
|
# built-in help, and by the manual page, which is generated from here.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_GUIDANCE: dict[str, str] = {
|
|
"record_seconds":
|
|
"How long one signal may keep the receiver before the sweep moves "
|
|
"on. Set it to 0 and a transmission is followed until it actually "
|
|
"ends, which is what you want for listening to conversations. Give "
|
|
"it a number if you would rather sample a busy band widely than sit "
|
|
"on the first station that comes up.",
|
|
"hang_seconds":
|
|
"How much silence means 'they have finished talking'. People pause "
|
|
"between sentences, and two people taking turns leave a gap every "
|
|
"time the conversation changes hands; anything shorter than this is "
|
|
"recorded straight through, so an exchange stays in one file. Raise "
|
|
"it if conversations keep getting split into pieces, lower it if "
|
|
"the scanner sits on dead air.",
|
|
"max_record_seconds":
|
|
"A safety stop. With no record limit set, a stuck transmitter or a "
|
|
"continuously modulated data channel would record until the disk "
|
|
"filled, and this is where that stops. Only worth changing if you "
|
|
"are deliberately capturing something very long; 0 removes the stop "
|
|
"entirely.",
|
|
"min_record_seconds":
|
|
"Recordings shorter than this are deleted instead of kept. A click "
|
|
"of static or a passing car's ignition noise can hold the squelch "
|
|
"open for a fraction of a second, and this is what keeps those out "
|
|
"of the directory. Raise it if you are collecting lots of tiny "
|
|
"useless files; lower it if you are chasing very short "
|
|
"transmissions such as data bursts or single-word replies.",
|
|
"revisit_seconds":
|
|
"After recording a frequency, ignore it for this long. One busy "
|
|
"repeater can otherwise take over the whole scan, recorded again "
|
|
"and again while everything else goes unheard. Lower it if you want "
|
|
"everything from one channel; raise it if one talkative frequency "
|
|
"is drowning out the rest of the band.",
|
|
"threshold_db":
|
|
"How much louder than the background hiss something must be before "
|
|
"the scanner stops on it. This is the squelch knob. Too low and it "
|
|
"stops on noise; too high and it walks past quiet stations. If you "
|
|
"are getting nothing at all, try lowering it a few dB; if you are "
|
|
"recording static, raise it.",
|
|
"dwell_seconds":
|
|
"How long the receiver listens at each tuning position while "
|
|
"sweeping. A short dwell sweeps faster but can miss a transmission "
|
|
"that starts and ends between visits; a long one hears more of what "
|
|
"is there but goes round the band slowly. The default is a good "
|
|
"compromise for voice traffic.",
|
|
"resolution_hz":
|
|
"How finely the sweep divides the spectrum when looking for "
|
|
"signals. Finer resolution separates two stations sitting close "
|
|
"together and finds weak narrow ones, but takes longer to compute. "
|
|
"Worth making finer if you are scanning a crowded band of narrow "
|
|
"channels.",
|
|
"detector":
|
|
"How the sweep decides how strong each part of the band was. "
|
|
"Peak-hold remembers the loudest instant, so it catches a "
|
|
"transmission that starts and stops during the dwell, which is what "
|
|
"you want for scanning. Averaging is quieter and steadier, and is "
|
|
"better when you want a clean picture of the band rather than to "
|
|
"catch every burst.",
|
|
"detector_bias_db":
|
|
"A correction for the fact that peak-hold makes plain noise look "
|
|
"stronger than it is, which would otherwise trigger the squelch on "
|
|
"nothing. It is worked out automatically from the detector and the "
|
|
"sweep settings, and there is normally no reason to set it by hand.",
|
|
"squelch_margin_db":
|
|
"Once a signal has been found, it may drop this far below the "
|
|
"squelch threshold before being counted as gone. Without it a "
|
|
"station sitting exactly at the threshold would flicker on and off, "
|
|
"chopping the recording into fragments. Raise it if recordings of "
|
|
"weak stations keep breaking up.",
|
|
"adaptive_floor":
|
|
"Learns what the background noise looks like across several sweeps "
|
|
"rather than judging from one. It makes the squelch steadier, "
|
|
"especially where the noise level varies across the band. Turn it "
|
|
"off only if you are watching a band where the noise itself changes "
|
|
"fast and you want the scanner to react immediately.",
|
|
"max_detections_per_step":
|
|
"The sweep can see several stations at once in the chunk of "
|
|
"spectrum it is looking at, and this is how many of them it will "
|
|
"queue up. Raise it in a crowded band where you are missing "
|
|
"simultaneous transmissions; lower it if you would rather the "
|
|
"scanner keep moving.",
|
|
"probe_seconds":
|
|
"Before recording, the scanner listens briefly to work out what "
|
|
"kind of signal it is, so it can pick the right way to decode it. "
|
|
"Longer is a more reliable guess but delays every recording "
|
|
"slightly. The listen is not wasted: it is kept and played into the "
|
|
"start of the recording.",
|
|
"require_signal":
|
|
"Judge each capture and throw away anything that turns out to be "
|
|
"noise, static or interference rather than a real transmission. "
|
|
"This is the single setting that decides whether the recordings "
|
|
"directory is full of things worth listening to or full of hiss. "
|
|
"Turn it off only if you want everything that breaks squelch, "
|
|
"however empty.",
|
|
"accept":
|
|
"Which kinds of transmission are worth keeping: speech, Morse, "
|
|
"data, trunking control channels, plain unmodulated carriers, and "
|
|
"noise. Most people want voice, cw and digital. Add carrier if you "
|
|
"are hunting beacons or interference sources, trunk if you are "
|
|
"collecting control channels for a decoder, and noise only for "
|
|
"diagnosing why nothing is being recorded.",
|
|
"skip_control":
|
|
"Trunked radio systems -- the kind police, fire and large businesses "
|
|
"use -- keep one frequency transmitting a data stream around the "
|
|
"clock. It tells the radios which channel to jump to for each "
|
|
"conversation; it carries no speech and it never goes quiet. To a "
|
|
"scanner it looks like a very strong, very interesting signal, so "
|
|
"without this setting the receiver parks on it, records the whole "
|
|
"record limit of buzzing, and finds it again on the next sweep. With "
|
|
"this on it is recognised within a second or two, named on screen, "
|
|
"and skipped. Leave it on unless you are deliberately collecting "
|
|
"control channel data to feed to a decoder.",
|
|
"control_seconds":
|
|
"Only matters for P25, DMR and NXDN. On those systems the control "
|
|
"channel and an ordinary digital conversation look identical for the "
|
|
"first few seconds, and the only thing that tells them apart is that "
|
|
"the conversation eventually pauses and the control channel never "
|
|
"does. This is how long a signal has to keep going without a break "
|
|
"before it is judged to be a control channel. Raise it if digital "
|
|
"voice calls are being skipped by mistake; lower it if you are tired "
|
|
"of waiting out control channels. It has no effect on the older "
|
|
"Motorola and EDACS systems, which are recognised immediately from "
|
|
"their symbol rate.",
|
|
"lockout_control":
|
|
"Write each control channel into the lock-out list as it is found, "
|
|
"so the scanner stops even looking at it. Skipping one already costs "
|
|
"only a second or two, so this is worth turning on mainly if you "
|
|
"scan the same band constantly and want the list built for you. With "
|
|
"'Remember lock-outs' also on, the entries are saved to your "
|
|
"settings file and survive a restart.",
|
|
"min_signal_score":
|
|
"How certain the content check must be before a recording is kept, "
|
|
"from 0 to 1. Lower it if real transmissions are being discarded, "
|
|
"since weak or noisy signals score lower. Raise it if borderline "
|
|
"rubbish is still getting through.",
|
|
"min_voice_score":
|
|
"How speech-like audio has to sound before it is called voice. "
|
|
"Speech has a pitch that moves and a rhythm of syllables; a steady "
|
|
"tone or a hum does not. Lower it if quiet or distorted speech is "
|
|
"being missed, raise it if music, hum or engine noise is being "
|
|
"recorded as if it were someone talking.",
|
|
"verify_seconds":
|
|
"How often a recording in progress is re-examined to see whether it "
|
|
"is still carrying anything. Checking often releases the receiver "
|
|
"quickly when a channel turns out to be holding static; checking "
|
|
"rarely costs less processing.",
|
|
"verify_max_seconds":
|
|
"How long a recording gets to prove it contains something. If "
|
|
"nothing recognisable has appeared by then it is abandoned and "
|
|
"deleted, and the sweep moves on rather than sitting on an open but "
|
|
"empty channel.",
|
|
"device_index":
|
|
"Which receiver to use when more than one is plugged in. Run "
|
|
"'bandsaunter devices' to see the list and their numbers. With a "
|
|
"single dongle this can be ignored.",
|
|
"sample_rate":
|
|
"How much spectrum the receiver takes in at once, which sets how "
|
|
"much ground each step of the sweep covers. Higher covers the band "
|
|
"in fewer steps but makes the computer work harder, and cheap "
|
|
"dongles start dropping samples. 2.048 MS/s suits most machines; "
|
|
"drop it if recordings sound wrong or the program warns about "
|
|
"dropped samples.",
|
|
"gain":
|
|
"How much the receiver amplifies. Automatic gain sounds convenient "
|
|
"but tends to wind itself up during quiet moments and amplify noise "
|
|
"until it breaks squelch. A fixed value is usually better for "
|
|
"scanning: start around 30 and lower it if strong stations sound "
|
|
"distorted, raise it if everything is too weak to hear.",
|
|
"ppm":
|
|
"Cheap receivers are tuned by a crystal that is never exactly "
|
|
"right, so every frequency reads slightly off, possibly by a few "
|
|
"kilohertz at UHF. If stations consistently appear a little above "
|
|
"or below where they should be, this corrects it. Leave at 0 unless "
|
|
"you have measured the error.",
|
|
"agc":
|
|
"A second, separate automatic gain control inside the receiver "
|
|
"chip, on top of the tuner gain. It is usually best left off, since "
|
|
"two automatic gain controls fighting each other makes the squelch "
|
|
"behave unpredictably.",
|
|
"bias_tee":
|
|
"Sends power up the antenna cable, which is how mast-mounted "
|
|
"amplifiers and some active antennas are fed. Leave it off unless "
|
|
"you have such a device: switching it on with ordinary equipment "
|
|
"attached can damage it.",
|
|
"offset_tuning":
|
|
"A trick for one older tuner chip, the E4000, that moves an "
|
|
"artefact away from the middle of the picture. Modern dongles, "
|
|
"including every R820T, ignore it. Leave it off unless you know you "
|
|
"have that tuner.",
|
|
"direct_sampling":
|
|
"How the receiver hears shortwave. These dongles cannot normally "
|
|
"tune below about 24 MHz, but they can be persuaded to sample the "
|
|
"antenna directly, which opens up everything beneath it: shortwave "
|
|
"broadcast, amateur HF, marine. Automatic switches it on when you "
|
|
"scan below 24 MHz and off again above. The Q branch is what almost "
|
|
"all hardware uses.",
|
|
"usable_fraction":
|
|
"The edges of what the receiver takes in are distorted by its own "
|
|
"filtering, so only the middle portion is trusted. Lowering this "
|
|
"uses less of each step and sweeps more slowly but avoids the poor "
|
|
"edges; raising it covers ground faster at the cost of missing or "
|
|
"mismeasuring signals near the edges.",
|
|
"dc_guard_hz":
|
|
"Every receiver of this type produces a spurious spike at the exact "
|
|
"centre of where it is tuned, which would otherwise look like a "
|
|
"station. The receiver is therefore deliberately parked slightly to "
|
|
"one side. There is rarely a reason to change this.",
|
|
"output_dir":
|
|
"Where recordings, transcripts and logs are written. Everything the "
|
|
"program produces goes here, so put it somewhere with room: audio "
|
|
"adds up quickly, and raw IQ adds up very quickly.",
|
|
"save_audio":
|
|
"Write the listenable audio file for each transmission. This is "
|
|
"almost certainly what you want; turning it off leaves only the log "
|
|
"and whatever other outputs are enabled, which is useful when you "
|
|
"only care about what was active and when.",
|
|
"save_iq":
|
|
"Also keep the raw radio samples, exactly as they came off the "
|
|
"receiver, alongside the audio. These can be re-analysed or decoded "
|
|
"later with other software, but they are enormous, many megabytes "
|
|
"per second, so leave this off unless you have a specific use for "
|
|
"them.",
|
|
"iq_format":
|
|
"The number format for those raw files. cf32 is the easiest for "
|
|
"other programs to read; cs16 is half the size for the same "
|
|
"samples. Only matters if raw IQ is being saved.",
|
|
"audio_rate":
|
|
"The sample rate of the saved audio. Higher preserves more of the "
|
|
"original sound at the cost of file size; the default is well "
|
|
"matched to what a radio channel can actually carry, and to what "
|
|
"speech recognisers expect.",
|
|
"classify":
|
|
"Work out what each recording actually is, whether FM voice, AM, "
|
|
"single sideband, Morse, a paging system or a digital voice mode, "
|
|
"and write it into the log and the filename. Turning it off saves a "
|
|
"little processing and leaves you to identify things by ear.",
|
|
"decode_images":
|
|
"Three of the things a receiver can hear are pictures: the weather "
|
|
"satellites on 137 MHz, amateur slow-scan television, and the "
|
|
"shortwave weather fax stations. All three are images sent as sound, "
|
|
"so they arrive in the same recordings everything else does. Each is "
|
|
"recognised by its own header rather than guessed at, so this costs "
|
|
"a moment per recording and finds nothing where there is nothing. "
|
|
"What it does find is written as a PNG beside the audio.",
|
|
"decode_morse":
|
|
"Turn keyed carriers into readable text, with the sending speed. "
|
|
"Morse is still in daily use by amateurs and by beacons, and this "
|
|
"saves you learning to read it by ear. It costs almost nothing when "
|
|
"there is no Morse about. Every capture is tried once it has "
|
|
"finished, whatever the modulation was called, because most of the "
|
|
"Morse on the air is a repeater or a beacon giving its callsign in a "
|
|
"burst of a second or two; a callsign read out of one goes to the "
|
|
"same lookup and the same map as a spoken one.",
|
|
"log_file":
|
|
"The name of the run log inside the output directory. It records "
|
|
"every recording with its time, frequency, duration and "
|
|
"identification, as JSON lines with a spreadsheet-friendly .csv "
|
|
"alongside.",
|
|
"combine_by_frequency":
|
|
"Instead of one file per transmission, keep one growing file per "
|
|
"frequency, with each new transmission appended to it. This turns a "
|
|
"scanner run into something you can play like a recording of that "
|
|
"channel, rather than hundreds of fragments to click through.",
|
|
"announce_timestamps":
|
|
"Speaks the date and time before each transmission in a combined "
|
|
"file, so you can hear when something happened without watching a "
|
|
"clock or reading filenames. It works with no extra software "
|
|
"installed; installing espeak-ng makes the voice clearer.",
|
|
"announce_frequency":
|
|
"Also read out the frequency with the time. Useful when several "
|
|
"nearby frequencies end up in the same combined file, or when you "
|
|
"want a spoken record of what you were listening to.",
|
|
"announce_engine":
|
|
"Which voice does the speaking. Automatic uses the best available: "
|
|
"espeak-ng if it is installed, otherwise a small built-in "
|
|
"synthesiser that needs nothing at all. Set it to builtin to force "
|
|
"the internal voice, or name another program.",
|
|
"combine_tolerance_hz":
|
|
"How far apart two receptions may be and still be treated as the "
|
|
"same frequency for combining. Transmitters drift and the "
|
|
"measurement is never exact, so a little tolerance keeps one "
|
|
"repeater in one file. Widen it if a single channel is being split "
|
|
"across several files.",
|
|
"combine_keep_individual":
|
|
"Keep the per-transmission files as well as the combined one. It "
|
|
"costs twice the disk space, but means you can still pick out a "
|
|
"single transmission without seeking through a long file.",
|
|
"transcribe":
|
|
"Write out what was said in each voice transmission as a text file "
|
|
"beside the recording, so a scan can be read rather than listened "
|
|
"to, and searched with ordinary text tools. It needs a speech "
|
|
"recogniser installed; the program says so plainly if none is "
|
|
"present.",
|
|
"transcribe_engine":
|
|
"Which speech recogniser to use when more than one is installed. "
|
|
"Automatic picks the best available. Radio audio is hard for these "
|
|
"programs, and the whisper-based ones are noticeably better at it "
|
|
"than the smaller alternatives.",
|
|
"transcribe_model":
|
|
"How large a recognition model to load. Bigger models are more "
|
|
"accurate and slower, and take more memory: tiny.en and base.en "
|
|
"keep up comfortably on an ordinary machine, small.en and medium.en "
|
|
"are better but heavier.",
|
|
"transcribe_language":
|
|
"The language to expect. Setting it is worth the trouble: on a "
|
|
"short, noisy clip automatic detection often guesses wrong and "
|
|
"returns confident nonsense in a language nobody was speaking. "
|
|
"Leave it blank only if you genuinely do not know.",
|
|
"transcribe_min_seconds":
|
|
"Do not bother transcribing captures shorter than this. Very short "
|
|
"clips rarely contain a whole word and mostly produce noise or "
|
|
"nothing, while still costing the processing.",
|
|
"decode_data":
|
|
"Read what a data signal actually says. A great deal of what a "
|
|
"scanner finds is not speech: doorbells, tyre-pressure sensors, "
|
|
"weather stations, remote controls, paging, packet radio. Each one "
|
|
"is sliced into its pulses, the line code worked out from the "
|
|
"pulse lengths alone, and the bits reported -- with the packet "
|
|
"named where its framing says what it is, and the message printed "
|
|
"in full where the protocol carries one. The check that keeps it "
|
|
"honest is repetition: these transmitters send the same packet "
|
|
"several times over, and bits that come back identical every time "
|
|
"did not come from noise. Turn it off to save a little processing "
|
|
"on a busy band.",
|
|
"callsign_lookup":
|
|
"When someone gives their callsign, look it up and say who they "
|
|
"are. The data is the FCC's own published licence register, which "
|
|
"carries the licensee's name, the town they are licensed in and "
|
|
"the coordinates that put them on the map. Only the callsign is "
|
|
"sent, and each one is asked about once and then remembered, so "
|
|
"this costs almost nothing. Turn it off if you would rather the "
|
|
"scan reached the network for nothing at all -- callsigns are "
|
|
"still picked out of the transcripts, and the prefix still says "
|
|
"which country and which US district they belong to.",
|
|
"kml_file":
|
|
"The name of a map file, kept in the output directory, of "
|
|
"everyone who identified themselves. Each station is one pin: the "
|
|
"callsign, who holds the licence, where they are licensed, and "
|
|
"every frequency and time you heard them. It is added to as the "
|
|
"scan runs and again by later scans, so it builds up into a "
|
|
"picture of what you can hear from where you are. KML is the "
|
|
"format Google Earth uses; QGIS, Marble and OsmAnd read it too. "
|
|
"Leave it blank if you do not want the map.",
|
|
"max_cycles":
|
|
"Stop after this many complete passes through all the frequencies. "
|
|
"Useful for a quick survey of what is active, or for a scripted run "
|
|
"that must finish. 0 means keep going until you stop it.",
|
|
"max_runtime_seconds":
|
|
"Stop after this long, whatever the scan is doing. Handy for an "
|
|
"unattended run, or for keeping a test short. 0 means no limit.",
|
|
"lockout":
|
|
"Frequencies the scan must never stop on. Every receiving setup has "
|
|
"a few: a pager transmitter down the road, a nearby data link, or a "
|
|
"spurious signal the receiver manufactures itself. Give several "
|
|
"separated by commas, and a whole stretch of spectrum as a pair, "
|
|
"such as 162.55M, 450M-455M.",
|
|
"lockout_width":
|
|
"How wide a single locked-out frequency is. A transmitter is never "
|
|
"exactly on its nominal frequency, so the block covers a little "
|
|
"either side. A lock-out written as a span ignores this and uses "
|
|
"exactly the width you gave.",
|
|
"quiet":
|
|
"Print only errors. For running from a script or a scheduled job "
|
|
"where the usual progress reporting would just fill a log file.",
|
|
"save_lockouts":
|
|
"When you lock a frequency out during a scan, remember it for later "
|
|
"runs instead of only the current one. Only the lock-out list is "
|
|
"written back, so options you passed for one run stay one-off. Turn "
|
|
"it off if you would rather the program never modified your "
|
|
"settings file.",
|
|
"plain":
|
|
"Print one line per recording instead of the live updating display. "
|
|
"The live display needs a real terminal; plain output is what you "
|
|
"want over a remote connection, when saving output to a file, or "
|
|
"when feeding another program. It is chosen automatically when "
|
|
"output is not going to a terminal.",
|
|
}
|
|
|
|
SETTINGS: tuple[Setting, ...] = tuple(
|
|
replace(s, guidance=" ".join(_GUIDANCE.get(s.key, "").split()))
|
|
for s in _TABLE)
|
|
|
|
GROUPS: tuple[str, ...] = tuple(dict.fromkeys(s.group for s in SETTINGS))
|
|
|
|
_BY_KEY = {s.key: s for s in SETTINGS}
|
|
|
|
|
|
def by_key(key: str) -> Setting | None:
|
|
return _BY_KEY.get(key)
|
|
|
|
|
|
def in_group(group: str) -> list[Setting]:
|
|
return [s for s in SETTINGS if s.group == group]
|
|
|
|
|
|
def search(term: str) -> list[Setting]:
|
|
"""Find settings by name, flag, or any words from their description.
|
|
|
|
Every word has to appear somewhere, so "voice score" finds the speech
|
|
threshold even though those two words are never adjacent in its text.
|
|
"""
|
|
tokens = [t for t in term.strip().lower().split() if t]
|
|
if not tokens:
|
|
return []
|
|
scored = []
|
|
for s in SETTINGS:
|
|
hay = " ".join((s.key, s.key.replace("_", " "), s.label, s.help,
|
|
s.detail, s.group, " ".join(s.flags),
|
|
" ".join(s.off_flags))).lower()
|
|
if not all(t in hay for t in tokens):
|
|
continue
|
|
# Rank a match on the name above one buried in the explanation.
|
|
name = f"{s.key} {s.key.replace('_', ' ')} {s.label}".lower()
|
|
rank = 0 if all(t in name for t in tokens) else 1
|
|
scored.append((rank, s))
|
|
scored.sort(key=lambda item: (item[0], item[1].key))
|
|
return [s for _, s in scored]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Values
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TRUE = {"y", "yes", "on", "true", "1", "t"}
|
|
_FALSE = {"n", "no", "off", "false", "0", "f"}
|
|
|
|
# Suffixes accepted on a frequency. Deliberately not the band-plan parser,
|
|
# which reads a bare small number as MHz -- here a bare number is always Hz,
|
|
# so a 3000 Hz resolution does not silently become 3 GHz.
|
|
_FREQ_MULT = {"": 1.0, "hz": 1.0, "k": 1e3, "khz": 1e3,
|
|
"m": 1e6, "mhz": 1e6, "g": 1e9, "ghz": 1e9}
|
|
_UNIT_WORDS = {"s": {"s", "sec", "secs", "second", "seconds"},
|
|
"dB": {"db"}, "ppm": {"ppm"}, "Hz": set(_FREQ_MULT)}
|
|
# What the display prints for an empty or unlimited value, accepted back so
|
|
# that whatever the menu shows can be typed straight back in.
|
|
_UNLIMITED = {"no limit", "unlimited", "none", "off", "-"}
|
|
_NUMBER = re.compile(r"^([+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+))\s*([a-zA-Z]*)$")
|
|
|
|
|
|
def _numeric(setting: "Setting", raw: str) -> float:
|
|
"""Read a number, tolerating the unit the display puts after it."""
|
|
text = raw.strip().replace(",", "").replace("_", "")
|
|
if text.lower() in _UNLIMITED and (setting.minimum in (None, 0)
|
|
or setting.minimum <= 0):
|
|
return 0.0
|
|
m = _NUMBER.match(text)
|
|
if not m:
|
|
raise SettingError(f"{raw.strip()!r} is not a number")
|
|
value = float(m.group(1))
|
|
suffix = m.group(2).lower()
|
|
if setting.unit == "Hz":
|
|
if suffix not in _FREQ_MULT:
|
|
raise SettingError(f"unknown frequency unit {m.group(2)!r}")
|
|
return value * _FREQ_MULT[suffix]
|
|
if suffix and suffix not in _UNIT_WORDS.get(setting.unit, set()):
|
|
expected = setting.unit or "no unit"
|
|
raise SettingError(f"unexpected unit {m.group(2)!r}; expected {expected}")
|
|
return value
|
|
|
|
|
|
def parse_value(setting: Setting, text):
|
|
"""Turn what the user typed into a value, or explain why it will not do."""
|
|
if not isinstance(text, str):
|
|
text = str(text)
|
|
raw = text.strip()
|
|
kind = setting.kind
|
|
|
|
try:
|
|
if kind == "bool":
|
|
low = raw.lower()
|
|
if low in _TRUE:
|
|
return True
|
|
if low in _FALSE:
|
|
return False
|
|
raise SettingError("answer yes or no")
|
|
|
|
if kind == "int":
|
|
value = int(round(_numeric(setting, raw)))
|
|
elif kind == "float":
|
|
value = _numeric(setting, raw)
|
|
elif kind == "opt_float":
|
|
if raw == "" or raw.lower() in ("auto", "automatic", "none", "-"):
|
|
return None
|
|
value = _numeric(setting, raw)
|
|
elif kind == "choice":
|
|
low = raw.lower()
|
|
if low not in setting.choices:
|
|
raise SettingError("choose " + ", ".join(setting.choices))
|
|
return low
|
|
elif kind in ("text", "path"):
|
|
if not raw:
|
|
raise SettingError("cannot be empty")
|
|
return raw
|
|
elif kind == "gain":
|
|
if raw.lower() in ("auto", "agc", ""):
|
|
return "auto"
|
|
# Through _numeric, so "28.5 dB" is accepted as readily as "28.5" --
|
|
# the menu displays the value with its unit, and what it displays
|
|
# has to be something the user can type straight back.
|
|
value = _numeric(setting, raw)
|
|
if value < 0:
|
|
raise SettingError("gain cannot be negative")
|
|
return value
|
|
elif kind == "direct":
|
|
low = raw.lower()
|
|
if low == "auto":
|
|
return "auto"
|
|
if low not in ("0", "1", "2"):
|
|
raise SettingError("choose auto, 0, 1 or 2")
|
|
return int(low)
|
|
elif kind == "accept_list":
|
|
items = [p.strip().lower() for p in raw.replace(" ", ",").split(",")
|
|
if p.strip()]
|
|
if not items:
|
|
raise SettingError("name at least one kind of content")
|
|
bad = [i for i in items if i not in setting.choices]
|
|
if bad:
|
|
raise SettingError(
|
|
f"unknown: {', '.join(bad)} (choose from "
|
|
f"{', '.join(setting.choices)})")
|
|
return list(dict.fromkeys(items))
|
|
elif kind == "lockout_list":
|
|
from .ranges import RangeError, parse_lockout_list
|
|
if not raw or raw.lower() in ("(none)", "none", "-"):
|
|
return []
|
|
try:
|
|
return parse_lockout_list(raw)
|
|
except RangeError as exc:
|
|
raise SettingError(str(exc)) from None
|
|
else:
|
|
raise SettingError(f"unsupported setting kind {kind!r}")
|
|
except SettingError:
|
|
raise
|
|
except ValueError:
|
|
raise SettingError(f"{raw!r} is not a number") from None
|
|
|
|
if setting.minimum is not None and value < setting.minimum:
|
|
raise SettingError(f"must be at least {setting.minimum:g}"
|
|
+ (f" {setting.unit}" if setting.unit else ""))
|
|
if setting.maximum is not None and value > setting.maximum:
|
|
raise SettingError(f"must be at most {setting.maximum:g}"
|
|
+ (f" {setting.unit}" if setting.unit else ""))
|
|
return value
|
|
|
|
|
|
def format_value(setting: Setting, value) -> str:
|
|
"""Render a value the way the user would type it."""
|
|
kind = setting.kind
|
|
if kind == "bool":
|
|
return "yes" if value else "no"
|
|
if kind == "opt_float":
|
|
return "automatic" if value is None else f"{value:g} {setting.unit}".strip()
|
|
if kind == "accept_list":
|
|
return ", ".join(value) if value else "(nothing)"
|
|
if kind == "lockout_list":
|
|
return ", ".join(lk.describe() for lk in value) if value else "(none)"
|
|
if kind == "gain":
|
|
return "auto" if isinstance(value, str) else f"{float(value):g} dB"
|
|
if kind == "direct":
|
|
return "auto" if value == "auto" else str(value)
|
|
if kind in ("text", "path", "choice"):
|
|
return str(value)
|
|
if kind == "int" and setting.unit == "Hz":
|
|
return fmt_hz(value)
|
|
if kind == "float" and setting.unit == "Hz":
|
|
return fmt_hz(value)
|
|
if kind in ("int", "float"):
|
|
if setting.key in ("record_seconds", "max_record_seconds",
|
|
"max_runtime_seconds", "max_cycles") and not value:
|
|
return "no limit"
|
|
return f"{value:g} {setting.unit}".strip()
|
|
return str(value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command line
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ARG_GROUP_TITLES = {
|
|
"Combining": "combining recordings by frequency",
|
|
"Transcription": "speech to text",
|
|
"Dwell and recording": "dwell behaviour",
|
|
"Detection": "detection",
|
|
"What counts as a signal": "what counts as a signal",
|
|
"Trunking": "trunked systems",
|
|
"Receiver": "receiver",
|
|
"Output": "output",
|
|
"Run control": "run control",
|
|
}
|
|
|
|
|
|
def add_arguments(parser) -> None:
|
|
"""Add every registry setting to an argparse parser, grouped."""
|
|
for group in GROUPS:
|
|
section = parser.add_argument_group(_ARG_GROUP_TITLES.get(group, group))
|
|
for s in in_group(group):
|
|
if not s.flags and not s.off_flags:
|
|
continue
|
|
unit = f" ({s.unit})" if s.unit else ""
|
|
if s.kind == "bool":
|
|
if s.flags:
|
|
section.add_argument(*s.flags, dest=s.dest,
|
|
action="store_true", default=None,
|
|
help=s.help)
|
|
if s.off_flags:
|
|
section.add_argument(*s.off_flags, dest=s.dest,
|
|
action="store_false", default=None,
|
|
help=f"do not {s.help}")
|
|
elif s.kind == "lockout_list":
|
|
section.add_argument(*s.flags, dest=s.dest, action="append",
|
|
default=None,
|
|
metavar=s.metavar or "VALUE",
|
|
help=s.help + "; repeatable")
|
|
else:
|
|
section.add_argument(*s.flags, dest=s.dest, default=None,
|
|
metavar=s.metavar or "VALUE",
|
|
help=s.help + unit)
|
|
|
|
|
|
def apply_args(cfg, args) -> list[str]:
|
|
"""Apply command-line values onto a config. Returns the keys that changed."""
|
|
changed = []
|
|
for s in SETTINGS:
|
|
if not s.flags and not s.off_flags:
|
|
continue
|
|
raw = getattr(args, s.dest, None)
|
|
if raw is None:
|
|
continue
|
|
if s.kind == "bool":
|
|
value = bool(raw)
|
|
elif s.kind == "lockout_list":
|
|
value = []
|
|
for item in (raw if isinstance(raw, list) else [raw]):
|
|
value.extend(parse_value(s, item))
|
|
else:
|
|
value = parse_value(s, raw)
|
|
setattr(cfg, s.key, value)
|
|
changed.append(s.key)
|
|
return changed
|
|
|
|
|
|
def coverage() -> tuple[list[str], list[str]]:
|
|
"""Config fields with no setting, and settings with no config field.
|
|
|
|
Used by the test suite to keep the table and the config from drifting.
|
|
"""
|
|
from .config import ScanConfig
|
|
exempt = {"ranges"}
|
|
config_keys = {f.name for f in fields(ScanConfig)} - exempt
|
|
setting_keys = {s.key for s in SETTINGS}
|
|
return sorted(config_keys - setting_keys), sorted(setting_keys - config_keys)
|