Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out what kind of signal it was. - Frequency ranges entered by hand or picked from a 135-entry US band plan, including whole-band and all-CW sweeps that resolve the demodulator per segment. - Detection calibrated against the peak-hold detector's own noise statistics, so the threshold means real margin over static rather than over the floor. - A content gate: captures are kept only if they carry voice, decodable CW, or an identified digital keying scheme. Speech is recognised by a pitch track that drifts, which static cannot imitate. - Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR, NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK. - Gapless streaming capture, with the signal path fast enough to keep up in real time, so recordings play back at the right speed. - Optional one-file-per-frequency recording with spoken timestamps, and speech-to-text transcription. - Menus and command line generated from one settings table, so neither can offer something the other cannot; settings persist in ~/.config. 367 tests, run against synthetic signals, a built-in receiver simulator, and real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
db3e0c79b9
39 changed files with 13473 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
recordings/
|
||||
*.wav
|
||||
*.cf32
|
||||
*.cs16
|
||||
*.sigmf-meta
|
||||
|
||||
# transcripts land beside recordings
|
||||
*_transcription.txt
|
||||
|
||||
# local environments
|
||||
.venv/
|
||||
venv/
|
||||
723
README.md
Normal file
723
README.md
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
# bandsaunter
|
||||
|
||||
A signal scanner, recorder and identifier for RTL-SDR receivers.
|
||||
|
||||
Give it any number of frequency ranges — typed in by hand or picked from a
|
||||
built-in US band plan — and it sweeps them, stops on anything above the noise
|
||||
floor, records it, and works out what kind of signal it was. CW/Morse is
|
||||
decoded to text.
|
||||
|
||||
```
|
||||
╭──────────────────────────────── receiver ────────────────────────────────╮
|
||||
│ Rafael Micro R820T/R820T2 2.048 MS/s gain auto +0 ppm │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭───────────────────────────────── sweep ──────────────────────────────────╮
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ step 4/6 146 MHz - 146.666667 MHz │
|
||||
│ ▆ █ ▄▄▄ peak -18.0 dBFS │
|
||||
│ recording cycle 2 hits 3 dropped 0 detections 7 up 0:04 │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ REC 146.52 MHz [nfm] ███████████░░░░░░░░░ 11.4/30s SIGNAL SNR 27 dB │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭───────────────────────────── recorded signals ───────────────────────────╮
|
||||
│ 19:38:43 460.025 MHz 8.2s 27.0 P25 Phase 1 C4FM digital voice │
|
||||
│ 19:38:43 144.1 MHz 8.2s 27.0 CW / Morse at 18 WPM "VVV DE…" │
|
||||
│ 19:38:43 146.52 MHz 8.2s 27.0 Narrowband FM voice (CTCSS 100) │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
sudo apt install rtl-sdr librtlsdr0 espeak-ng # Debian/Ubuntu
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
| | Package | Needed for |
|
||||
|---|---|---|
|
||||
| **required** | `librtlsdr0` (`rtl-sdr`) | talking to the dongle at all |
|
||||
| **required** | `numpy`, `scipy` | all signal processing |
|
||||
| **required** | `rich` | the menus and the live display |
|
||||
| **required** | `PyYAML` | the settings file and profiles |
|
||||
| *recommended* | `espeak-ng` | clearer spoken timestamps |
|
||||
| *optional* | `faster-whisper` (pip) | transcribing speech to text |
|
||||
|
||||
`espeak-ng` is a **recommendation, not a requirement**: without it the spoken
|
||||
timestamps are produced by a built-in formant synthesiser, so the feature
|
||||
works on a machine with nothing else installed. With it they are clearer and
|
||||
render about three times faster. Any of `espeak`, `pico2wave`, `flite` or
|
||||
macOS `say` is used if found instead.
|
||||
|
||||
For a Debian package that maps to `Depends: librtlsdr0, python3-numpy,
|
||||
python3-scipy, python3-rich, python3-yaml`, `Recommends: espeak-ng,
|
||||
rtl-sdr` and `Suggests: python3-vosk` — transcription is off by default and
|
||||
the scan reports it plainly when no recogniser is installed. `rtl-sdr` itself only supplies command-line tools bandsaunter does not
|
||||
call — the library is what matters.
|
||||
|
||||
`bandsaunter scan --combine --dry-run` reports which engine will be used.
|
||||
|
||||
If the dongle is not accessible, the DVB-T kernel driver has usually claimed
|
||||
it:
|
||||
|
||||
```bash
|
||||
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo rmmod dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
Check with `bandsaunter devices --test`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
bandsaunter # the menus: set up and scan
|
||||
bandsaunter scan -b 2m -b marine-vhf # band-plan presets
|
||||
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
|
||||
bandsaunter scan -b 2m --simulate # try it without hardware
|
||||
```
|
||||
|
||||
## Two ways to drive it
|
||||
|
||||
Everything is available both ways. Run `bandsaunter` with no arguments for the
|
||||
menus, or pass flags for scripting — the two are generated from the same
|
||||
table of settings, so neither can offer something the other cannot.
|
||||
|
||||
```
|
||||
1 Frequency ranges 3 configured
|
||||
2 Band plan 107 US presets
|
||||
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
|
||||
4 Saved settings and profiles
|
||||
h Help
|
||||
s Start scanning
|
||||
q Quit
|
||||
```
|
||||
|
||||
Settings are grouped, show their current value against the built-in default,
|
||||
and carry their own help:
|
||||
|
||||
```
|
||||
# setting value what it does
|
||||
1 Record for * no limit longest one signal may hold the receiver
|
||||
2 Wait for quiet * 6 s quiet time before the sweep resumes
|
||||
3 Absolute limit 900 s ceiling on one capture, even when 'Record for' is 0
|
||||
* differs from the built-in default
|
||||
|
||||
Number to change it, ?N for help on one, d to reset the group, blank to go back.
|
||||
```
|
||||
|
||||
`?2` explains a setting in full, including the command-line flag that does the
|
||||
same thing. Typing a search term instead of a number finds settings by any
|
||||
word in their name or description — `voice score` finds the speech threshold.
|
||||
|
||||
Values may be typed with their units: `5 s`, `2.048 MHz`, `12 dB`, `48k`, or
|
||||
`no limit` for the settings that accept 0.
|
||||
|
||||
## Settings that persist
|
||||
|
||||
Settings are saved to `~/.config/bandsaunter/config.yaml` and picked up by every
|
||||
later run. Save them from the menus (**4 → s**) or from the command line:
|
||||
|
||||
```bash
|
||||
bandsaunter config # open the settings menu
|
||||
bandsaunter config hang_seconds=6 record_seconds=0 # set and save directly
|
||||
bandsaunter config --show # every setting, with defaults
|
||||
bandsaunter config --describe hang_seconds # explain one in full
|
||||
bandsaunter config --path # where the file lives
|
||||
bandsaunter config --reset # back to defaults
|
||||
```
|
||||
|
||||
Three layers apply in order, each overriding the last:
|
||||
|
||||
1. the saved settings file
|
||||
2. a named profile, if `--profile` is given
|
||||
3. any flags on the command line
|
||||
|
||||
So a saved squelch of 12 dB stays in force while `--hang 1.5` overrides just
|
||||
the hang for one run. `--no-config` ignores the file entirely; `--save` stores
|
||||
the resulting settings as the new default.
|
||||
|
||||
Named profiles live beside it in the same directory:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m -b 70cm --record 0 --hang 6 --save-profile local
|
||||
bandsaunter scan -p local
|
||||
bandsaunter profiles
|
||||
```
|
||||
|
||||
## Entering frequencies
|
||||
|
||||
**By hand** — repeat `-r` as many times as you like; there is no limit on the
|
||||
number of start/end pairs.
|
||||
|
||||
```bash
|
||||
bandsaunter scan -r 144M-148M -r 462.5M-467.8M -r 929M-932M
|
||||
```
|
||||
|
||||
A range is `start-end`, with optional `/step` and `@mode`:
|
||||
|
||||
| Form | Meaning |
|
||||
|---|---|
|
||||
| `144M-148M` | explicit start and end |
|
||||
| `144-148M` | the unit carries over to the left end |
|
||||
| `146.52M` | a single frequency |
|
||||
| `144M-148M/25k` | with a channel step |
|
||||
| `144M-148M/25k@nfm` | and a forced demodulator |
|
||||
|
||||
Units may be written `144M`, `144 MHz`, `144000k`, or plain Hz. A bare number
|
||||
below 10000 is read as MHz, so `-r 162.4-162.55` does what you expect.
|
||||
|
||||
**From the US band plan** — 107 presets across 18 categories:
|
||||
|
||||
```bash
|
||||
bandsaunter bands --categories # list categories
|
||||
bandsaunter bands --category Aviation # everything in one category
|
||||
bandsaunter bands pager # search
|
||||
bandsaunter scan -b gmrs -b railroad -b noaa-weather
|
||||
```
|
||||
|
||||
Each preset carries its own channel spacing, demodulator and bandwidth, so
|
||||
`-b marine-vhf` scans 25 kHz channels in NFM while `-b fm-broadcast` uses
|
||||
200 kHz WFM, without being told.
|
||||
|
||||
Each amateur band also has a **complete** entry that covers the whole band and
|
||||
picks the demodulator per segment, because a band is not one mode:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m-complete
|
||||
```
|
||||
|
||||
scans 144-148 MHz as CW below 144.1, SSB to 144.3 and FM above it, so a CW
|
||||
beacon at the bottom is decoded to text while a repeater at the top is
|
||||
demodulated as FM — in one sweep. There is one for every band from 160 m to
|
||||
33 cm: `160m-complete`, `80m-complete`, ... `70cm-complete`, `33cm-complete`.
|
||||
|
||||
Where an amateur band overlaps another service the amateur reading wins inside
|
||||
a complete-band sweep — 433 MHz is treated as 70 cm rather than as the ISM
|
||||
band it shares — while scanning `-r 433.9M` on its own still treats it as ISM.
|
||||
|
||||
A few presets stand for a *set* of others, so scattered segments can be picked
|
||||
in one go:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b all-cw --record 0 --hang 6
|
||||
```
|
||||
|
||||
`all-cw` covers every CW allocation in the plan — 160, 80, 40, 30, 20, 17, 15,
|
||||
12, 10, 6 and 2 metres — as eleven separate ranges rather than one span from
|
||||
1.8 to 144 MHz. That is 1.25 MHz of spectrum in total, so a full pass takes
|
||||
under a second and CW gets decoded to text as it turns up. Direct sampling
|
||||
switches itself on for the HF segments and off again from 12 m upward; the HF
|
||||
part needs an HF antenna to be worth anything.
|
||||
|
||||
The menus do both: browse the band plan by category, or type in start/end
|
||||
pairs one after another. Ranges can be listed, removed, toggled on and off,
|
||||
and have their demodulator changed from the ranges menu.
|
||||
|
||||
## The two dwell settings
|
||||
|
||||
These are the settings that decide how the scanner behaves when it finds
|
||||
something:
|
||||
|
||||
| Setting | Flag | What it does |
|
||||
|---|---|---|
|
||||
| Record for X seconds before continuing | `--record 30` | The longest a single signal may hold the receiver. `0` means stay as long as it keeps transmitting. |
|
||||
| Wait for X seconds of no signal before continuing | `--hang 3` | How long the channel must stay quiet before the sweep resumes. **Gaps shorter than this are recorded straight through.** |
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m --record 30 --hang 3
|
||||
```
|
||||
|
||||
Whichever comes first wins, and that is worth being clear about: **`--record`
|
||||
overrides `--hang`.** A transmission still in progress at the record limit is
|
||||
cut off there, however long the hang time is. If a recording keeps ending at
|
||||
exactly 30 seconds, that is the default record limit doing it, not the hang —
|
||||
set `--record 0`. The scan reports it when this happens.
|
||||
|
||||
Both are measured in **samples**, not wall-clock time, so a 30 second setting
|
||||
produces a 30.0 second recording.
|
||||
|
||||
Supporting settings:
|
||||
|
||||
- `--min-record 0.5` — discard anything shorter, so brief noise spikes leave
|
||||
nothing behind on disk.
|
||||
- `--revisit 8` — ignore a frequency for this long after recording it, so a
|
||||
busy repeater does not monopolise the sweep.
|
||||
- `--max-record 900` — absolute ceiling on one capture, applied even when
|
||||
`--record` is 0.
|
||||
- `--threshold 8` — squelch, in dB above the measured noise floor.
|
||||
|
||||
### Capturing both sides of a conversation
|
||||
|
||||
`--hang` is what holds a recording open across the natural pauses in two-way
|
||||
traffic. Set it longer than the gap between overs and the whole exchange lands
|
||||
in one file:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m --record 0 --hang 6
|
||||
```
|
||||
|
||||
`--record 0` is the important half. Without it the per-signal cap cuts the
|
||||
exchange off mid-sentence no matter what the hang time is — the default 30
|
||||
seconds is a common surprise. `--max-record` (default 900 s) still bounds an
|
||||
unlimited capture so nothing runs away.
|
||||
|
||||
To make it the permanent default:
|
||||
|
||||
```bash
|
||||
bandsaunter config record_seconds=0 hang_seconds=6
|
||||
```
|
||||
|
||||
"Quiet" means *no real signal*, not merely a closed squelch. Silence, static
|
||||
and interference all count towards the timer, so a burst of noise during a
|
||||
pause does not reset it and park the receiver on a finished conversation.
|
||||
Recognising that a signal carries nothing takes a couple of seconds of
|
||||
evidence, so expect the tail to run a little past `--hang` in that case.
|
||||
|
||||
Once a capture has produced real content it is never abandoned as noise, since
|
||||
a quiet spell between overs would otherwise throw the conversation away.
|
||||
|
||||
## Only real signals get recorded
|
||||
|
||||
A power threshold cannot tell a transmission from a hump of interference, so
|
||||
every capture is checked for *content* before it is kept. Recording happens
|
||||
only for:
|
||||
|
||||
| Category | What it means |
|
||||
|---|---|
|
||||
| `voice` | speech structure in the demodulated audio: a pitch track in the 70-400 Hz range that drifts the way intonation does, pauses between phrases, syllable-rate envelope modulation, and formants that move |
|
||||
| `cw` | a keyed carrier whose timing resolves as Morse |
|
||||
| `digital` | an identified keying scheme: discrete FSK levels, an M-PSK phase line, or on-off keying -- corroborated by a symbol rate |
|
||||
|
||||
Everything else -- static, hum, switch-mode power supply harmonics, clock
|
||||
spurs, bare carriers -- is discarded, and the files it wrote are deleted.
|
||||
|
||||
The check runs *while* the capture is still going, so interference is dropped
|
||||
after a second or two instead of holding the receiver for the whole record
|
||||
time.
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m # voice, CW and digital (default)
|
||||
bandsaunter scan -b 2m --accept voice # voice only
|
||||
bandsaunter scan -b ism-433 --accept digital # data bursts only
|
||||
bandsaunter scan -b 2m --keep-carriers # also keep unmodulated carriers
|
||||
bandsaunter scan -b 2m --keep-everything # no content check at all
|
||||
```
|
||||
|
||||
Tuning knobs: `--min-voice-score` (0-1, default 0.45) sets how speech-like
|
||||
audio must be; `--min-signal-score` sets the confidence needed to keep
|
||||
anything; `--verify-max` caps how long a contentless capture is given to prove
|
||||
itself.
|
||||
|
||||
### What makes this hold up against interference
|
||||
|
||||
Static is good at imitating most of the things that look like structure, so
|
||||
each test is built so that noise cannot pass it:
|
||||
|
||||
- **A pitch track that moves gates the voice score**, rather than contributing
|
||||
a share of it. Dynamics, syllable-rate modulation and energy in the voice
|
||||
band are all things hiss does too; weighted alongside voicing they were
|
||||
enough to carry noise over the line on their own. Speech is the only thing
|
||||
here that produces a pitch period that drifts, so nothing is called voice
|
||||
without one. A steady tone or mains hum has a perfectly stable "pitch" and
|
||||
is rejected for exactly that reason. Measuring drift needs several voiced
|
||||
frames, so the requirement eases for a short over that cannot supply them;
|
||||
the steady-tone guard still applies.
|
||||
- **A symbol rate has to hold still across the capture.** The estimator always
|
||||
returns its best peak, so on noise it reports one rate for the first half of
|
||||
a capture and a different one for the second. Real data keeps one.
|
||||
- **Keying has to land on a grid.** On/off contrast alone is not evidence: a
|
||||
signal fading across the squelch produces plenty of it, with run lengths
|
||||
that fit no symbol period at all.
|
||||
- **A phase line has to be created by the exponentiation.** An unmodulated
|
||||
carrier -- including the gaps between phrases on an FM channel -- already
|
||||
has a line at every power, and would otherwise look like textbook PSK.
|
||||
- **The demodulator is chosen from the signal**, not from the band plan, by
|
||||
probing briefly before recording. An AM signal inside a
|
||||
band listed as FM would otherwise be recorded through the wrong detector,
|
||||
giving audio that is useless to listen to and impossible to judge. Three
|
||||
ratios settle it — how much the envelope varies, how far the tone swings,
|
||||
and how much power sits in a carrier — because those hold steady over a
|
||||
fraction of a second whatever is being said. Running the full classifier on
|
||||
so short a probe was tried and is not reliable: speech makes any modulation
|
||||
look bursty over half a second, and AM came back as on-off keying while FM
|
||||
came back as AM. The probe is played into the recording rather than
|
||||
discarded, so a short over does not lose its opening.
|
||||
- **Content is judged only on the audio that was actually recorded**, and only
|
||||
on the stretches the squelch called signal. Demodulating two ways and
|
||||
keeping whichever scored higher is cherry-picking, and on noise one of the
|
||||
two always flatters it.
|
||||
|
||||
The gate needs roughly two seconds of audio to judge speech reliably, so pair
|
||||
it with `--record 5` or more rather than very short capture limits.
|
||||
|
||||
### Keeping up with the radio
|
||||
|
||||
An RTL-SDR only delivers samples while the host is actively reading. Anything
|
||||
that arrives while the program is busy demodulating is discarded by the
|
||||
driver, and a recording then holds *less* than really went by -- which plays
|
||||
back too fast.
|
||||
|
||||
Two things keep that from happening:
|
||||
|
||||
- **Captures stream asynchronously.** A ring of USB transfers stays queued in
|
||||
a background thread, so the dongle is never waiting for the host. Sweeping
|
||||
still uses plain reads, because each dwell is an independent snapshot and a
|
||||
gap between them costs nothing.
|
||||
- **The signal path is fast enough to keep up.** Decimation computes only the
|
||||
samples that survive, rather than filtering at the input rate and throwing
|
||||
away seven of every eight outputs; the quarter-rate local oscillator is the
|
||||
four-step cycle 1, -j, -1, +j and needs no trigonometry; and pitch tracking
|
||||
runs through the FFT instead of a direct autocorrelation per frame. Together
|
||||
those took the capture loop from 65% of the real-time budget to under 10%.
|
||||
|
||||
If the host does fall behind anyway, the scan reports how many samples were
|
||||
lost rather than silently producing a fast recording.
|
||||
|
||||
### Why the threshold is what it is
|
||||
|
||||
The sweep uses peak-hold, which keeps the largest value each FFT bin reached
|
||||
during the dwell. That finds bursty traffic that averaging would bury -- but
|
||||
it also means noise alone rides several dB above the measured floor. On this
|
||||
hardware, empty spectrum reaches 5-9 dB above a percentile floor with nothing
|
||||
transmitting.
|
||||
|
||||
So `--threshold` is a margin over *noise*, not over the floor: the offset that
|
||||
noise alone clears is computed from the detector (segment count and bin count)
|
||||
and added automatically. A threshold of 8 means 8 dB of real headroom. It is
|
||||
deliberately not measured from the spectrum -- a spread estimated from the
|
||||
data reads five times higher across the packed broadcast FM band than on empty
|
||||
spectrum, which would suppress exactly the stations you are looking for.
|
||||
|
||||
## Signal identification
|
||||
|
||||
Every recording is classified from its own IQ. The classifier measures
|
||||
occupied bandwidth, envelope statistics, discriminator levels, phase
|
||||
behaviour, spectral flatness and symbol rate, then combines those with the
|
||||
frequency to name the signal:
|
||||
|
||||
| Family | Recognised as |
|
||||
|---|---|
|
||||
| Analogue voice | Narrowband FM (with CTCSS tone or DCS), wideband FM (stereo pilot detected), AM, SSB (USB/LSB) |
|
||||
| CW | Keyed carrier, **decoded to text** with the speed in WPM |
|
||||
| Digital voice | P25 C4FM, DMR (TDMA burst structure), NXDN, D-STAR |
|
||||
| Data | POCSAG and FLEX paging, ACARS, AIS, APRS/AFSK1200, 2-FSK and 4-FSK, BPSK/QPSK/8-PSK |
|
||||
| Other | Unmodulated carriers, OOK/ISM devices, ADS-B and UAT, DME/TACAN pulses, wideband OFDM/cellular |
|
||||
|
||||
Each result carries a confidence and the reasoning behind it:
|
||||
|
||||
```
|
||||
146.520038 MHz 3.0s SNR 27.6 dB Narrowband FM voice (CTCSS 100.0 Hz) (88%)
|
||||
4.9 kHz wide, 0.9 kHz rms deviation
|
||||
```
|
||||
|
||||
Low SNR reduces confidence rather than producing a confident wrong answer.
|
||||
|
||||
### CW / Morse
|
||||
|
||||
Keyed carriers are decoded to text. The speed is measured from the signal, so
|
||||
nothing has to be configured, and anything from about 8 to 40 WPM reads
|
||||
reliably:
|
||||
|
||||
```
|
||||
144.1 MHz 12.0s SNR 50.8 dB CW / Morse at 18 WPM CW "VVV DE W1AW FN31"
|
||||
```
|
||||
|
||||
The decoder runs its own CW detector over the captured IQ, so Morse is found
|
||||
even when the recording itself was made in FM or SSB.
|
||||
|
||||
## Output
|
||||
|
||||
Everything lands in one directory, named
|
||||
`frequency--yyyy-mm-dd_hour_minute_second-modulation.wav`:
|
||||
|
||||
```
|
||||
0014.058000MHz--2026-08-21_20_35_41-cw.wav
|
||||
0098.299255MHz--2026-08-21_20_24_01-wfm.wav demodulated audio
|
||||
0098.299255MHz--2026-08-21_20_24_01-wfm.json identification, features, timing
|
||||
0098.299255MHz--2026-08-21_20_24_01-wfm.cf32 raw IQ (with --iq)
|
||||
0098.299255MHz--2026-08-21_20_24_01-wfm.sigmf-meta SigMF sidecar (with --iq)
|
||||
0098.299255MHz--2026-08-21_20_24_01-wfm_transcription.txt (with --transcribe)
|
||||
0098.361991MHz--2026-08-21_20_24_18-wfm.wav
|
||||
0146.520000MHz--2026-08-21_20_31_02-nfm.wav
|
||||
1090.000000MHz--2026-08-21_20_38_12-raw.wav
|
||||
scan_log.jsonl one line per hit
|
||||
scan_log.csv the same, as a spreadsheet
|
||||
```
|
||||
|
||||
Frequency leads and is padded to four digits, so a plain directory listing
|
||||
sorts by frequency across the whole tuning range — unpadded, 1090 MHz would
|
||||
sort before 146 MHz. Each channel's captures group together with the
|
||||
timestamp ordering them. Every artefact of one capture shares a stem, and the
|
||||
modulation suffix is what the signal was *identified* as, so the file is
|
||||
renamed once the capture has been analysed.
|
||||
|
||||
### Where files go
|
||||
|
||||
The directory is asked for the first time bandsaunter is run and remembered
|
||||
afterwards:
|
||||
|
||||
```
|
||||
Recordings, transcripts and the scan log are all written to one directory.
|
||||
Where would you like them?
|
||||
|
||||
recordings directory (~/bandsaunter):
|
||||
```
|
||||
|
||||
It is an ordinary setting, so it can be changed at any time:
|
||||
|
||||
```bash
|
||||
bandsaunter config output_dir=~/somewhere-else
|
||||
bandsaunter scan -b 2m -o /tmp/just-this-once
|
||||
```
|
||||
|
||||
The question is only asked when there is someone to answer it: a scan run from
|
||||
a script or with output redirected uses the default rather than blocking, and
|
||||
`--no-config` skips it entirely.
|
||||
|
||||
### One file per frequency
|
||||
|
||||
With `--combine`, each frequency gets a single file that every later reception
|
||||
is appended to, so a whole watch on a channel plays back as one recording:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m --combine --record 0 --hang 6
|
||||
```
|
||||
|
||||
```
|
||||
recordings/
|
||||
146.520000MHz.wav every transmission heard on 146.520, in order
|
||||
147.100000MHz.wav
|
||||
146.520692MHz--2026-08-21_13_21_20-nfm.json what each capture was
|
||||
scan_log.csv
|
||||
```
|
||||
|
||||
Each transmission is preceded by **its date and time read aloud**, so the file
|
||||
says when everything was heard without needing the log open. Receptions within
|
||||
`--combine-tolerance` (6.25 kHz by default) of each other count as the same
|
||||
channel, which covers the few hundred hertz a detection wanders by.
|
||||
|
||||
The file is rewritten to stay valid after every append, so it can be opened
|
||||
and played while the scan is still running, and a scan stopped part-way still
|
||||
leaves a working recording. A later run continues the same file rather than
|
||||
starting a new one.
|
||||
|
||||
By default the per-transmission WAVs are removed once they have been added, so
|
||||
each recording exists in one place; `--keep-individual` keeps both. The `.json`
|
||||
describing each capture is written either way.
|
||||
|
||||
`--no-announce` leaves the timestamps out, and `--announce-frequency` reads the
|
||||
frequency out as well.
|
||||
|
||||
### Where the speech comes from
|
||||
|
||||
The announcements use an installed text-to-speech program if there is one
|
||||
(`espeak-ng`, `espeak`, `pico2wave`, `flite`, `say`) and fall back to a
|
||||
built-in formant synthesiser otherwise, so the feature works with nothing else
|
||||
installed. `--announce-engine builtin` forces the built-in one; the scan plan
|
||||
reports which is in use.
|
||||
|
||||
The two are given different wording. An installed engine gets ordinary text,
|
||||
where punctuation is what produces the phrasing — and the obvious spellings
|
||||
are traps: espeak-ng reads `14:38:05` as "fourteen thirty, eight zero five",
|
||||
and an ISO date as "two thousand and twenty six dash zero eight dash twenty
|
||||
one". It is given `August 21, twenty twenty six, at 14 38 and 05 seconds`
|
||||
instead. The built-in synthesiser gets the word list it has pronunciations
|
||||
for. Both are level-matched, so switching between them does not change how
|
||||
loud the announcements sit against the recordings.
|
||||
|
||||
It only has to say numbers, month names and a handful of words, which makes
|
||||
direct synthesis practical: a glottal source through a cascade of three
|
||||
formant resonators, with the formant tracks interpolated between phonemes.
|
||||
The test suite checks by LPC analysis that all ten vowels come out within
|
||||
130 Hz of their intended first formant and 250 Hz of their second — summing
|
||||
the resonators in parallel rather than cascading them loses the first formant
|
||||
entirely and makes every vowel sound the same.
|
||||
|
||||
### Transcribing speech to text
|
||||
|
||||
With `--transcribe`, anything the content check identified as **voice** is
|
||||
passed to a speech recogniser and the words are written beside the recording:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 2m --transcribe
|
||||
```
|
||||
|
||||
```
|
||||
146.520000MHz--2026-08-21_12_18_38-nfm.wav
|
||||
146.520000MHz--2026-08-21_12_18_38-nfm_transcription.txt
|
||||
146.520000MHz--2026-08-21_12_18_38-nfm.json
|
||||
```
|
||||
|
||||
Only voice is transcribed — running a recogniser over Morse or a data burst
|
||||
costs seconds and produces nothing. CW is decoded separately and appears in
|
||||
the metadata as text already.
|
||||
|
||||
**A capture with nothing recognisable in it produces no file.** Music, a
|
||||
carrier with an open mic, a fragment too short to make out: nothing is
|
||||
written, rather than a directory of placeholders. The transcript is also
|
||||
copied into the capture's `.json`, which names it only once it exists — so the
|
||||
metadata never points at a file that was never created. The scan reports the
|
||||
tally at the end:
|
||||
|
||||
```
|
||||
2 transcript(s) written, 1 with no recognisable speech
|
||||
```
|
||||
|
||||
Recognition takes seconds per capture, far longer than a capture itself, so it
|
||||
runs on its own thread and the scan never waits for it; anything still queued
|
||||
is finished when the scan stops. When recordings are being combined by
|
||||
frequency there is one transcript per frequency too, each line stamped with
|
||||
the time:
|
||||
|
||||
```
|
||||
[2026-08-21 12:18:38] this is what the first transmission said
|
||||
[2026-08-21 12:24:02] and this is the second
|
||||
```
|
||||
|
||||
### Getting a recogniser
|
||||
|
||||
Unlike the spoken announcements, this needs an installed engine — recognition
|
||||
depends on a trained model, so there is no built-in fallback.
|
||||
|
||||
```bash
|
||||
bandsaunter transcribe --engines # what is installed
|
||||
pip install faster-whisper # the recommended one
|
||||
```
|
||||
|
||||
| Engine | Notes |
|
||||
|---|---|
|
||||
| `faster-whisper` | best on radio audio; ~120 MB of dependencies, model downloads on first use |
|
||||
| `whisper` | the original; heavier |
|
||||
| `whisper-cli` | whisper.cpp, no Python dependencies |
|
||||
| `vosk` | ~10 MB plus a 40 MB model, fully offline, but weaker on noisy audio |
|
||||
| `pocketsphinx` | tiny; poor on radio audio |
|
||||
|
||||
The difference is easy to measure. Both engines on the same 26-second
|
||||
off-air recording:
|
||||
|
||||
```
|
||||
faster-whisper 3.4s "August 21, 2026, at 13.42 and 28 seconds,
|
||||
96.108 megahertz, 13.42 and 47 seconds, ..."
|
||||
vosk 11.2s "august twenty one twenty twenty six at thirteen
|
||||
forty two i'm twenty eight seconds ... forty
|
||||
family factories ..."
|
||||
```
|
||||
|
||||
Whisper is both more accurate and three times faster, and it punctuates.
|
||||
Vosk's advantage is size and that it needs nothing after its model is
|
||||
downloaded once.
|
||||
|
||||
`--transcribe-model` selects the size (`tiny.en`, `base.en`, `small.en`,
|
||||
`medium.en`) and `--transcribe-language` fixes the language — worth setting,
|
||||
since on a short noisy clip automatic detection often guesses wrong and
|
||||
returns nonsense in another language.
|
||||
|
||||
Existing recordings can be transcribed after the fact:
|
||||
|
||||
```bash
|
||||
bandsaunter transcribe recordings/ # every WAV in a directory
|
||||
bandsaunter transcribe one.wav --stdout
|
||||
```
|
||||
|
||||
Re-examine anything later:
|
||||
|
||||
```bash
|
||||
bandsaunter analyze recordings/2026-08-19/.../iq.cf32 # identify
|
||||
bandsaunter analyze recordings/2026-08-19/.../audio.wav # decode CW
|
||||
```
|
||||
|
||||
## Live controls
|
||||
|
||||
The display sizes itself to the terminal, giving up the spectrum row, then the
|
||||
hit list, then the key hints as space runs short. A frame taller than the
|
||||
terminal cannot be redrawn in place, so an oversized one would leave a copy of
|
||||
itself behind on every refresh.
|
||||
|
||||
For the same reason the driver's own messages are suppressed while a scan
|
||||
runs: librtlsdr writes them straight to file descriptor 2 from C — including
|
||||
`Allocating 15 zero-copy buffers` on *every* capture — and they draw over the
|
||||
display and break its cursor tracking. `bandsaunter devices` still shows them,
|
||||
since that is the command to run when something is wrong, and
|
||||
`BANDSAUNTER_DRIVER_MESSAGES=1` restores them everywhere.
|
||||
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `q` | stop |
|
||||
| `p` | pause / resume |
|
||||
| `s` | skip this signal, resume sweeping |
|
||||
| `l` | lock out this frequency for the rest of the run |
|
||||
| `+` / `-` | adjust the squelch threshold |
|
||||
|
||||
## Built-in help
|
||||
|
||||
Press `h` in the menus for topics covering setup, how the sweep works, why
|
||||
nothing (or too much) is being recorded, capturing conversations, where files
|
||||
go, HF reception and the keys available during a scan. Typing a setting name
|
||||
there explains that setting instead.
|
||||
|
||||
From the command line, `bandsaunter config --describe <setting>` does the same,
|
||||
and `bandsaunter scan --help` lists every flag grouped the same way as the menus.
|
||||
|
||||
## HF
|
||||
|
||||
Frequencies below 24 MHz need direct sampling, which most RTL-SDR dongles
|
||||
support on the Q branch. It is selected automatically:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 40m-cw --record 60 # 40 m CW, decoded to text
|
||||
bandsaunter scan -b am-broadcast
|
||||
```
|
||||
|
||||
You will need an HF antenna; the tuner is bypassed in this mode, so there is
|
||||
no front-end filtering or gain.
|
||||
|
||||
## How the sweep works
|
||||
|
||||
- The band is covered in steps of `sample_rate x usable_fraction / 2`. The
|
||||
local oscillator is parked *below* the span each step covers, so the
|
||||
RTL2832's DC spike never lands inside the frequencies being searched.
|
||||
- The noise floor is measured per FFT bin as a sliding low percentile, which
|
||||
follows the receiver's passband shape and steps over signals. There is no
|
||||
warm-up period, and a station that transmits constantly does not learn
|
||||
itself into the floor.
|
||||
- The sweep uses peak-hold rather than averaging across each dwell, so bursty
|
||||
traffic — CW, packet, a short over — is not averaged into the noise.
|
||||
- On a hit, the receiver retunes with a quarter-rate LO offset (moving the DC
|
||||
spike off the signal), probes once at ~60 Hz resolution to measure the real
|
||||
occupied bandwidth, and picks the demodulator from that plus the band plan.
|
||||
|
||||
## Without hardware
|
||||
|
||||
`--simulate` swaps in a synthetic receiver carrying one of each interesting
|
||||
signal type, which is also what the test suite runs against:
|
||||
|
||||
```bash
|
||||
bandsaunter scan -r 144M-148M --simulate
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Covers DSP invariants, frequency parsing, the classifier against synthetic
|
||||
signals at several SNRs and random seeds, Morse decoding from 8 to 40 WPM, the
|
||||
voice detector against synthetic speech and against noise, tones and hum, and
|
||||
full scan runs through the simulator checking that `--record` and `--hang` are
|
||||
obeyed, that static and bare carriers are never written to disk, and that
|
||||
audio, IQ and metadata are correct.
|
||||
|
||||
The simulator's voice transmitters carry synthesised speech -- glottal pulses
|
||||
through moving formants, compressed the way a real transmitter compresses,
|
||||
then gated into syllables and phrases -- because sine tones would not exercise
|
||||
the speech detector at all.
|
||||
|
||||
## Legal note
|
||||
|
||||
Receiving is not the same as being allowed to use or divulge what you hear. In
|
||||
the US, the ECPA prohibits intercepting cellular and other private
|
||||
communications, and rebroadcasting or acting on what you receive is separately
|
||||
restricted. Check your local rules.
|
||||
8
bandsaunter/__init__.py
Executable file
8
bandsaunter/__init__.py
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
"""bandsaunter -- a signal scanner for RTL-SDR receivers.
|
||||
|
||||
Sweeps arbitrary frequency ranges (or US band-plan presets), records what it
|
||||
finds, and identifies the modulation -- including decoding CW/Morse to text.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = ["__version__"]
|
||||
6
bandsaunter/__main__.py
Executable file
6
bandsaunter/__main__.py
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
48
bandsaunter/_quiet.py
Normal file
48
bandsaunter/_quiet.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Silencing output that comes from underneath Python.
|
||||
|
||||
Libraries write progress bars and status lines straight to file descriptor 2,
|
||||
where redirecting :data:`sys.stderr` cannot reach them. During a scan those
|
||||
land in the middle of the live display and break its cursor tracking, so the
|
||||
header ends up drawn several times over.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
__all__ = ["suppress_stderr"]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_stderr(enabled: bool = True):
|
||||
"""Send file descriptor 2 to nowhere for the duration of a call.
|
||||
|
||||
Kept to the narrowest possible window so a Python traceback is never
|
||||
swallowed by it.
|
||||
"""
|
||||
if not enabled:
|
||||
yield
|
||||
return
|
||||
saved = devnull = None
|
||||
try:
|
||||
sys.stderr.flush()
|
||||
saved = os.dup(2)
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 2)
|
||||
except OSError:
|
||||
if saved is not None:
|
||||
os.close(saved)
|
||||
if devnull is not None:
|
||||
os.close(devnull)
|
||||
yield
|
||||
return
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
os.dup2(saved, 2)
|
||||
finally:
|
||||
os.close(saved)
|
||||
os.close(devnull)
|
||||
436
bandsaunter/announce.py
Normal file
436
bandsaunter/announce.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
"""Spoken announcements, for labelling recordings with the time.
|
||||
|
||||
Uses an installed text-to-speech program when there is one. When there is
|
||||
not -- which is the common case on a headless box -- it falls back to a
|
||||
built-in formant synthesiser. The vocabulary an announcement needs is small
|
||||
and fixed (numbers, month names, a few words), so synthesising it directly is
|
||||
practical and keeps the feature working with nothing else installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
|
||||
__all__ = ["say", "speak_timestamp", "timestamp_phrase", "timestamp_text",
|
||||
"number_words", "available_engine", "ENGINES"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phonemes. Formant targets in Hz, roughly the Peterson-Barney vowel centres
|
||||
# with consonant loci from the standard synthesis-by-rule tables.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Phone:
|
||||
kind: str # vowel, nasal, fric, stop, approx, silence
|
||||
f1: float = 500.0
|
||||
f2: float = 1500.0
|
||||
f3: float = 2500.0
|
||||
duration: float = 0.09 # seconds at normal speed
|
||||
voiced: bool = True
|
||||
noise: float = 0.0 # fricative noise level
|
||||
amp: float = 1.0
|
||||
# Diphthongs and glides move towards a second target.
|
||||
to: tuple[float, float, float] | None = None
|
||||
|
||||
|
||||
P = Phone
|
||||
PHONEMES: dict[str, Phone] = {
|
||||
# -- vowels ----------------------------------------------------------
|
||||
"iy": P("vowel", 300, 2300, 3000, 0.11),
|
||||
"ih": P("vowel", 400, 2000, 2550, 0.075),
|
||||
"eh": P("vowel", 550, 1850, 2500, 0.09),
|
||||
"ae": P("vowel", 660, 1720, 2410, 0.11),
|
||||
"aa": P("vowel", 730, 1090, 2440, 0.11),
|
||||
"ao": P("vowel", 570, 840, 2410, 0.10),
|
||||
"uh": P("vowel", 440, 1020, 2240, 0.075),
|
||||
"uw": P("vowel", 320, 900, 2200, 0.11),
|
||||
"ah": P("vowel", 640, 1190, 2390, 0.075),
|
||||
"er": P("vowel", 490, 1350, 1690, 0.10),
|
||||
# -- diphthongs -------------------------------------------------------
|
||||
"ay": P("vowel", 730, 1090, 2440, 0.16, to=(400, 2000, 2550)),
|
||||
"ey": P("vowel", 550, 1850, 2500, 0.15, to=(300, 2300, 3000)),
|
||||
"ow": P("vowel", 570, 840, 2410, 0.15, to=(320, 900, 2200)),
|
||||
"aw": P("vowel", 730, 1090, 2440, 0.16, to=(440, 1020, 2240)),
|
||||
"oy": P("vowel", 570, 840, 2410, 0.16, to=(400, 2000, 2550)),
|
||||
# -- nasals ------------------------------------------------------------
|
||||
"m": P("nasal", 300, 1100, 2200, 0.07, amp=0.55),
|
||||
"n": P("nasal", 300, 1600, 2600, 0.07, amp=0.55),
|
||||
"ng": P("nasal", 300, 2000, 2600, 0.07, amp=0.55),
|
||||
# -- approximants ------------------------------------------------------
|
||||
"l": P("approx", 380, 1100, 2600, 0.07, amp=0.75),
|
||||
"r": P("approx", 400, 1100, 1600, 0.07, amp=0.75),
|
||||
"w": P("approx", 320, 800, 2200, 0.06, amp=0.7, to=(500, 1500, 2500)),
|
||||
"y": P("approx", 300, 2300, 3000, 0.06, amp=0.7, to=(500, 1500, 2500)),
|
||||
# -- fricatives --------------------------------------------------------
|
||||
"s": P("fric", 500, 4500, 6500, 0.11, voiced=False, noise=1.0, amp=0.5),
|
||||
"z": P("fric", 400, 4200, 6000, 0.09, voiced=True, noise=0.7, amp=0.45),
|
||||
"f": P("fric", 500, 1800, 4500, 0.10, voiced=False, noise=0.7, amp=0.32),
|
||||
"v": P("fric", 400, 1600, 3000, 0.07, voiced=True, noise=0.4, amp=0.35),
|
||||
"th": P("fric", 500, 1700, 4000, 0.09, voiced=False, noise=0.6, amp=0.28),
|
||||
"dh": P("fric", 400, 1500, 2800, 0.06, voiced=True, noise=0.35, amp=0.35),
|
||||
"sh": P("fric", 500, 2400, 4000, 0.12, voiced=False, noise=1.0, amp=0.5),
|
||||
"h": P("fric", 500, 1500, 2500, 0.06, voiced=False, noise=0.5, amp=0.25),
|
||||
# -- stops (closure, then burst, then the transition into the vowel) ---
|
||||
"p": P("stop", 400, 1100, 2200, 0.085, voiced=False, noise=0.8, amp=0.4),
|
||||
"t": P("stop", 400, 1800, 2600, 0.085, voiced=False, noise=0.9, amp=0.45),
|
||||
"k": P("stop", 400, 1900, 2400, 0.085, voiced=False, noise=0.85, amp=0.42),
|
||||
"b": P("stop", 350, 1100, 2200, 0.07, voiced=True, noise=0.35, amp=0.4),
|
||||
"d": P("stop", 350, 1800, 2600, 0.07, voiced=True, noise=0.4, amp=0.4),
|
||||
"g": P("stop", 350, 1900, 2400, 0.07, voiced=True, noise=0.4, amp=0.4),
|
||||
# -- affricates ---------------------------------------------------------
|
||||
"ch": P("stop", 500, 2400, 4000, 0.13, voiced=False, noise=1.0, amp=0.45),
|
||||
"jh": P("stop", 450, 2200, 3200, 0.11, voiced=True, noise=0.6, amp=0.45),
|
||||
# -- silence -------------------------------------------------------------
|
||||
"_": P("silence", duration=0.09, voiced=False, amp=0.0),
|
||||
"__": P("silence", duration=0.22, voiced=False, amp=0.0),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vocabulary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WORDS: dict[str, str] = {
|
||||
"zero": "z iy r ow", "one": "w ah n", "two": "t uw", "three": "th r iy",
|
||||
"four": "f ao r", "five": "f ay v", "six": "s ih k s",
|
||||
"seven": "s eh v ah n", "eight": "ey t", "nine": "n ay n",
|
||||
"ten": "t eh n", "eleven": "ih l eh v ah n", "twelve": "t w eh l v",
|
||||
"thirteen": "th er t iy n", "fourteen": "f ao r t iy n",
|
||||
"fifteen": "f ih f t iy n", "sixteen": "s ih k s t iy n",
|
||||
"seventeen": "s eh v ah n t iy n", "eighteen": "ey t iy n",
|
||||
"nineteen": "n ay n t iy n", "twenty": "t w eh n t iy",
|
||||
"thirty": "th er t iy", "forty": "f ao r t iy", "fifty": "f ih f t iy",
|
||||
"sixty": "s ih k s t iy", "seventy": "s eh v ah n t iy",
|
||||
"eighty": "ey t iy", "ninety": "n ay n t iy",
|
||||
"hundred": "h ah n d r ah d", "thousand": "th aw z ah n d",
|
||||
"oh": "ow", "point": "p oy n t",
|
||||
"january": "jh ae n y uw eh r iy", "february": "f eh b y uw eh r iy",
|
||||
"march": "m aa r ch", "april": "ey p r ih l", "may": "m ey",
|
||||
"june": "jh uw n", "july": "jh uw l ay", "august": "ao g ah s t",
|
||||
"september": "s eh p t eh m b er", "october": "aa k t ow b er",
|
||||
"november": "n ow v eh m b er", "december": "d ih s eh m b er",
|
||||
"a.m.": "ey eh m", "p.m.": "p iy eh m",
|
||||
"megahertz": "m eh g ah h er t s", "kilohertz": "k ih l ow h er t s",
|
||||
"hours": "aw er z", "at": "ae t", "on": "ao n",
|
||||
}
|
||||
|
||||
_ONES = ("zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine")
|
||||
_TEENS = ("ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
|
||||
"sixteen", "seventeen", "eighteen", "nineteen")
|
||||
_TENS = ("", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
||||
"eighty", "ninety")
|
||||
MONTHS = ("january", "february", "march", "april", "may", "june", "july",
|
||||
"august", "september", "october", "november", "december")
|
||||
|
||||
|
||||
def number_words(n: int) -> list[str]:
|
||||
"""Spell a whole number from 0 to 9999 the way it is normally said."""
|
||||
n = int(n)
|
||||
if n < 0:
|
||||
return ["minus"] + number_words(-n)
|
||||
if n < 10:
|
||||
return [_ONES[n]]
|
||||
if n < 20:
|
||||
return [_TEENS[n - 10]]
|
||||
if n < 100:
|
||||
tens, ones = divmod(n, 10)
|
||||
return [_TENS[tens]] + ([_ONES[ones]] if ones else [])
|
||||
if n < 1000:
|
||||
hundreds, rest = divmod(n, 100)
|
||||
out = [_ONES[hundreds], "hundred"]
|
||||
return out + (number_words(rest) if rest else [])
|
||||
thousands, rest = divmod(n, 1000)
|
||||
out = number_words(thousands) + ["thousand"]
|
||||
return out + (number_words(rest) if rest else [])
|
||||
|
||||
|
||||
def _year_words(year: int) -> list[str]:
|
||||
"""Years are said in pairs: 2026 is "twenty twenty six", not "two thousand"."""
|
||||
if 2000 <= year <= 2099:
|
||||
rest = year - 2000
|
||||
if rest == 0:
|
||||
return ["two", "thousand"]
|
||||
if rest < 10:
|
||||
return ["two", "thousand", _ONES[rest]]
|
||||
return ["twenty"] + number_words(rest)
|
||||
return number_words(year)
|
||||
|
||||
|
||||
def _two_digit_clock(value: int) -> list[str]:
|
||||
"""Clock fields: 05 is "oh five", 30 is "thirty"."""
|
||||
if value == 0:
|
||||
return ["oh", "oh"]
|
||||
if value < 10:
|
||||
return ["oh", _ONES[value]]
|
||||
return number_words(value)
|
||||
|
||||
|
||||
def timestamp_phrase(when: datetime, frequency: float | None = None,
|
||||
with_date: bool = True, with_seconds: bool = True) -> str:
|
||||
"""The words to speak for one timestamp."""
|
||||
words: list[str] = []
|
||||
if with_date:
|
||||
words += [MONTHS[when.month - 1]] + number_words(when.day)
|
||||
words += ["_"] + _year_words(when.year) + ["_"]
|
||||
words += number_words(when.hour) if when.hour >= 10 else \
|
||||
_two_digit_clock(when.hour)
|
||||
words += _two_digit_clock(when.minute)
|
||||
if with_seconds:
|
||||
words += ["_"] + _two_digit_clock(when.second)
|
||||
if frequency:
|
||||
words += ["__"] + _frequency_words(frequency)
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def timestamp_text(when: datetime, frequency: float | None = None,
|
||||
with_date: bool = True, with_seconds: bool = True) -> str:
|
||||
"""The same timestamp as ordinary text, for an installed engine.
|
||||
|
||||
Written the way those engines read best rather than as the word list the
|
||||
built-in synthesiser needs. Punctuation is what gives them their phrasing,
|
||||
and the obvious spellings are traps: espeak reads "14:38:05" as "fourteen
|
||||
thirty, eight zero five", and an ISO date as "two thousand and twenty six
|
||||
dash zero eight dash twenty one".
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if with_date:
|
||||
parts.append(f"{MONTHS[when.month - 1].capitalize()} {when.day}")
|
||||
parts.append(" ".join(_year_words(when.year)))
|
||||
clock = f"{when.hour:02d} {when.minute:02d}"
|
||||
if with_seconds:
|
||||
clock += f" and {when.second:02d} seconds"
|
||||
parts.append(f"at {clock}" if with_date else clock)
|
||||
if frequency:
|
||||
parts.append(f"{frequency / 1e6:.3f} megahertz")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _frequency_words(hz: float) -> list[str]:
|
||||
mhz = hz / 1e6
|
||||
whole = int(mhz)
|
||||
frac = round((mhz - whole) * 1000) # kHz, three digits
|
||||
out = number_words(whole) + ["point"]
|
||||
for digit in f"{frac:03d}":
|
||||
out.append(_ONES[int(digit)])
|
||||
return out + ["megahertz"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthesis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resonator(freq: np.ndarray, bandwidth: float, fs: float,
|
||||
x: np.ndarray, block: int = 64) -> np.ndarray:
|
||||
"""Two-pole resonator whose centre frequency moves as the phrase does.
|
||||
|
||||
Processed in short blocks with the coefficients held constant across each
|
||||
one. Articulation moves far more slowly than 4 ms, so this is
|
||||
indistinguishable from recomputing every sample, and it runs through a
|
||||
compiled filter instead of a Python loop.
|
||||
"""
|
||||
r = math.exp(-math.pi * bandwidth / fs)
|
||||
out = np.zeros(x.size)
|
||||
zi = np.zeros(2)
|
||||
for start in range(0, x.size, block):
|
||||
stop = min(start + block, x.size)
|
||||
centre = float(np.mean(freq[start:stop]))
|
||||
theta = 2.0 * math.pi * centre / fs
|
||||
b1 = 2.0 * r * math.cos(theta)
|
||||
b2 = -(r * r)
|
||||
gain = 1.0 - b1 - b2
|
||||
chunk, zi = sps.lfilter([gain], [1.0, -b1, -b2], x[start:stop], zi=zi)
|
||||
out[start:stop] = chunk
|
||||
return out
|
||||
|
||||
|
||||
def _phones_of(text: str) -> list[tuple[str, Phone]]:
|
||||
"""Turn a phrase into phones, looking each word up in the vocabulary."""
|
||||
out: list[tuple[str, Phone]] = []
|
||||
for word in text.split():
|
||||
if word in ("_", "__"):
|
||||
out.append((word, PHONEMES[word]))
|
||||
continue
|
||||
spelling = WORDS.get(word.lower())
|
||||
if spelling is None:
|
||||
continue
|
||||
for name in spelling.split():
|
||||
phone = PHONEMES.get(name)
|
||||
if phone is not None:
|
||||
out.append((name, phone))
|
||||
out.append(("_", PHONEMES["_"]))
|
||||
return out
|
||||
|
||||
|
||||
def synthesize(text: str, fs: float = 16000.0, pitch: float = 115.0,
|
||||
rate: float = 1.0) -> np.ndarray:
|
||||
"""Render a phrase with the built-in formant synthesiser."""
|
||||
phones = _phones_of(text)
|
||||
if not phones:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
|
||||
# Lay out the formant tracks and the source description sample by sample,
|
||||
# so that transitions between phones are continuous rather than stepped.
|
||||
tracks_f, tracks_a, voiced, noise = [], [], [], []
|
||||
for name, ph in phones:
|
||||
n = max(1, int(ph.duration / max(rate, 0.1) * fs))
|
||||
start = np.array([ph.f1, ph.f2, ph.f3])
|
||||
end = np.array(ph.to) if ph.to else start
|
||||
ramp = np.linspace(0.0, 1.0, n)[:, None]
|
||||
tracks_f.append(start[None, :] * (1 - ramp) + end[None, :] * ramp)
|
||||
|
||||
env = np.full(n, ph.amp)
|
||||
if ph.kind == "stop":
|
||||
# Closure, then a burst: silence for the first half, then a short
|
||||
# noisy release. Without the silence a stop is just a fricative.
|
||||
hold = int(n * 0.55)
|
||||
env[:hold] = 0.0
|
||||
env[hold:] = np.linspace(ph.amp, ph.amp * 0.35, n - hold)
|
||||
elif ph.kind == "silence":
|
||||
env[:] = 0.0
|
||||
else:
|
||||
edge = max(1, int(0.012 * fs))
|
||||
if n > 2 * edge:
|
||||
env[:edge] *= np.linspace(0, 1, edge)
|
||||
env[-edge:] *= np.linspace(1, 0, edge)
|
||||
tracks_a.append(env)
|
||||
voiced.append(np.full(n, 1.0 if ph.voiced else 0.0))
|
||||
noise.append(np.full(n, ph.noise))
|
||||
|
||||
f_track = np.vstack(tracks_f)
|
||||
amp = np.concatenate(tracks_a)
|
||||
voiced = np.concatenate(voiced)
|
||||
noise = np.concatenate(noise)
|
||||
total = amp.size
|
||||
|
||||
# Smooth the tracks: articulation is continuous, and abrupt formant jumps
|
||||
# are heard as clicks.
|
||||
smooth = max(3, int(0.02 * fs))
|
||||
kernel = np.ones(smooth) / smooth
|
||||
for i in range(3):
|
||||
f_track[:, i] = np.convolve(f_track[:, i], kernel, mode="same")
|
||||
voiced = np.convolve(voiced, kernel, mode="same")
|
||||
|
||||
# Source: a glottal pulse train with a falling pitch, plus noise.
|
||||
t = np.arange(total) / fs
|
||||
f0 = pitch * (1.0 - 0.18 * t / max(t[-1], 1e-6)) * \
|
||||
(1.0 + 0.03 * np.sin(2 * np.pi * 3.1 * t))
|
||||
phase = np.cumsum(2.0 * np.pi * f0 / fs)
|
||||
pulses = np.zeros(total)
|
||||
edges = np.flatnonzero(np.diff(np.floor(phase / (2 * np.pi))) > 0)
|
||||
pulses[edges] = 1.0
|
||||
pulses -= pulses.mean()
|
||||
rng = np.random.default_rng(12345)
|
||||
hiss = rng.standard_normal(total)
|
||||
|
||||
# A cascade, not three resonators added together. Summing them in
|
||||
# parallel with fixed weights loses the first formant: each resonator is
|
||||
# normalised at DC, which flatters a high formant far more than a low one,
|
||||
# so F1 ends up buried. In cascade the product of the three responses is
|
||||
# the vocal-tract envelope, and the relative levels come out on their own.
|
||||
voiced_src = sps.lfilter([1.0], [1.0, -0.94], voiced * pulses)
|
||||
voiced_src /= max(float(np.abs(voiced_src).max()), 1e-9)
|
||||
src = voiced_src + noise * hiss * 0.30
|
||||
|
||||
out = _resonator(f_track[:, 0], 80.0, fs, src)
|
||||
out = _resonator(f_track[:, 1], 110.0, fs, out)
|
||||
out = _resonator(f_track[:, 2], 160.0, fs, out)
|
||||
out *= amp
|
||||
peak = float(np.abs(out).max())
|
||||
if peak > 0:
|
||||
out = out / peak * 0.7
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# External engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ENGINES = ("espeak-ng", "espeak", "pico2wave", "flite", "say")
|
||||
|
||||
|
||||
def available_engine() -> str | None:
|
||||
"""The first installed text-to-speech program, if any."""
|
||||
for name in ENGINES:
|
||||
if shutil.which(name):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _external(text: str, fs: float, engine: str) -> np.ndarray | None:
|
||||
"""Render with an installed engine, returning None if it will not play."""
|
||||
words = " ".join(w for w in text.split() if w not in ("_", "__"))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp) / "say.wav"
|
||||
if engine in ("espeak-ng", "espeak"):
|
||||
cmd = [engine, "-w", str(out), "-s", "150", words]
|
||||
elif engine == "pico2wave":
|
||||
cmd = [engine, "-w", str(out), words]
|
||||
elif engine == "flite":
|
||||
cmd = [engine, "-t", words, "-o", str(out)]
|
||||
elif engine == "say":
|
||||
cmd = [engine, "-o", str(out), "--data-format=LEI16@16000", words]
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=20)
|
||||
with wave.open(str(out)) as w:
|
||||
raw = w.readframes(w.getnframes())
|
||||
rate = w.getframerate()
|
||||
channels = w.getnchannels()
|
||||
except (OSError, subprocess.SubprocessError, wave.Error):
|
||||
return None
|
||||
audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
|
||||
if channels > 1:
|
||||
audio = audio.reshape(-1, channels).mean(axis=1)
|
||||
if rate != fs and audio.size:
|
||||
from scipy.signal import resample_poly
|
||||
g = math.gcd(int(rate), int(fs))
|
||||
audio = resample_poly(audio, int(fs) // g, int(rate) // g)
|
||||
# Match the built-in synthesiser's level, so switching engines does not
|
||||
# change how loud the announcements are against the recordings.
|
||||
peak = float(np.abs(audio).max()) if audio.size else 0.0
|
||||
if peak > 0:
|
||||
audio = audio / peak * 0.7
|
||||
return audio.astype(np.float32)
|
||||
|
||||
|
||||
def say(text: str, fs: float = 16000.0, engine: str = "auto") -> np.ndarray:
|
||||
"""Speak a phrase, preferring an installed engine over the built-in one."""
|
||||
if engine != "builtin":
|
||||
chosen = available_engine() if engine == "auto" else engine
|
||||
if chosen:
|
||||
audio = _external(text, fs, chosen)
|
||||
if audio is not None and audio.size:
|
||||
return audio
|
||||
return synthesize(text, fs)
|
||||
|
||||
|
||||
def speak_timestamp(when: datetime, fs: float = 16000.0,
|
||||
frequency: float | None = None, engine: str = "auto",
|
||||
with_date: bool = True) -> np.ndarray:
|
||||
"""Audio saying the date and time, for splicing before a recording.
|
||||
|
||||
The wording differs by engine: an installed one is given ordinary text
|
||||
with punctuation, while the built-in synthesiser is given the word list it
|
||||
has pronunciations for.
|
||||
"""
|
||||
chosen = None if engine == "builtin" else (
|
||||
available_engine() if engine == "auto" else engine)
|
||||
if chosen:
|
||||
audio = _external(timestamp_text(when, frequency, with_date=with_date),
|
||||
fs, chosen)
|
||||
if audio is not None and audio.size:
|
||||
return audio
|
||||
return synthesize(timestamp_phrase(when, frequency, with_date=with_date), fs)
|
||||
537
bandsaunter/bandplan.py
Executable file
537
bandsaunter/bandplan.py
Executable file
|
|
@ -0,0 +1,537 @@
|
|||
"""US band plan presets.
|
||||
|
||||
Each preset is a tunable span plus enough metadata for the scanner to pick a
|
||||
sensible demodulator, channel spacing and bandwidth without being told.
|
||||
Frequencies are in Hz. Coverage follows the FCC allocations and the common
|
||||
NA channel plans (ITU Region 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["BandPreset", "PRESETS", "CATEGORIES", "by_key", "search",
|
||||
"in_category", "presets_covering", "expand_preset"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BandPreset:
|
||||
key: str
|
||||
name: str
|
||||
category: str
|
||||
start: float
|
||||
stop: float
|
||||
step: float = 12_500.0 # channel spacing / scan resolution
|
||||
mode: str = "nfm" # demodulator hint: nfm wfm am usb lsb cw raw
|
||||
bandwidth: float = 12_500.0 # nominal signal bandwidth
|
||||
note: str = ""
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
# A preset may instead stand for a set of others, so that scattered
|
||||
# segments -- every CW allocation, say -- can be picked in one go.
|
||||
members: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def is_group(self) -> bool:
|
||||
return bool(self.members)
|
||||
|
||||
def expand(self) -> list["BandPreset"]:
|
||||
"""The presets this one actually scans: itself, or its members."""
|
||||
if not self.members:
|
||||
return [self]
|
||||
out = []
|
||||
for key in self.members:
|
||||
member = _BY_KEY.get(key)
|
||||
if member is not None:
|
||||
out.extend(member.expand())
|
||||
return out
|
||||
|
||||
@property
|
||||
def span(self) -> float:
|
||||
return self.stop - self.start
|
||||
|
||||
@property
|
||||
def needs_direct_sampling(self) -> bool:
|
||||
return self.stop < 24_000_000
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.name} {fmt_hz(self.start)}-{fmt_hz(self.stop)} [{self.mode}]"
|
||||
|
||||
|
||||
def fmt_hz(hz: float) -> str:
|
||||
"""Render a frequency the way a radio operator would write it."""
|
||||
hz = float(hz)
|
||||
if hz >= 1e9:
|
||||
return f"{hz/1e9:.6f}".rstrip("0").rstrip(".") + " GHz"
|
||||
if hz >= 1e6:
|
||||
s = f"{hz/1e6:.6f}".rstrip("0").rstrip(".")
|
||||
return f"{s} MHz"
|
||||
if hz >= 1e3:
|
||||
s = f"{hz/1e3:.4f}".rstrip("0").rstrip(".")
|
||||
return f"{s} kHz"
|
||||
return f"{hz:.0f} Hz"
|
||||
|
||||
|
||||
_P = BandPreset
|
||||
|
||||
PRESETS: tuple[BandPreset, ...] = (
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HF -- requires direct sampling (Q branch) on an RTL2832 dongle.
|
||||
# ------------------------------------------------------------------
|
||||
_P("am-broadcast", "AM Broadcast Band", "HF / Shortwave",
|
||||
530_000, 1_710_000, 10_000, "am", 10_000,
|
||||
"US MW broadcast, 10 kHz channel spacing", ("broadcast", "hf")),
|
||||
_P("160m", "160 m Amateur (Top Band)", "Amateur Radio",
|
||||
1_800_000, 2_000_000, 500, "lsb", 2_800,
|
||||
"CW/digital low, phone above 1.843", ("ham", "hf")),
|
||||
_P("160m-cw", "160 m CW", "Amateur Radio",
|
||||
1_800_000, 1_840_000, 200, "cw", 500,
|
||||
"CW and digital segment", ("ham", "hf", "cw")),
|
||||
_P("120m-swbc", "120 m Shortwave Broadcast", "HF / Shortwave",
|
||||
2_300_000, 2_495_000, 5_000, "am", 9_000, "Tropical band", ("swl", "hf")),
|
||||
_P("marine-hf-2mhz", "Marine HF 2 MHz", "Marine",
|
||||
2_000_000, 2_850_000, 1_000, "usb", 2_800,
|
||||
"Includes 2182 kHz distress", ("marine", "hf")),
|
||||
_P("wwv", "WWV / WWVH / CHU Time Signals", "Utility / Time",
|
||||
2_490_000, 20_010_000, 1_000, "am", 6_000,
|
||||
"2.5/5/10/15/20 MHz WWV, 3.33/7.85/14.67 CHU", ("utility", "hf")),
|
||||
_P("90m-swbc", "90 m Shortwave Broadcast", "HF / Shortwave",
|
||||
3_200_000, 3_400_000, 5_000, "am", 9_000, "Tropical band", ("swl", "hf")),
|
||||
_P("80m", "80/75 m Amateur", "Amateur Radio",
|
||||
3_500_000, 4_000_000, 500, "lsb", 2_800,
|
||||
"CW 3.500-3.600, phone 3.800-4.000", ("ham", "hf")),
|
||||
_P("80m-cw", "80 m CW / Digital", "Amateur Radio",
|
||||
3_500_000, 3_600_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 3.573", ("ham", "hf", "cw")),
|
||||
_P("75m-swbc", "75 m Shortwave Broadcast", "HF / Shortwave",
|
||||
3_900_000, 4_000_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("60m", "60 m Amateur (channelised)", "Amateur Radio",
|
||||
5_330_500, 5_405_000, 100, "usb", 2_800,
|
||||
"Five fixed US channels", ("ham", "hf")),
|
||||
_P("49m-swbc", "49 m Shortwave Broadcast", "HF / Shortwave",
|
||||
5_900_000, 6_200_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("aero-hf", "HF Aeronautical (oceanic)", "Aviation",
|
||||
5_450_000, 6_700_000, 1_000, "usb", 2_800,
|
||||
"MWARA / VOLMET / SELCAL", ("aviation", "hf")),
|
||||
_P("40m", "40 m Amateur", "Amateur Radio",
|
||||
7_000_000, 7_300_000, 500, "lsb", 2_800,
|
||||
"CW 7.000-7.125, phone 7.125-7.300", ("ham", "hf")),
|
||||
_P("40m-cw", "40 m CW / Digital", "Amateur Radio",
|
||||
7_000_000, 7_125_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 7.074", ("ham", "hf", "cw")),
|
||||
_P("41m-swbc", "41 m Shortwave Broadcast", "HF / Shortwave",
|
||||
7_200_000, 7_600_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("31m-swbc", "31 m Shortwave Broadcast", "HF / Shortwave",
|
||||
9_400_000, 9_900_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("30m", "30 m Amateur (CW/digital only)", "Amateur Radio",
|
||||
10_100_000, 10_150_000, 200, "cw", 500,
|
||||
"No phone; FT8 at 10.136", ("ham", "hf", "cw")),
|
||||
_P("25m-swbc", "25 m Shortwave Broadcast", "HF / Shortwave",
|
||||
11_600_000, 12_100_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("22m-swbc", "22 m Shortwave Broadcast", "HF / Shortwave",
|
||||
13_570_000, 13_870_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("20m", "20 m Amateur", "Amateur Radio",
|
||||
14_000_000, 14_350_000, 500, "usb", 2_800,
|
||||
"CW 14.000-14.150, phone above", ("ham", "hf")),
|
||||
_P("20m-cw", "20 m CW / Digital", "Amateur Radio",
|
||||
14_000_000, 14_150_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 14.074", ("ham", "hf", "cw")),
|
||||
_P("19m-swbc", "19 m Shortwave Broadcast", "HF / Shortwave",
|
||||
15_100_000, 15_830_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("17m", "17 m Amateur", "Amateur Radio",
|
||||
18_068_000, 18_168_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
||||
_P("17m-cw", "17 m CW / Digital", "Amateur Radio",
|
||||
18_068_000, 18_110_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 18.100", ("ham", "hf", "cw")),
|
||||
_P("16m-swbc", "16 m Shortwave Broadcast", "HF / Shortwave",
|
||||
17_480_000, 17_900_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("15m", "15 m Amateur", "Amateur Radio",
|
||||
21_000_000, 21_450_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
||||
_P("15m-cw", "15 m CW / Digital", "Amateur Radio",
|
||||
21_000_000, 21_200_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 21.074", ("ham", "hf", "cw")),
|
||||
_P("13m-swbc", "13 m Shortwave Broadcast", "HF / Shortwave",
|
||||
21_450_000, 21_850_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
||||
_P("12m", "12 m Amateur", "Amateur Radio",
|
||||
24_890_000, 24_990_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
||||
_P("12m-cw", "12 m CW / Digital", "Amateur Radio",
|
||||
24_890_000, 24_930_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 24.915", ("ham", "hf", "cw")),
|
||||
_P("cb", "Citizens Band (CB) 11 m", "Land Mobile",
|
||||
26_965_000, 27_405_000, 10_000, "am", 8_000,
|
||||
"40 channels; ch 19 = 27.185, SSB above ch 35", ("cb",)),
|
||||
_P("cb-ssb", "CB SSB / Freeband", "Land Mobile",
|
||||
27_235_000, 27_995_000, 5_000, "usb", 2_800, "", ("cb",)),
|
||||
_P("10m", "10 m Amateur", "Amateur Radio",
|
||||
28_000_000, 29_700_000, 1_000, "usb", 2_800,
|
||||
"CW/data low, FM repeaters 29.5-29.7", ("ham", "hf")),
|
||||
_P("10m-cw", "10 m CW / Digital", "Amateur Radio",
|
||||
28_000_000, 28_300_000, 200, "cw", 500,
|
||||
"CW segment, FT8 at 28.074, beacons 28.190-28.300", ("ham", "hf", "cw")),
|
||||
_P("10m-fm", "10 m FM Simplex / Repeaters", "Amateur Radio",
|
||||
29_500_000, 29_700_000, 20_000, "nfm", 16_000, "", ("ham",)),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VHF low band
|
||||
# ------------------------------------------------------------------
|
||||
_P("vhf-low-public-safety", "VHF Low Band Public Safety", "Public Safety",
|
||||
30_000_000, 50_000_000, 5_000, "nfm", 12_500,
|
||||
"State police, forestry, highway crews", ("publicsafety",)),
|
||||
_P("mil-lowband", "Military / SINCGARS Low Band", "Military",
|
||||
30_000_000, 88_000_000, 25_000, "nfm", 25_000,
|
||||
"Tactical FM, mostly frequency hopping", ("military",)),
|
||||
_P("6m", "6 m Amateur", "Amateur Radio",
|
||||
50_000_000, 54_000_000, 20_000, "nfm", 16_000,
|
||||
"CW/SSB 50.0-50.3, FM 52-54", ("ham",)),
|
||||
_P("6m-ssb", "6 m Weak Signal (CW/SSB)", "Amateur Radio",
|
||||
50_000_000, 50_300_000, 500, "usb", 2_800,
|
||||
"50.125 SSB calling, FT8 at 50.313", ("ham", "cw")),
|
||||
_P("6m-cw", "6 m CW", "Amateur Radio",
|
||||
50_000_000, 50_100_000, 200, "cw", 500,
|
||||
"CW segment, beacons 50.060-50.080", ("ham", "cw")),
|
||||
_P("rc-radio-control", "R/C Radio Control 72 MHz", "Telemetry / Control",
|
||||
72_000_000, 76_000_000, 20_000, "nfm", 10_000,
|
||||
"Model aircraft channels 11-60", ("control",)),
|
||||
_P("tv-low-vhf", "VHF TV Channels 2-6", "Broadcast",
|
||||
54_000_000, 88_000_000, 6_000_000, "raw", 6_000_000,
|
||||
"ATSC 8VSB, mostly vacated", ("broadcast",)),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VHF high band
|
||||
# ------------------------------------------------------------------
|
||||
_P("fm-broadcast", "FM Broadcast Band", "Broadcast",
|
||||
88_100_000, 107_900_000, 200_000, "wfm", 180_000,
|
||||
"US odd-tenth channel centres", ("broadcast",)),
|
||||
_P("aircraft-vhf", "VHF Airband (voice)", "Aviation",
|
||||
118_000_000, 137_000_000, 25_000, "am", 12_500,
|
||||
"Tower/approach/centre, 8.33 kHz not used in US", ("aviation",)),
|
||||
_P("aircraft-vhf-tower", "VHF Airband - Tower & Ground", "Aviation",
|
||||
118_000_000, 122_000_000, 25_000, "am", 12_500, "", ("aviation",)),
|
||||
_P("aircraft-vhf-unicom", "VHF Airband - UNICOM / CTAF", "Aviation",
|
||||
122_000_000, 123_600_000, 25_000, "am", 12_500,
|
||||
"122.700-122.975 and 123.000 CTAF", ("aviation",)),
|
||||
_P("acars", "ACARS Datalink", "Aviation",
|
||||
129_125_000, 136_975_000, 25_000, "am", 12_500,
|
||||
"Primary US ACARS on 131.550", ("aviation", "data")),
|
||||
_P("satellite-vhf", "VHF Satellite Downlinks", "Satellite",
|
||||
136_000_000, 138_000_000, 12_500, "nfm", 40_000,
|
||||
"NOAA APT 137.100/137.9125, Meteor, cubesats", ("satellite",)),
|
||||
_P("noaa-apt", "NOAA APT Weather Satellites", "Satellite",
|
||||
137_000_000, 138_000_000, 25_000, "wfm", 40_000,
|
||||
"NOAA-15 137.620, NOAA-18 137.9125, NOAA-19 137.100", ("satellite",)),
|
||||
_P("federal-vhf", "Federal Government VHF", "Government",
|
||||
138_000_000, 144_000_000, 12_500, "nfm", 12_500,
|
||||
"Military land mobile and federal agencies", ("government",)),
|
||||
_P("2m", "2 m Amateur", "Amateur Radio",
|
||||
144_000_000, 148_000_000, 15_000, "nfm", 16_000,
|
||||
"CW/SSB 144.0-144.3, FM simplex/repeaters above", ("ham",)),
|
||||
_P("2m-ssb", "2 m Weak Signal (CW/SSB)", "Amateur Radio",
|
||||
144_000_000, 144_300_000, 500, "usb", 2_800,
|
||||
"144.200 SSB calling, FT8 at 144.174", ("ham", "cw")),
|
||||
_P("2m-cw", "2 m CW", "Amateur Radio",
|
||||
144_000_000, 144_100_000, 200, "cw", 500,
|
||||
"CW segment, EME and beacons at the bottom", ("ham", "cw")),
|
||||
_P("2m-simplex", "2 m FM Simplex", "Amateur Radio",
|
||||
146_400_000, 147_600_000, 15_000, "nfm", 16_000,
|
||||
"146.520 national simplex calling", ("ham",)),
|
||||
_P("2m-repeaters", "2 m Repeater Outputs", "Amateur Radio",
|
||||
145_100_000, 148_000_000, 15_000, "nfm", 16_000, "", ("ham",)),
|
||||
_P("aprs", "APRS / Packet 2 m", "Amateur Radio",
|
||||
144_380_000, 144_400_000, 12_500, "nfm", 16_000,
|
||||
"144.390 AFSK1200 in North America", ("ham", "data")),
|
||||
_P("federal-vhf-2", "Federal Government VHF (upper)", "Government",
|
||||
148_000_000, 150_800_000, 12_500, "nfm", 12_500, "", ("government",)),
|
||||
_P("vhf-business", "VHF Business / Industrial", "Land Mobile",
|
||||
150_800_000, 156_000_000, 7_500, "nfm", 12_500,
|
||||
"Itinerant, taxi, utilities, tow", ("business",)),
|
||||
_P("murs", "MURS", "Land Mobile",
|
||||
151_820_000, 154_600_000, 7_500, "nfm", 12_500,
|
||||
"5 licence-free channels: 151.820/.880/.940, 154.570/.600", ("business",)),
|
||||
_P("railroad", "Railroad (AAR channels)", "Land Mobile",
|
||||
159_810_000, 161_565_000, 7_500, "nfm", 12_500,
|
||||
"97 AAR road/yard channels", ("railroad",)),
|
||||
_P("marine-vhf", "Marine VHF", "Marine",
|
||||
156_000_000, 162_025_000, 25_000, "nfm", 16_000,
|
||||
"Ch 16 = 156.800 distress, Ch 13/22A working", ("marine",)),
|
||||
_P("ais", "AIS Ship Transponders", "Marine",
|
||||
161_975_000, 162_025_000, 25_000, "raw", 25_000,
|
||||
"AIS-A 161.975, AIS-B 162.025, 9600 GMSK", ("marine", "data")),
|
||||
_P("noaa-weather", "NOAA Weather Radio", "Broadcast",
|
||||
162_400_000, 162_550_000, 25_000, "nfm", 16_000,
|
||||
"7 channels, 162.400-162.550", ("broadcast",)),
|
||||
_P("federal-vhf-3", "Federal VHF (162-174)", "Government",
|
||||
162_000_000, 174_000_000, 12_500, "nfm", 12_500,
|
||||
"FBI, DHS, Forest Service, NPS", ("government",)),
|
||||
_P("vhf-highband-ps", "VHF High Band Public Safety", "Public Safety",
|
||||
154_000_000, 160_000_000, 7_500, "nfm", 12_500,
|
||||
"Fire, EMS, police, local government", ("publicsafety",)),
|
||||
_P("wireless-mics-vhf", "Wireless Microphones VHF", "Audio / Production",
|
||||
169_000_000, 172_000_000, 25_000, "nfm", 25_000,
|
||||
"Travelling band and assistive listening", ("production",)),
|
||||
_P("tv-high-vhf", "VHF TV Channels 7-13", "Broadcast",
|
||||
174_000_000, 216_000_000, 6_000_000, "raw", 6_000_000,
|
||||
"ATSC 8VSB", ("broadcast",)),
|
||||
_P("dab-us", "VHF 216-225 (misc)", "Broadcast",
|
||||
216_000_000, 225_000_000, 25_000, "nfm", 25_000,
|
||||
"Telemetry, AMTS maritime", ("misc",)),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UHF
|
||||
# ------------------------------------------------------------------
|
||||
_P("mil-uhf-air", "Military UHF Aircraft", "Aviation",
|
||||
225_000_000, 400_000_000, 25_000, "am", 12_500,
|
||||
"243.000 guard, air refuelling, ranges", ("military", "aviation")),
|
||||
_P("milsat-uhf", "UHF SATCOM Downlinks", "Satellite",
|
||||
240_000_000, 270_000_000, 5_000, "nfm", 25_000,
|
||||
"FLTSATCOM / UFO downlinks", ("satellite", "military")),
|
||||
_P("1.25m", "1.25 m (220 MHz) Amateur", "Amateur Radio",
|
||||
222_000_000, 225_000_000, 20_000, "nfm", 16_000, "", ("ham",)),
|
||||
_P("1.25m-weak", "1.25 m Weak Signal (CW/SSB)", "Amateur Radio",
|
||||
222_000_000, 222_150_000, 500, "usb", 2_800,
|
||||
"222.100 SSB calling", ("ham", "cw")),
|
||||
_P("federal-uhf", "Federal Government UHF", "Government",
|
||||
406_100_000, 420_000_000, 12_500, "nfm", 12_500,
|
||||
"Federal land mobile", ("government",)),
|
||||
_P("radiosonde", "Radiosondes / Weather Balloons", "Telemetry / Control",
|
||||
400_000_000, 406_000_000, 10_000, "nfm", 15_000,
|
||||
"RS41, DFM, iMet -- 403 MHz is the US cluster", ("telemetry",)),
|
||||
_P("70cm", "70 cm Amateur", "Amateur Radio",
|
||||
420_000_000, 450_000_000, 12_500, "nfm", 16_000,
|
||||
"Repeaters 440-450, ATV low", ("ham",)),
|
||||
_P("70cm-cw", "70 cm CW / EME", "Amateur Radio",
|
||||
432_000_000, 432_100_000, 200, "cw", 500,
|
||||
"CW and moonbounce at the bottom of the band", ("ham", "cw")),
|
||||
_P("70cm-weak", "70 cm Weak Signal (SSB)", "Amateur Radio",
|
||||
432_100_000, 432_400_000, 500, "usb", 2_800,
|
||||
"432.100 SSB calling", ("ham",)),
|
||||
_P("70cm-fm", "70 cm FM Repeaters", "Amateur Radio",
|
||||
440_000_000, 450_000_000, 12_500, "nfm", 16_000,
|
||||
"Repeater outputs and simplex", ("ham",)),
|
||||
_P("70cm-simplex", "70 cm FM Simplex", "Amateur Radio",
|
||||
445_000_000, 447_000_000, 12_500, "nfm", 16_000,
|
||||
"446.000 national simplex calling", ("ham",)),
|
||||
_P("uhf-business", "UHF Business / Industrial", "Land Mobile",
|
||||
450_000_000, 470_000_000, 6_250, "nfm", 12_500,
|
||||
"Includes 464/469 itinerant 'colour dot' channels", ("business",)),
|
||||
_P("uhf-public-safety", "UHF Public Safety", "Public Safety",
|
||||
453_000_000, 460_000_000, 6_250, "nfm", 12_500, "", ("publicsafety",)),
|
||||
_P("gmrs", "GMRS / FRS", "Land Mobile",
|
||||
462_550_000, 467_725_000, 12_500, "nfm", 12_500,
|
||||
"22 shared channels + 8 GMRS repeater pairs", ("consumer",)),
|
||||
_P("frs-simplex", "FRS Simplex Channels", "Land Mobile",
|
||||
462_562_500, 467_712_500, 12_500, "nfm", 12_500,
|
||||
"Licence-free handhelds", ("consumer",)),
|
||||
_P("dot-itinerant", "Itinerant 'Colour Dot' Channels", "Land Mobile",
|
||||
464_500_000, 469_562_500, 12_500, "nfm", 12_500,
|
||||
"Red/Blue/Green/Purple dot business itinerant", ("business",)),
|
||||
_P("uhf-tv", "UHF TV Channels 14-36", "Broadcast",
|
||||
470_000_000, 608_000_000, 6_000_000, "raw", 6_000_000,
|
||||
"ATSC 1.0/3.0", ("broadcast",)),
|
||||
_P("t-band", "T-Band Public Safety (470-512)", "Public Safety",
|
||||
470_000_000, 512_000_000, 12_500, "nfm", 12_500,
|
||||
"Only in 11 major metro areas", ("publicsafety",)),
|
||||
_P("wireless-mics-uhf", "Wireless Microphones UHF", "Audio / Production",
|
||||
512_000_000, 608_000_000, 25_000, "nfm", 200_000,
|
||||
"Shure/Sennheiser IEM and mics in the TV band", ("production",)),
|
||||
_P("600-duplex-gap", "600 MHz Duplex Gap / White Space", "Telecom",
|
||||
614_000_000, 698_000_000, 100_000, "raw", 200_000,
|
||||
"T-Mobile 600 LTE, wireless mics in the gap", ("telecom",)),
|
||||
_P("700-public-safety", "700 MHz Public Safety", "Public Safety",
|
||||
763_000_000, 806_000_000, 12_500, "nfm", 12_500,
|
||||
"P25 Phase I/II narrowband + FirstNet LTE", ("publicsafety", "trunked")),
|
||||
_P("800-public-safety", "800 MHz Public Safety / SMR", "Public Safety",
|
||||
806_000_000, 824_000_000, 12_500, "nfm", 12_500,
|
||||
"NPSPAC, Motorola trunked systems", ("publicsafety", "trunked")),
|
||||
_P("800-trunked", "800 MHz Trunked Downlinks", "Public Safety",
|
||||
851_000_000, 869_000_000, 12_500, "nfm", 12_500,
|
||||
"Repeater outputs for 806-824 inputs", ("trunked",)),
|
||||
_P("900-smr", "900 MHz SMR / Business", "Land Mobile",
|
||||
896_000_000, 940_000_000, 12_500, "nfm", 12_500,
|
||||
"Includes 935-940 trunked outputs", ("business", "trunked")),
|
||||
_P("cell-850", "Cellular 850 MHz", "Telecom",
|
||||
824_000_000, 894_000_000, 200_000, "raw", 1_400_000,
|
||||
"GSM/LTE band 5", ("telecom",)),
|
||||
_P("cell-1900", "PCS 1900 MHz", "Telecom",
|
||||
1_850_000_000, 1_990_000_000, 200_000, "raw", 1_400_000,
|
||||
"LTE band 2/25 -- above most RTL tuners", ("telecom",)),
|
||||
_P("33cm", "33 cm (902-928) Amateur", "Amateur Radio",
|
||||
902_000_000, 928_000_000, 25_000, "nfm", 16_000, "", ("ham",)),
|
||||
_P("33cm-weak", "33 cm Weak Signal (CW/SSB)", "Amateur Radio",
|
||||
902_100_000, 903_100_000, 500, "usb", 2_800,
|
||||
"903.100 SSB calling", ("ham", "cw")),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ISM / SRD / consumer
|
||||
# ------------------------------------------------------------------
|
||||
_P("ism-315", "ISM 315 MHz (SRD)", "ISM / Devices",
|
||||
314_900_000, 315_100_000, 10_000, "raw", 50_000,
|
||||
"TPMS, key fobs, garage doors, OOK/FSK", ("ism",)),
|
||||
_P("ism-390", "ISM 390 MHz (SRD)", "ISM / Devices",
|
||||
389_900_000, 390_100_000, 10_000, "raw", 50_000,
|
||||
"GM/Ford TPMS and remotes", ("ism",)),
|
||||
_P("ism-433", "ISM 433 MHz (SRD)", "ISM / Devices",
|
||||
433_050_000, 434_790_000, 10_000, "raw", 50_000,
|
||||
"Weather stations, sensors, remotes", ("ism",)),
|
||||
_P("ism-915", "ISM 902-928 MHz", "ISM / Devices",
|
||||
902_000_000, 928_000_000, 100_000, "raw", 200_000,
|
||||
"Smart meters, LoRa, FHSS, Z-Wave 908.4", ("ism",)),
|
||||
_P("zwave", "Z-Wave (US)", "ISM / Devices",
|
||||
908_000_000, 916_500_000, 100_000, "raw", 100_000,
|
||||
"908.4 / 916.0 MHz", ("ism",)),
|
||||
_P("tpms", "TPMS Tyre Sensors", "ISM / Devices",
|
||||
314_900_000, 315_100_000, 10_000, "raw", 50_000,
|
||||
"Also check 433.92 MHz", ("ism",)),
|
||||
_P("cordless-phones", "Cordless Phones (legacy)", "Consumer",
|
||||
43_000_000, 50_000_000, 20_000, "nfm", 12_500,
|
||||
"43-50 MHz analogue handsets", ("consumer",)),
|
||||
_P("baby-monitors", "Baby Monitors / Analogue Video", "Consumer",
|
||||
49_000_000, 50_000_000, 20_000, "nfm", 25_000, "", ("consumer",)),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data / paging / aviation surveillance
|
||||
# ------------------------------------------------------------------
|
||||
_P("pagers-vhf", "VHF Paging (POCSAG/FLEX)", "Paging",
|
||||
152_000_000, 159_000_000, 12_500, "nfm", 12_500,
|
||||
"152.0-152.24, 157.45, 158.1", ("paging", "data")),
|
||||
_P("pagers-uhf", "UHF / 900 MHz Paging", "Paging",
|
||||
929_000_000, 932_000_000, 25_000, "nfm", 25_000,
|
||||
"FLEX and POCSAG carriers", ("paging", "data")),
|
||||
_P("adsb", "ADS-B (1090 MHz)", "Aviation",
|
||||
1_089_000_000, 1_091_000_000, 1_000_000, "raw", 2_000_000,
|
||||
"Mode S extended squitter, PPM at 1 Mbit/s", ("aviation", "data")),
|
||||
_P("uat-978", "UAT / ADS-B 978 MHz", "Aviation",
|
||||
977_000_000, 979_000_000, 1_000_000, "raw", 2_000_000,
|
||||
"US general aviation ADS-B and FIS-B", ("aviation", "data")),
|
||||
_P("dme-tacan", "DME / TACAN", "Aviation",
|
||||
960_000_000, 1_215_000_000, 1_000_000, "raw", 1_000_000,
|
||||
"Pulse pairs, aircraft navigation", ("aviation",)),
|
||||
_P("inmarsat", "Inmarsat L-band Downlink", "Satellite",
|
||||
1_525_000_000, 1_559_000_000, 25_000, "raw", 50_000,
|
||||
"STD-C / AERO -- needs an LNA and a patch antenna", ("satellite",)),
|
||||
_P("iridium", "Iridium Downlink", "Satellite",
|
||||
1_616_000_000, 1_626_500_000, 100_000, "raw", 500_000,
|
||||
"Bursty QPSK, top of the RTL tuning range", ("satellite",)),
|
||||
_P("gps-l1", "GPS L1", "Satellite",
|
||||
1_575_000_000, 1_576_000_000, 1_000_000, "raw", 2_000_000,
|
||||
"Below the noise floor without correlation", ("satellite",)),
|
||||
_P("weather-fax", "HF Weather Fax / RTTY", "Utility / Time",
|
||||
3_800_000, 17_200_000, 1_000, "usb", 2_800,
|
||||
"NOAA HF FAX schedules", ("utility", "hf")),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broad sweeps
|
||||
# ------------------------------------------------------------------
|
||||
_P("everything-vhf", "Full VHF Sweep", "Wide Sweeps",
|
||||
30_000_000, 300_000_000, 12_500, "nfm", 12_500,
|
||||
"Slow but thorough", ("sweep",)),
|
||||
_P("everything-uhf", "Full UHF Sweep", "Wide Sweeps",
|
||||
300_000_000, 1_000_000_000, 12_500, "nfm", 12_500,
|
||||
"Very slow", ("sweep",)),
|
||||
_P("everything", "Full Tuner Range Sweep", "Wide Sweeps",
|
||||
24_000_000, 1_700_000_000, 25_000, "nfm", 12_500,
|
||||
"The whole R820T range; expect long cycle times", ("sweep",)),
|
||||
_P("everything-hf", "Full HF Sweep (direct sampling)", "Wide Sweeps",
|
||||
500_000, 28_000_000, 1_000, "usb", 3_000,
|
||||
"Requires direct sampling and an HF antenna", ("sweep", "hf")),
|
||||
_P("common-scanner", "Common Scanner Bands", "Wide Sweeps",
|
||||
144_000_000, 174_000_000, 12_500, "nfm", 12_500,
|
||||
"The classic 'action band'", ("sweep",)),
|
||||
# Whole amateur bands. Mode "auto" and bandwidth 0 mean the demodulator
|
||||
# and filter width are looked up per frequency from the segment presets
|
||||
# above, so one range covers a band whose bottom is CW, middle SSB and
|
||||
# top FM without any of it being demodulated the wrong way.
|
||||
_P("160m-complete", "160 m Complete Band", "Amateur Radio",
|
||||
1_800_000, 2_000_000, 500, "auto", 0,
|
||||
"CW, digital and LSB phone", ("ham", "hf", "complete")),
|
||||
_P("80m-complete", "80/75 m Complete Band", "Amateur Radio",
|
||||
3_500_000, 4_000_000, 500, "auto", 0,
|
||||
"CW below 3.600, LSB phone above", ("ham", "hf", "complete")),
|
||||
_P("60m-complete", "60 m Complete Band", "Amateur Radio",
|
||||
5_330_500, 5_405_000, 100, "auto", 0,
|
||||
"Five fixed USB channels", ("ham", "hf", "complete")),
|
||||
_P("40m-complete", "40 m Complete Band", "Amateur Radio",
|
||||
7_000_000, 7_300_000, 500, "auto", 0,
|
||||
"CW below 7.125, LSB phone above", ("ham", "hf", "complete")),
|
||||
_P("30m-complete", "30 m Complete Band", "Amateur Radio",
|
||||
10_100_000, 10_150_000, 200, "auto", 0,
|
||||
"CW and digital only, no phone", ("ham", "hf", "complete")),
|
||||
_P("20m-complete", "20 m Complete Band", "Amateur Radio",
|
||||
14_000_000, 14_350_000, 500, "auto", 0,
|
||||
"CW below 14.150, USB phone above", ("ham", "hf", "complete")),
|
||||
_P("17m-complete", "17 m Complete Band", "Amateur Radio",
|
||||
18_068_000, 18_168_000, 500, "auto", 0,
|
||||
"CW below 18.110, USB phone above", ("ham", "hf", "complete")),
|
||||
_P("15m-complete", "15 m Complete Band", "Amateur Radio",
|
||||
21_000_000, 21_450_000, 500, "auto", 0,
|
||||
"CW below 21.200, USB phone above", ("ham", "hf", "complete")),
|
||||
_P("12m-complete", "12 m Complete Band", "Amateur Radio",
|
||||
24_890_000, 24_990_000, 500, "auto", 0,
|
||||
"CW below 24.930, USB phone above", ("ham", "hf", "complete")),
|
||||
_P("10m-complete", "10 m Complete Band", "Amateur Radio",
|
||||
28_000_000, 29_700_000, 1_000, "auto", 0,
|
||||
"CW, USB phone, then FM repeaters at the top", ("ham", "hf", "complete")),
|
||||
_P("6m-complete", "6 m Complete Band", "Amateur Radio",
|
||||
50_000_000, 54_000_000, 5_000, "auto", 0,
|
||||
"CW, SSB, then FM above 50.3", ("ham", "complete")),
|
||||
_P("2m-complete", "2 m Complete Band", "Amateur Radio",
|
||||
144_000_000, 148_000_000, 5_000, "auto", 0,
|
||||
"CW, SSB, then FM above 144.3", ("ham", "complete")),
|
||||
_P("1.25m-complete", "1.25 m Complete Band", "Amateur Radio",
|
||||
222_000_000, 225_000_000, 5_000, "auto", 0,
|
||||
"Weak signal at the bottom, FM above", ("ham", "complete")),
|
||||
_P("70cm-complete", "70 cm Complete Band", "Amateur Radio",
|
||||
420_000_000, 450_000_000, 6_250, "auto", 0,
|
||||
"ATV and weak signal low, FM repeaters above 440",
|
||||
("ham", "complete")),
|
||||
_P("33cm-complete", "33 cm Complete Band", "Amateur Radio",
|
||||
902_000_000, 928_000_000, 12_500, "auto", 0,
|
||||
"Shared with ISM devices throughout", ("ham", "complete")),
|
||||
|
||||
_P("all-cw", "All CW Segments", "Wide Sweeps",
|
||||
1_800_000, 432_100_000, 200, "cw", 500,
|
||||
"Every CW allocation from 160 m to 70 cm in one sweep; the HF part "
|
||||
"needs direct sampling and an HF antenna",
|
||||
("sweep", "cw", "ham"),
|
||||
members=("160m-cw", "80m-cw", "40m-cw", "30m", "20m-cw", "17m-cw",
|
||||
"15m-cw", "12m-cw", "10m-cw", "6m-cw", "2m-cw", "70cm-cw")),
|
||||
)
|
||||
|
||||
|
||||
CATEGORIES: tuple[str, ...] = tuple(
|
||||
dict.fromkeys(p.category for p in PRESETS)
|
||||
)
|
||||
|
||||
_BY_KEY = {p.key: p for p in PRESETS}
|
||||
|
||||
|
||||
def by_key(key: str) -> BandPreset | None:
|
||||
return _BY_KEY.get(key.strip().lower())
|
||||
|
||||
|
||||
def in_category(category: str) -> list[BandPreset]:
|
||||
cl = category.strip().lower()
|
||||
return [p for p in PRESETS if p.category.lower() == cl]
|
||||
|
||||
|
||||
def search(term: str) -> list[BandPreset]:
|
||||
"""Fuzzy-ish preset lookup over key, name, note and tags."""
|
||||
t = term.strip().lower()
|
||||
if not t:
|
||||
return []
|
||||
hits = []
|
||||
for p in PRESETS:
|
||||
hay = " ".join((p.key, p.name, p.note, p.category, " ".join(p.tags))).lower()
|
||||
if t in hay:
|
||||
hits.append(p)
|
||||
return hits
|
||||
|
||||
|
||||
def presets_covering(hz: float) -> list[BandPreset]:
|
||||
"""Every preset whose span contains ``hz`` -- used to label detections."""
|
||||
return [p for p in PRESETS if not p.is_group and p.start <= hz <= p.stop]
|
||||
|
||||
|
||||
def expand_preset(key: str) -> list[BandPreset]:
|
||||
"""The presets a key stands for, following groups to their members."""
|
||||
preset = by_key(key)
|
||||
return preset.expand() if preset is not None else []
|
||||
980
bandsaunter/classify.py
Executable file
980
bandsaunter/classify.py
Executable file
|
|
@ -0,0 +1,980 @@
|
|||
"""Modulation and signal-type classification.
|
||||
|
||||
The classifier works on a block of complex baseband that has already been
|
||||
centred on the signal and decimated to a rate a few times its bandwidth. It
|
||||
extracts a feature vector, scores it against a rule set for the common
|
||||
modulation families, then refines the answer with a table of known systems
|
||||
keyed on frequency, bandwidth and symbol rate.
|
||||
|
||||
Nothing here decodes traffic; it names what the signal *is*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
|
||||
from .dsp import (db, instantaneous_frequency,
|
||||
occupied_bandwidth, spectral_flatness, welch_psd)
|
||||
|
||||
__all__ = ["classify", "Classification", "SignalFeatures", "extract_features",
|
||||
"CTCSS_TONES", "detect_ctcss"]
|
||||
|
||||
|
||||
def _pow2_floor(n: int, cap: int = 1 << 16) -> int:
|
||||
"""Largest power of two that is <= n (and <= cap)."""
|
||||
n = int(min(n, cap))
|
||||
return 1 << int(math.floor(math.log2(max(2, n))))
|
||||
|
||||
|
||||
# EIA/TIA-603 standard CTCSS tones, Hz.
|
||||
CTCSS_TONES = (
|
||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3,
|
||||
131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9,
|
||||
171.3, 173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5,
|
||||
203.5, 206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1,
|
||||
)
|
||||
|
||||
# Symbol rates worth naming when the cyclostationary estimator lands near one.
|
||||
_KNOWN_BAUD = (
|
||||
(300, "300 baud"), (512, "POCSAG-512"), (1200, "1200 baud"),
|
||||
(1600, "FLEX-1600"), (2400, "2400 baud"), (3200, "FLEX-3200"),
|
||||
(4800, "4800 baud"), (6400, "FLEX-6400"), (9600, "9600 baud"),
|
||||
(18000, "TETRA"), (19200, "19.2 kbaud"), (36000, "36 kbaud"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalFeatures:
|
||||
"""Everything the rules and the report are allowed to look at."""
|
||||
|
||||
sample_rate: float
|
||||
n_samples: int
|
||||
duration: float
|
||||
|
||||
# spectrum
|
||||
bandwidth: float = 0.0 # span holding 99% of the power
|
||||
bw_noise: float = 0.0 # span standing above the noise floor
|
||||
am_depth: float = 0.0 # envelope modulation in the audio band
|
||||
bw3: float = 0.0
|
||||
bw20: float = 0.0
|
||||
centre_offset: float = 0.0
|
||||
flatness: float = 0.0
|
||||
papr_spectral: float = 0.0 # dB, peak bin over median bin
|
||||
carrier_ratio: float = 0.0 # fraction of power in the peak bin
|
||||
symmetry: float = 0.0 # -1 all lower sideband, +1 all upper
|
||||
snr_db: float = 0.0
|
||||
|
||||
# envelope
|
||||
env_cv: float = 0.0 # std/mean of |x|
|
||||
env_kurtosis: float = 0.0
|
||||
ook_contrast_db: float = 0.0
|
||||
ook_duty: float = 0.0
|
||||
keying_regularity: float = 0.0 # do on/off runs fit a symbol grid?
|
||||
is_bursty: bool = False
|
||||
burst_rate_hz: float = 0.0
|
||||
duty_cycle: float = 1.0
|
||||
|
||||
# frequency / phase
|
||||
fdev_rms: float = 0.0
|
||||
fdev_peak: float = 0.0
|
||||
fdev_ratio: float = 0.0 # fdev_rms / bandwidth: separates AM from SSB/FM
|
||||
ifreq_kurtosis: float = 0.0 # peaky (PSK) vs multimodal (FSK)
|
||||
level_dwell: float = 0.0 # fraction of time the tone sits still
|
||||
freq_modes: int = 0
|
||||
mode_spacing: float = 0.0
|
||||
psk_order: int = 0
|
||||
psk_strength: float = 0.0
|
||||
|
||||
# timing
|
||||
baud: float = 0.0
|
||||
baud_strength: float = 0.0
|
||||
baud_stability: float = 0.0 # does the symbol rate hold across the capture?
|
||||
|
||||
# audio-domain
|
||||
ctcss_hz: float = 0.0
|
||||
has_subaudible_data: bool = False
|
||||
stereo_pilot: bool = False
|
||||
afsk_1200: bool = False
|
||||
audio_peak_hz: float = 0.0
|
||||
|
||||
extras: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Classification:
|
||||
label: str
|
||||
family: str
|
||||
confidence: float
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
alternatives: list[tuple[str, float]] = field(default_factory=list)
|
||||
suggested_mode: str = "nfm"
|
||||
features: SignalFeatures | None = None
|
||||
|
||||
def summary(self) -> str:
|
||||
pct = int(round(self.confidence * 100))
|
||||
return f"{self.label} ({pct}%)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _otsu(values: np.ndarray, bins: int = 128) -> float:
|
||||
"""Otsu threshold -- splits an on/off envelope into its two populations."""
|
||||
hist, edges = np.histogram(values, bins=bins)
|
||||
hist = hist.astype(np.float64)
|
||||
total = hist.sum()
|
||||
if total == 0:
|
||||
return float(np.median(values))
|
||||
centres = 0.5 * (edges[1:] + edges[:-1])
|
||||
w0 = np.cumsum(hist)
|
||||
w1 = total - w0
|
||||
mu0 = np.cumsum(hist * centres) / np.maximum(w0, 1e-12)
|
||||
grand = (hist * centres).sum()
|
||||
mu1 = (grand - np.cumsum(hist * centres)) / np.maximum(w1, 1e-12)
|
||||
var_between = w0 * w1 * (mu0 - mu1) ** 2
|
||||
var_between[~np.isfinite(var_between)] = 0.0
|
||||
return float(centres[int(np.argmax(var_between))])
|
||||
|
||||
|
||||
def _cyclic_line(feature: np.ndarray, fs: float,
|
||||
lo_hz: float = 40.0, hi_hz: float | None = None):
|
||||
"""Find the strongest periodic line in a nonnegative feature signal.
|
||||
|
||||
Symbol transitions sit on a symbol-rate grid, so the transition-magnitude
|
||||
signal carries a spectral line at the baud rate. Returns
|
||||
``(frequency_hz, prominence_db)``.
|
||||
"""
|
||||
n = feature.size
|
||||
if n < 256:
|
||||
return 0.0, 0.0
|
||||
hi_hz = hi_hz or fs / 2.5
|
||||
x = feature.astype(np.float64)
|
||||
x = x - x.mean()
|
||||
if not np.any(x):
|
||||
return 0.0, 0.0
|
||||
nfft = _pow2_floor(n)
|
||||
x = x[:nfft] * np.hanning(nfft)
|
||||
spec = np.abs(np.fft.rfft(x, nfft))
|
||||
freqs = np.fft.rfftfreq(nfft, 1.0 / fs)
|
||||
|
||||
band = (freqs >= lo_hz) & (freqs <= hi_hz)
|
||||
if not np.any(band):
|
||||
return 0.0, 0.0
|
||||
sub = spec[band]
|
||||
subf = freqs[band]
|
||||
k = int(np.argmax(sub))
|
||||
peak = sub[k]
|
||||
med = np.median(sub) + 1e-12
|
||||
return float(subf[k]), float(20.0 * math.log10(peak / med))
|
||||
|
||||
|
||||
def _count_modes(values: np.ndarray, weights: np.ndarray | None = None,
|
||||
bins: int = 96):
|
||||
"""Histogram-based mode counting for FSK level detection."""
|
||||
if values.size < 64:
|
||||
return 0, 0.0, np.zeros(0)
|
||||
lo, hi = np.percentile(values, [1.0, 99.0])
|
||||
if hi <= lo:
|
||||
return 0, 0.0, np.zeros(0)
|
||||
hist, edges = np.histogram(values, bins=bins, range=(lo, hi), weights=weights)
|
||||
hist = hist.astype(np.float64)
|
||||
if hist.sum() == 0:
|
||||
return 0, 0.0, np.zeros(0)
|
||||
# Light smoothing so shot noise does not create spurious modes.
|
||||
kern = np.array([1.0, 3.0, 6.0, 8.0, 6.0, 3.0, 1.0])
|
||||
kern /= kern.sum()
|
||||
sm = np.convolve(hist, kern, mode="same")
|
||||
centres = 0.5 * (edges[1:] + edges[:-1])
|
||||
|
||||
peaks, props = sps.find_peaks(sm, height=0.22 * sm.max(),
|
||||
distance=max(3, bins // 16),
|
||||
prominence=0.15 * sm.max())
|
||||
if peaks.size == 0:
|
||||
return 0, 0.0, np.zeros(0)
|
||||
order = np.argsort(props["peak_heights"])[::-1][:8]
|
||||
sel = np.sort(peaks[order])
|
||||
locs = centres[sel]
|
||||
if locs.size < 2:
|
||||
return int(locs.size), 0.0, locs
|
||||
|
||||
diffs = np.diff(locs)
|
||||
spacing = float(np.median(diffs))
|
||||
# FSK levels are evenly spaced. If the peaks fit a uniform grid, report
|
||||
# the grid size instead of the raw peak count -- one spurious shoulder
|
||||
# should not turn 4-FSK into 5-FSK.
|
||||
if spacing > 0:
|
||||
grid = (locs - locs[0]) / spacing
|
||||
if np.max(np.abs(grid - np.round(grid))) < 0.28:
|
||||
n_levels = int(round(grid[-1])) + 1
|
||||
if 2 <= n_levels <= 8:
|
||||
return n_levels, spacing, locs
|
||||
return int(locs.size), spacing, locs
|
||||
|
||||
|
||||
def _psk_order(x: np.ndarray, fs: float):
|
||||
"""Detect M-PSK by looking for the spectral line produced by x**M."""
|
||||
if x.size < 1024:
|
||||
return 0, 0.0
|
||||
xn = x / (np.abs(x) + 1e-9) # constant-modulus, phase only
|
||||
best = (0, 0.0)
|
||||
for m in (2, 4, 8):
|
||||
y = xn ** m
|
||||
nfft = _pow2_floor(y.size, 1 << 15)
|
||||
spec = np.abs(np.fft.fftshift(np.fft.fft(y[:nfft] * np.hanning(nfft), nfft)))
|
||||
peak = spec.max()
|
||||
med = np.median(spec) + 1e-12
|
||||
strength = 20.0 * math.log10(peak / med)
|
||||
if strength > best[1]:
|
||||
best = (m, strength)
|
||||
return best
|
||||
|
||||
|
||||
def detect_ctcss(audio: np.ndarray, fs: float):
|
||||
"""Return ``(tone_hz, is_dcs_like)`` from a demodulated FM audio block."""
|
||||
if audio.size < int(fs * 0.25):
|
||||
return 0.0, False
|
||||
n = _pow2_floor(audio.size)
|
||||
x = audio[:n].astype(np.float64)
|
||||
x = x - x.mean()
|
||||
spec = np.abs(np.fft.rfft(x * np.hanning(n), n))
|
||||
freqs = np.fft.rfftfreq(n, 1.0 / fs)
|
||||
|
||||
sub = (freqs >= 60.0) & (freqs <= 260.0)
|
||||
voice = (freqs >= 300.0) & (freqs <= 3000.0)
|
||||
if not np.any(sub):
|
||||
return 0.0, False
|
||||
sub_spec = spec[sub]
|
||||
sub_f = freqs[sub]
|
||||
k = int(np.argmax(sub_spec))
|
||||
peak_f = float(sub_f[k])
|
||||
peak_v = float(sub_spec[k])
|
||||
floor = float(np.median(spec[voice])) + 1e-12 if np.any(voice) else 1e-12
|
||||
|
||||
if peak_v / floor < 6.0:
|
||||
return 0.0, False
|
||||
# A CTCSS tone is a single sharp line; DCS is a 134.4 bps square wave and
|
||||
# spreads its energy across the whole subaudible region.
|
||||
band_energy = float(np.sum(sub_spec ** 2))
|
||||
tone_energy = float(np.sum(sub_spec[max(0, k - 2):k + 3] ** 2))
|
||||
if tone_energy / max(band_energy, 1e-12) < 0.35:
|
||||
return 0.0, True
|
||||
|
||||
nearest = min(CTCSS_TONES, key=lambda t: abs(t - peak_f))
|
||||
if abs(nearest - peak_f) <= max(1.5, 0.02 * nearest):
|
||||
return float(nearest), False
|
||||
return 0.0, False
|
||||
|
||||
|
||||
def _center_and_filter(x: np.ndarray, sample_rate: float, offset_hz: float,
|
||||
bw_hz: float, wide_bw_hz: float = 0.0) -> np.ndarray:
|
||||
"""Shift the signal to DC and low-pass it to its own occupied bandwidth.
|
||||
|
||||
Time-domain features (envelope, discriminator, phase) are meaningless when
|
||||
they are dominated by noise from the rest of the IF, so every measurement
|
||||
after the spectrum step runs on this filtered copy.
|
||||
"""
|
||||
# Be generous: a carrier-dominated signal (AM) has a small 99%-power
|
||||
# bandwidth but sidebands well outside it, so take the wider of the two
|
||||
# measures and leave headroom on top.
|
||||
keep = max(bw_hz, wide_bw_hz) * 1.8
|
||||
keep = max(keep, sample_rate / 200.0)
|
||||
if keep >= sample_rate * 0.9 and abs(offset_hz) < sample_rate / 50.0:
|
||||
return x # already occupies most of the band; filtering buys nothing
|
||||
|
||||
if abs(offset_hz) > sample_rate / 1000.0:
|
||||
n = np.arange(x.size, dtype=np.float64)
|
||||
x = (x * np.exp(-2j * math.pi * offset_hz * n / sample_rate)).astype(np.complex64)
|
||||
|
||||
# firwin's cutoff is in units of Nyquist; we want +-keep/2 around DC.
|
||||
norm = (keep / 2.0) / (sample_rate / 2.0)
|
||||
if norm >= 0.95:
|
||||
return x
|
||||
ntaps = 127
|
||||
taps = sps.firwin(ntaps, norm).astype(np.float64)
|
||||
y = sps.lfilter(taps, [1.0], x).astype(np.complex64)
|
||||
# Drop the filter's start-up ramp: it looks exactly like a signal fading
|
||||
# in, which would otherwise register as on-off keying.
|
||||
return y[ntaps:] if y.size > 4 * ntaps else y
|
||||
|
||||
|
||||
def _robust_floor(psd_db: np.ndarray) -> tuple[float, float]:
|
||||
"""Noise floor and spread of one spectrum, by sigma clipping.
|
||||
|
||||
Repeatedly drops the bins that stand out until only the noise population
|
||||
is left. A sliding percentile is the right tool while sweeping, where
|
||||
signals are narrow slivers of a wide span, but not here: by this point
|
||||
the capture is centred on one signal that may fill most of the analysis
|
||||
band, and a windowed percentile would sit on the signal itself.
|
||||
"""
|
||||
v = np.asarray(psd_db, dtype=np.float64)
|
||||
if v.size < 8:
|
||||
return float(np.median(v)), 1.0
|
||||
mask = np.ones(v.size, dtype=bool)
|
||||
floor = float(np.median(v))
|
||||
sigma = 1.0
|
||||
for _ in range(6):
|
||||
sel = v[mask]
|
||||
if sel.size < max(8, v.size // 10):
|
||||
break
|
||||
floor = float(np.median(sel))
|
||||
sigma = float(1.4826 * np.median(np.abs(sel - floor))) or 1.0
|
||||
new_mask = v < floor + max(3.0 * sigma, 3.0)
|
||||
if new_mask.sum() < max(8, v.size // 10):
|
||||
break
|
||||
if np.array_equal(new_mask, mask):
|
||||
break
|
||||
mask = new_mask
|
||||
return floor, max(sigma, 0.3)
|
||||
|
||||
|
||||
def _occupied_span(psd_db: np.ndarray, bin_hz: float,
|
||||
margin_db: float = 0.0) -> tuple[float, float]:
|
||||
"""Occupied bandwidth as an analyst reads it off a spectrum display.
|
||||
|
||||
Returns ``(bandwidth_hz, centre_offset_hz)`` for the contiguous run of
|
||||
bins standing above the noise floor around the strongest peak.
|
||||
|
||||
A 99%-of-power measure cannot be used here: AM puts almost all of its
|
||||
power in the carrier, so 99% of the power lives in a single bin and the
|
||||
channel would be reported as tens of hertz wide.
|
||||
"""
|
||||
n = psd_db.size
|
||||
if n < 8:
|
||||
return bin_hz, 0.0
|
||||
floor, sigma = _robust_floor(psd_db)
|
||||
threshold = floor + (margin_db or max(6.0, 4.0 * sigma))
|
||||
above = psd_db > threshold
|
||||
if not np.any(above):
|
||||
return bin_hz, 0.0
|
||||
|
||||
idx = np.flatnonzero(above)
|
||||
# 2nd-to-98th percentile of the *positions* that stand above the noise.
|
||||
# Counting positions rather than weighting them by power matters: a
|
||||
# carrier holds so much more power than its sidebands that a weighted
|
||||
# measure collapses onto the carrier bin and reports a few hertz.
|
||||
lo = int(np.percentile(idx, 2))
|
||||
hi = int(np.percentile(idx, 98))
|
||||
if hi < lo:
|
||||
lo, hi = hi, lo
|
||||
w = np.maximum(psd_db[idx] - floor, 1e-9)
|
||||
centroid = float(np.dot(idx.astype(np.float64), w) / w.sum())
|
||||
offset = (centroid - (n - 1) / 2.0) * bin_hz
|
||||
return max(bin_hz, float((hi - lo + 1) * bin_hz)), offset
|
||||
|
||||
|
||||
def _run_spans(on: np.ndarray):
|
||||
"""Yield ``(state, start, length)`` for each constant run of a mask."""
|
||||
if on.size == 0:
|
||||
return
|
||||
change = np.flatnonzero(np.diff(on.astype(np.int8)))
|
||||
bounds = np.concatenate(([0], change + 1, [on.size]))
|
||||
for i in range(bounds.size - 1):
|
||||
yield bool(on[bounds[i]]), int(bounds[i]), int(bounds[i + 1] - bounds[i])
|
||||
|
||||
|
||||
def _longest_active_run(on: np.ndarray) -> tuple[int, int]:
|
||||
"""Start and length of the longest contiguous key-down / active stretch."""
|
||||
best = (0, 0)
|
||||
for state, start, length in _run_spans(on):
|
||||
if state and length > best[1]:
|
||||
best = (start, length)
|
||||
return best
|
||||
|
||||
|
||||
def extract_features(x: np.ndarray, sample_rate: float,
|
||||
snr_db: float = 0.0) -> SignalFeatures:
|
||||
"""Compute the full feature vector for one captured block."""
|
||||
x = np.asarray(x, dtype=np.complex64)
|
||||
n = x.size
|
||||
f = SignalFeatures(sample_rate=float(sample_rate), n_samples=n,
|
||||
duration=n / float(sample_rate), snr_db=float(snr_db))
|
||||
if n < 512:
|
||||
return f
|
||||
|
||||
# ---- keying structure, measured across the whole capture ----------
|
||||
env_full = np.abs(x).astype(np.float64)
|
||||
smooth_n = max(4, int(sample_rate / 4000.0))
|
||||
env_s = np.convolve(env_full, np.ones(smooth_n) / smooth_n, mode="same")
|
||||
thr = _otsu(env_s)
|
||||
on = env_s > thr
|
||||
f.ook_duty = float(on.mean())
|
||||
f.duty_cycle = f.ook_duty
|
||||
if np.any(on) and np.any(~on):
|
||||
hi = float(np.mean(env_s[on]))
|
||||
lo = float(np.mean(env_s[~on])) + 1e-12
|
||||
f.ook_contrast_db = float(20.0 * math.log10(hi / lo))
|
||||
|
||||
# Do the on and off runs land on a common grid? Keyed data and Morse
|
||||
# both quantise to a symbol or dot length; a signal drifting across the
|
||||
# squelch threshold produces runs of every length, which is what tells
|
||||
# real keying apart from a fading carrier.
|
||||
spans = [ln for _, _, ln in _run_spans(on)]
|
||||
if len(spans) >= 6:
|
||||
arr = np.array(spans[1:-1] if len(spans) > 8 else spans, dtype=np.float64)
|
||||
unit = float(np.percentile(arr, 20))
|
||||
# The unit has to be resolvable. When the envelope merely jitters
|
||||
# across the threshold the runs are one or two samples long, and every
|
||||
# length is then trivially an "integer multiple" of one sample -- a
|
||||
# test that noise passes perfectly.
|
||||
if unit >= 8.0:
|
||||
ratios = arr / unit
|
||||
usable = ratios[ratios <= 12.0]
|
||||
if usable.size >= 4:
|
||||
f.keying_regularity = float(
|
||||
np.mean(np.abs(usable - np.round(usable)) < 0.2))
|
||||
|
||||
# Burst / TDMA structure: how often does the envelope gate on and off?
|
||||
if 0.02 < f.ook_duty < 0.98:
|
||||
edges = np.diff(on.astype(np.int8))
|
||||
rises = np.where(edges > 0)[0]
|
||||
if rises.size >= 3:
|
||||
periods = np.diff(rises) / sample_rate
|
||||
med = float(np.median(periods))
|
||||
if med > 0:
|
||||
f.burst_rate_hz = 1.0 / med
|
||||
f.is_bursty = bool(np.std(periods) / med < 0.5)
|
||||
|
||||
# ---- pick the stretch to characterise ------------------------------
|
||||
# Speech has pauses, and on SSB the carrier disappears with them. Judging
|
||||
# modulation across the silence would describe the silence: an AM voice
|
||||
# channel reads as a bare carrier, SSB voice reads as on-off keying. So
|
||||
# measure the longest continuously-active stretch instead -- provided it
|
||||
# is long enough to be a transmission rather than a data symbol.
|
||||
seg = x
|
||||
if 0.05 < f.ook_duty < 0.92:
|
||||
s_start, s_len = _longest_active_run(on)
|
||||
if s_len >= max(int(0.3 * sample_rate), 4096) and s_len < int(0.92 * n):
|
||||
seg = x[s_start:s_start + s_len]
|
||||
f.extras["analysed_seconds"] = round(s_len / sample_rate, 3)
|
||||
f.extras["analysed_fraction"] = round(s_len / n, 3)
|
||||
|
||||
# ---- spectrum ------------------------------------------------------
|
||||
nfft = min(4096, 1 << int(math.floor(math.log2(seg.size))))
|
||||
freqs, psd = welch_psd(seg, nfft)
|
||||
bin_hz = sample_rate / nfft
|
||||
psd_db = db(psd)
|
||||
|
||||
# Primary bandwidth is the span holding 99% of the power. The span
|
||||
# standing above the noise is kept alongside it because the two disagree
|
||||
# in a useful way: AM puts nearly all its power in the carrier, so a large
|
||||
# gap between them is itself evidence of a carrier-dominated signal.
|
||||
f.bandwidth, f.centre_offset = occupied_bandwidth(psd, bin_hz, 0.99)
|
||||
f.bw_noise, _ = _occupied_span(psd_db, bin_hz)
|
||||
total = psd.sum()
|
||||
peak_lin = psd.max()
|
||||
f.carrier_ratio = float(peak_lin / total) if total > 0 else 0.0
|
||||
f.papr_spectral = float(psd_db.max() - np.median(psd_db))
|
||||
f.flatness = spectral_flatness(psd)
|
||||
|
||||
peak_db = psd_db.max()
|
||||
for lvl, attr in ((3.0, "bw3"), (20.0, "bw20")):
|
||||
above = psd_db >= (peak_db - lvl)
|
||||
if np.any(above):
|
||||
idx = np.where(above)[0]
|
||||
setattr(f, attr, float((idx[-1] - idx[0] + 1) * bin_hz))
|
||||
|
||||
# Sideband asymmetry measured about the strongest bin and only across the
|
||||
# occupied band -- comparing the two halves of the whole IF just measures
|
||||
# where the noise sits.
|
||||
pk = int(np.argmax(psd))
|
||||
span = max(2, int(f.bandwidth / bin_hz))
|
||||
lo_i, hi_i = max(0, pk - span), min(nfft, pk + span + 1)
|
||||
lower = float(psd[lo_i:pk].sum())
|
||||
upper = float(psd[pk + 1:hi_i].sum())
|
||||
if lower + upper > 0:
|
||||
f.symmetry = (upper - lower) / (upper + lower)
|
||||
|
||||
# Every time-domain measurement below runs on the signal alone.
|
||||
# Size the analysis filter from the widest honest estimate. Using the
|
||||
# 99%-power figure alone would band-limit an AM channel to its carrier and
|
||||
# every later measurement would describe a dead carrier.
|
||||
keep_bw = max(f.bandwidth, f.bw_noise, f.bw20)
|
||||
xf = _center_and_filter(seg, sample_rate, f.centre_offset, keep_bw, f.bw20)
|
||||
f.extras["_filtered"] = xf
|
||||
seg = xf
|
||||
|
||||
# ---- envelope -----------------------------------------------------
|
||||
env = np.abs(seg).astype(np.float64)
|
||||
mean_env = float(env.mean())
|
||||
if mean_env > 0:
|
||||
f.env_cv = float(env.std() / mean_env)
|
||||
centred = env - mean_env
|
||||
var = float(centred.var())
|
||||
if var > 0:
|
||||
f.env_kurtosis = float(np.mean(centred ** 4) / (var ** 2) - 3.0)
|
||||
|
||||
# Does the envelope carry audio? A bare carrier's envelope is flat, an
|
||||
# FM carrier's is flat by construction, and an AM or SSB voice channel's
|
||||
# envelope *is* the speech. This is what tells a modulated AM channel
|
||||
# apart from a dead carrier, which the 99%-power bandwidth cannot do
|
||||
# because the carrier holds nearly all the power either way.
|
||||
if mean_env > 0 and env.size > 1024:
|
||||
ac = env - mean_env
|
||||
dec = max(1, int(sample_rate / 16_000))
|
||||
a = ac[::dec]
|
||||
fs_a = sample_rate / dec
|
||||
n_a = 1 << int(math.floor(math.log2(max(256, min(a.size, 1 << 15)))))
|
||||
if a.size >= n_a:
|
||||
spec = np.abs(np.fft.rfft(a[:n_a] * np.hanning(n_a), n_a)) ** 2
|
||||
fr = np.fft.rfftfreq(n_a, 1.0 / fs_a)
|
||||
band = (fr >= 100.0) & (fr <= min(4000.0, fs_a / 2.2))
|
||||
if np.any(band):
|
||||
f.am_depth = float(np.sqrt(np.sum(spec[band])) /
|
||||
(mean_env * n_a / 2.0))
|
||||
|
||||
# ---- instantaneous frequency --------------------------------------
|
||||
ifreq = instantaneous_frequency(seg, sample_rate)
|
||||
if ifreq.size:
|
||||
w = env[1:]
|
||||
strong = w > (0.5 * mean_env) if mean_env > 0 else np.ones_like(w, bool)
|
||||
sel = ifreq[strong] if strong.sum() > 64 else ifreq
|
||||
f.fdev_rms = float(np.std(sel))
|
||||
f.fdev_peak = float(np.percentile(np.abs(sel - np.mean(sel)), 99.0))
|
||||
f.fdev_ratio = f.fdev_rms / max(f.bandwidth, 1.0)
|
||||
centred = sel - np.mean(sel)
|
||||
var = float(centred.var())
|
||||
if var > 0:
|
||||
# Negative => the discriminator sits on discrete levels (FSK);
|
||||
# strongly positive => it is flat with impulses (PSK phase jumps).
|
||||
f.ifreq_kurtosis = float(np.mean(centred ** 4) / (var ** 2) - 3.0)
|
||||
|
||||
# How much of the time does the tone hold still? FSK parks on a level
|
||||
# for a whole symbol and jumps between them, so its slope is zero
|
||||
# almost everywhere. Tone-modulated or voice FM sweeps continuously
|
||||
# and is never flat for long. Without this, the two peaks that
|
||||
# sinusoidal FM puts at the ends of its swing (a CTCSS tone during a
|
||||
# speech pause, say) read as a pair of FSK levels.
|
||||
slope = np.diff(sel)
|
||||
if slope.size > 64:
|
||||
scale = float(np.std(slope))
|
||||
if scale > 0:
|
||||
f.level_dwell = float(np.mean(np.abs(slope) < 0.25 * scale))
|
||||
modes, spacing, _ = _count_modes(sel, weights=None)
|
||||
f.freq_modes = modes
|
||||
f.mode_spacing = spacing
|
||||
|
||||
# ---- phase --------------------------------------------------------
|
||||
order, strength = _psk_order(seg, sample_rate)
|
||||
f.psk_order, f.psk_strength = order, strength
|
||||
|
||||
# ---- symbol rate ---------------------------------------------------
|
||||
# For FSK the transition magnitude of the discriminator carries the line;
|
||||
# for linear modulations the squared envelope does.
|
||||
cand = []
|
||||
if ifreq.size > 512:
|
||||
cand.append((_cyclic_line(np.abs(np.diff(ifreq)), sample_rate),
|
||||
np.abs(np.diff(ifreq))))
|
||||
cand.append((_cyclic_line(np.abs(np.diff(env)), sample_rate),
|
||||
np.abs(np.diff(env))))
|
||||
cand.append((_cyclic_line(env ** 2, sample_rate), env ** 2))
|
||||
(baud, strength), winner = max(cand, key=lambda c: c[0][1])
|
||||
if strength > 8.0:
|
||||
f.baud, f.baud_strength = baud, strength
|
||||
# A real symbol rate is a property of the transmission and holds for
|
||||
# its whole length. The estimator always returns its best peak, so
|
||||
# on noise it returns a different answer for each half of the same
|
||||
# capture -- which is exactly how to tell the two apart.
|
||||
half = winner.size // 2
|
||||
if half > 1024:
|
||||
b1, _ = _cyclic_line(winner[:half], sample_rate)
|
||||
b2, _ = _cyclic_line(winner[half:], sample_rate)
|
||||
if b1 > 0 and b2 > 0:
|
||||
f.baud_stability = float(
|
||||
1.0 - abs(b1 - b2) / max(b1, b2))
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _analyse_fm_audio(x: np.ndarray, sample_rate: float, f: SignalFeatures) -> None:
|
||||
"""Fill in the audio-domain features that need an FM demodulation."""
|
||||
ifreq = instantaneous_frequency(x, sample_rate)
|
||||
if ifreq.size < 1024:
|
||||
return
|
||||
|
||||
# 19 kHz stereo pilot -> broadcast FM.
|
||||
if sample_rate > 60_000:
|
||||
n = _pow2_floor(ifreq.size)
|
||||
spec = np.abs(np.fft.rfft((ifreq[:n] - ifreq[:n].mean()) * np.hanning(n), n))
|
||||
freqs = np.fft.rfftfreq(n, 1.0 / sample_rate)
|
||||
near = (freqs > 18_800) & (freqs < 19_200)
|
||||
ref = (freqs > 22_000) & (freqs < 30_000)
|
||||
if np.any(near) and np.any(ref):
|
||||
f.stereo_pilot = bool(spec[near].max() > 8.0 * (np.median(spec[ref]) + 1e-12))
|
||||
|
||||
# Decimate the discriminator output to a voice rate for tone work.
|
||||
dec = max(1, int(sample_rate // 16_000))
|
||||
audio = sps.decimate(ifreq, dec, ftype="fir", zero_phase=False) if dec > 1 else ifreq
|
||||
fs_a = sample_rate / dec
|
||||
|
||||
tone, dcs = detect_ctcss(audio, fs_a)
|
||||
f.ctcss_hz = tone
|
||||
f.has_subaudible_data = dcs
|
||||
|
||||
n = _pow2_floor(audio.size, 1 << 15)
|
||||
if n >= 1024:
|
||||
a = audio[:n].astype(np.float64)
|
||||
a -= a.mean()
|
||||
spec = np.abs(np.fft.rfft(a * np.hanning(n), n))
|
||||
freqs = np.fft.rfftfreq(n, 1.0 / fs_a)
|
||||
band = (freqs > 250.0) & (freqs < 3500.0)
|
||||
if np.any(band):
|
||||
sb, sf = spec[band], freqs[band]
|
||||
f.audio_peak_hz = float(sf[int(np.argmax(sb))])
|
||||
# AFSK1200 (APRS, Bell 202) sits on 1200 Hz and 2200 Hz marks.
|
||||
def energy(target, width=90.0):
|
||||
m = (sf > target - width) & (sf < target + width)
|
||||
return float(sb[m].max()) if np.any(m) else 0.0
|
||||
base = float(np.median(sb)) + 1e-12
|
||||
e12, e22 = energy(1200.0), energy(2200.0)
|
||||
f.afsk_1200 = bool(e12 > 5 * base and e22 > 5 * base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _score_rules(f: SignalFeatures) -> list[tuple[str, str, float, str, str]]:
|
||||
"""Return ``(label, family, score, reason, suggested_mode)`` candidates."""
|
||||
out = []
|
||||
|
||||
def add(label, family, score, reason, mode="nfm"):
|
||||
if score > 0:
|
||||
out.append((label, family, float(score), reason, mode))
|
||||
|
||||
const_env = f.env_cv < 0.35
|
||||
narrow = f.bandwidth < 30_000
|
||||
very_narrow = f.bandwidth < 1_500
|
||||
# A dead carrier occupies essentially no bandwidth, whatever the noise does
|
||||
# to its measured phase jitter.
|
||||
tone_bw = max(60.0, f.sample_rate / 400.0)
|
||||
|
||||
# -- unmodulated carrier / tone -------------------------------------
|
||||
if f.bandwidth < tone_bw and f.env_cv < 0.20 \
|
||||
and (f.ook_contrast_db < 6 or f.ook_duty > 0.93):
|
||||
add("Unmodulated carrier", "carrier",
|
||||
0.72 + min(0.2, f.papr_spectral / 200.0),
|
||||
f"single steady line only {f.bandwidth:.0f} Hz wide, no keying or "
|
||||
f"modulation sidebands", "cw")
|
||||
|
||||
# -- CW / on-off keying ---------------------------------------------
|
||||
if f.ook_contrast_db > 10 and very_narrow and 0.05 < f.ook_duty < 0.85:
|
||||
base = 0.55 + min(0.3, f.ook_contrast_db / 60.0)
|
||||
if f.baud and f.baud < 60:
|
||||
base += 0.1
|
||||
add("CW / Morse (on-off keyed carrier)", "cw", base,
|
||||
f"keyed carrier, {f.ook_contrast_db:.0f} dB on/off contrast, "
|
||||
f"{f.bandwidth:.0f} Hz wide", "cw")
|
||||
elif f.ook_contrast_db > 12 and f.bandwidth < 60_000 and 0.02 < f.ook_duty < 0.9:
|
||||
add("OOK / ASK data burst", "ook",
|
||||
0.5 + min(0.25, f.ook_contrast_db / 80.0),
|
||||
f"on-off keying, {f.ook_contrast_db:.0f} dB contrast, "
|
||||
f"{f.baud:.0f} baud" if f.baud else "on-off keying", "raw")
|
||||
|
||||
# -- FSK families ----------------------------------------------------
|
||||
# Discrete levels, and the tone actually rests on them.
|
||||
level_like = f.ifreq_kurtosis < 1.5 and f.level_dwell > 0.32
|
||||
if const_env and f.freq_modes >= 2 and f.fdev_rms > 200 and level_like:
|
||||
levels = f.freq_modes
|
||||
if levels in (2, 3):
|
||||
name, conf = "2-FSK (binary FSK)", 0.6
|
||||
elif levels == 4:
|
||||
name, conf = "4-FSK / C4FM", 0.68
|
||||
elif levels in (5, 6, 7, 8):
|
||||
name, conf = f"{levels}-level FSK", 0.5
|
||||
else:
|
||||
name, conf = "Multi-level FSK", 0.4
|
||||
if f.baud_strength > 12:
|
||||
conf += 0.12
|
||||
reason = (f"constant envelope, {levels} discriminator levels "
|
||||
f"{f.mode_spacing:.0f} Hz apart")
|
||||
if f.baud:
|
||||
reason += f", ~{f.baud:.0f} baud"
|
||||
add(name, "fsk", conf, reason, "nfm")
|
||||
|
||||
# -- analogue FM -----------------------------------------------------
|
||||
# A quiet FM channel carrying only a CTCSS tone has very little deviation,
|
||||
# so this floor has to sit low.
|
||||
if const_env and f.fdev_rms > 120 and not level_like:
|
||||
if f.bandwidth > 100_000:
|
||||
conf = 0.72 + (0.15 if f.stereo_pilot else 0.0)
|
||||
label = "Wideband FM (broadcast)"
|
||||
if f.stereo_pilot:
|
||||
label = "Wideband FM broadcast (stereo, 19 kHz pilot)"
|
||||
add(label, "wfm", conf,
|
||||
f"{f.bandwidth/1e3:.0f} kHz wide, {f.fdev_rms/1e3:.1f} kHz rms deviation",
|
||||
"wfm")
|
||||
elif narrow:
|
||||
conf = 0.6
|
||||
if f.ctcss_hz:
|
||||
conf += 0.2
|
||||
if 300 < f.audio_peak_hz < 3200:
|
||||
conf += 0.08
|
||||
label = "Narrowband FM voice"
|
||||
if f.ctcss_hz:
|
||||
label += f" (CTCSS {f.ctcss_hz:.1f} Hz)"
|
||||
elif f.has_subaudible_data:
|
||||
label += " (DCS subaudible data)"
|
||||
add(label, "nfm", conf,
|
||||
f"{f.bandwidth/1e3:.1f} kHz wide, {f.fdev_rms/1e3:.1f} kHz rms deviation",
|
||||
"nfm")
|
||||
|
||||
# -- AM ---------------------------------------------------------------
|
||||
# AM keeps its carrier, so the phase hardly moves and the sidebands are
|
||||
# mirror images. That is exactly what separates it from SSB.
|
||||
# Real AM often runs at modest modulation depth, so the envelope only has
|
||||
# to vary more than receiver noise alone would make it vary.
|
||||
snr_lin = 10.0 ** (max(f.snr_db, 0.0) / 10.0)
|
||||
env_noise = 1.0 / math.sqrt(max(2.0, snr_lin))
|
||||
am_modulated = f.env_cv > max(0.045, 2.0 * env_noise)
|
||||
if (am_modulated and narrow and f.bandwidth > 4 * tone_bw
|
||||
and f.carrier_ratio > 0.08
|
||||
and f.fdev_ratio < 0.18 and abs(f.symmetry) < 0.60):
|
||||
conf = 0.55 + min(0.2, 2.0 * f.env_cv) - 0.2 * abs(f.symmetry)
|
||||
add("AM (amplitude modulation)", "am", conf,
|
||||
f"symmetric sidebands around a surviving carrier, "
|
||||
f"{f.env_cv:.2f} envelope variation, {f.bandwidth/1e3:.1f} kHz wide", "am")
|
||||
|
||||
# -- SSB ---------------------------------------------------------------
|
||||
if (300 < f.bandwidth < 6_500 and f.env_cv > 0.35 and f.fdev_ratio > 0.15
|
||||
and f.ook_contrast_db < 14):
|
||||
# Which sideband cannot be recovered once we have centred on the
|
||||
# signal, so fall back on the HF convention (LSB below 10 MHz).
|
||||
conf = 0.52 + min(0.2, f.fdev_ratio) + min(0.15, abs(f.symmetry) * 0.2)
|
||||
add("SSB voice (suppressed carrier)", "ssb", conf,
|
||||
f"no carrier line, phase swings across the full {f.bandwidth:.0f} Hz "
|
||||
f"of audio bandwidth", "usb")
|
||||
|
||||
# -- PSK ---------------------------------------------------------------
|
||||
impulsive_phase = f.ifreq_kurtosis > 3.0
|
||||
fsk_like = f.freq_modes >= 2 and f.mode_spacing > 0 and level_like
|
||||
# Raising to the Mth power must *create* the spectral line. An
|
||||
# unmodulated carrier -- including the silent gaps between phrases on an
|
||||
# FM channel -- already has a line at every power, and would otherwise
|
||||
# look like textbook PSK.
|
||||
psk_line_created = f.psk_strength > f.papr_spectral + 8.0
|
||||
if const_env and f.psk_strength > 20 and f.psk_order and impulsive_phase \
|
||||
and psk_line_created and f.carrier_ratio < 0.10 \
|
||||
and not fsk_like and f.bandwidth > tone_bw:
|
||||
m = f.psk_order
|
||||
name = {2: "BPSK", 4: "QPSK / pi-4 DQPSK", 8: "8-PSK"}.get(m, f"{m}-PSK")
|
||||
conf = 0.5 + min(0.25, (f.psk_strength - 20) / 60.0)
|
||||
reason = f"x^{m} produces a spectral line ({f.psk_strength:.0f} dB)"
|
||||
if f.baud:
|
||||
reason += f", ~{f.baud:.0f} baud"
|
||||
add(name, "psk", conf, reason, "raw")
|
||||
|
||||
# -- wideband digital / noise-like ------------------------------------
|
||||
if f.flatness > 0.55 and f.bandwidth > 200_000 and f.carrier_ratio < 0.05:
|
||||
add("Wideband digital carrier (OFDM/CDMA-like)", "digital",
|
||||
0.5 + min(0.25, f.flatness - 0.55),
|
||||
f"flat, noise-like spectrum {f.bandwidth/1e6:.2f} MHz wide", "raw")
|
||||
|
||||
# -- pulsed / radar-like ----------------------------------------------
|
||||
if f.ook_duty < 0.05 and f.ook_contrast_db > 15 and f.bandwidth > 200_000:
|
||||
add("Pulsed transmission (radar / DME / Mode S-like)", "pulse",
|
||||
0.5 + min(0.2, f.ook_contrast_db / 100.0),
|
||||
f"{f.ook_duty*100:.1f}% duty cycle, wide pulses", "raw")
|
||||
|
||||
if not out:
|
||||
add("Unidentified signal", "unknown", 0.2,
|
||||
f"{f.bandwidth/1e3:.1f} kHz wide, SNR {f.snr_db:.0f} dB", "nfm")
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known-system refinement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _baud_near(f: SignalFeatures, target: float, tol: float = 0.12) -> bool:
|
||||
return bool(f.baud) and abs(f.baud - target) <= tol * target
|
||||
|
||||
|
||||
def _identify_system(freq_hz: float, f: SignalFeatures,
|
||||
best_family: str) -> tuple[str, float, str] | None:
|
||||
"""Name a specific system when frequency + shape + baud all agree."""
|
||||
mhz = freq_hz / 1e6
|
||||
|
||||
def within(lo, hi):
|
||||
return lo <= mhz <= hi
|
||||
|
||||
# Aviation surveillance
|
||||
if within(1089, 1091) and f.ook_duty < 0.2:
|
||||
return ("ADS-B / Mode S (1090 MHz extended squitter)", 0.9,
|
||||
"1090 MHz, pulse-position keyed bursts")
|
||||
if within(977, 979):
|
||||
return ("UAT ADS-B / FIS-B (978 MHz)", 0.85, "978 MHz UAT channel")
|
||||
if within(960, 1215) and f.ook_duty < 0.1:
|
||||
return ("DME / TACAN pulse pairs", 0.7, "pulsed navigation band")
|
||||
|
||||
# Broadcast
|
||||
if within(87.9, 108.1) and best_family in ("wfm", "fsk", "nfm"):
|
||||
label = "FM broadcast station"
|
||||
if f.stereo_pilot:
|
||||
label += " (stereo)"
|
||||
return (label, 0.9, "FM broadcast band, wideband FM")
|
||||
if within(162.39, 162.56) and best_family == "nfm":
|
||||
return ("NOAA Weather Radio", 0.9, "NWR channel, narrowband FM")
|
||||
if within(136.9, 138.1) and f.bandwidth > 25_000:
|
||||
return ("Weather satellite downlink (NOAA APT / Meteor)", 0.7,
|
||||
"137 MHz satellite band")
|
||||
|
||||
# Marine / maritime
|
||||
if within(161.96, 162.04) and _baud_near(f, 9600, 0.2):
|
||||
return ("AIS ship transponder (9600 GMSK)", 0.88,
|
||||
"AIS channel A/B, 9600 baud GMSK")
|
||||
if within(156.0, 162.1) and best_family == "nfm":
|
||||
ch = _marine_channel(freq_hz)
|
||||
return (f"Marine VHF voice{ch}", 0.75, "marine VHF band, narrowband FM")
|
||||
|
||||
# Aviation voice / data
|
||||
if within(118.0, 137.0):
|
||||
if within(129.0, 137.0) and _baud_near(f, 2400, 0.2) and f.ook_duty < 0.6:
|
||||
return ("ACARS datalink (2400 baud MSK)", 0.85,
|
||||
"ACARS band, 2400 baud bursts")
|
||||
if best_family == "am":
|
||||
return ("VHF airband voice (AM)", 0.85, "118-137 MHz airband, AM")
|
||||
if within(225.0, 400.0) and best_family == "am":
|
||||
return ("Military UHF air voice (AM)", 0.7, "225-400 MHz UHF air band")
|
||||
|
||||
# Amateur
|
||||
if within(144.38, 144.40) and (f.afsk_1200 or _baud_near(f, 1200, 0.2)):
|
||||
return ("APRS packet (AFSK 1200 baud)", 0.88, "144.390 MHz APRS channel")
|
||||
ham_hf = any(lo <= mhz <= hi for lo, hi in (
|
||||
(1.8, 2.0), (3.5, 4.0), (5.33, 5.41), (7.0, 7.3), (10.1, 10.15),
|
||||
(14.0, 14.35), (18.068, 18.168), (21.0, 21.45), (24.89, 24.99),
|
||||
(28.0, 29.7)))
|
||||
if ham_hf and best_family == "ssb":
|
||||
return ("Amateur HF SSB voice", 0.75, "inside a US amateur HF phone band")
|
||||
if ham_hf and best_family == "cw":
|
||||
return ("Amateur HF CW (Morse)", 0.8, "inside a US amateur HF CW segment")
|
||||
if within(26.965, 27.405) and best_family in ("am", "ssb"):
|
||||
return ("CB radio (Citizens Band)", 0.75, "11 m CB channel")
|
||||
if within(144.0, 148.0) and best_family == "nfm":
|
||||
return ("2 m amateur FM", 0.7, "2 m band, narrowband FM")
|
||||
if within(420.0, 450.0) and best_family == "nfm":
|
||||
return ("70 cm amateur FM", 0.7, "70 cm band, narrowband FM")
|
||||
|
||||
# Land mobile digital voice
|
||||
if best_family == "fsk" and f.freq_modes == 4:
|
||||
if _baud_near(f, 4800) and 8_000 < f.bandwidth < 16_000:
|
||||
if f.is_bursty and 25.0 < f.burst_rate_hz < 45.0:
|
||||
return ("DMR digital voice (TDMA, 4800 baud C4FM)", 0.8,
|
||||
"12.5 kHz 4-FSK with ~30 ms TDMA bursts")
|
||||
return ("P25 Phase 1 C4FM digital voice (4800 baud)", 0.75,
|
||||
"12.5 kHz 4-level FSK at 4800 baud")
|
||||
if _baud_near(f, 2400) and f.bandwidth < 8_000:
|
||||
return ("NXDN digital voice (2400 baud, 6.25 kHz)", 0.72,
|
||||
"6.25 kHz 4-FSK at 2400 baud")
|
||||
if best_family == "fsk" and f.freq_modes <= 3:
|
||||
if _baud_near(f, 4800) and f.bandwidth < 8_000:
|
||||
return ("D-STAR digital voice (4800 baud GMSK)", 0.65,
|
||||
"6.25 kHz GMSK at 4800 baud")
|
||||
for baud, name in ((512, "POCSAG 512"), (1200, "POCSAG 1200"),
|
||||
(2400, "POCSAG 2400")):
|
||||
if _baud_near(f, baud) and (within(929, 932) or within(150, 160)):
|
||||
return (f"{name} pager traffic", 0.75,
|
||||
f"paging band, {baud} baud 2-FSK")
|
||||
for baud, name in ((1600, "FLEX 1600"), (3200, "FLEX 3200"),
|
||||
(6400, "FLEX 6400")):
|
||||
if _baud_near(f, baud) and within(929, 932):
|
||||
return (f"{name} pager traffic", 0.72,
|
||||
f"900 MHz paging, {baud} baud FLEX")
|
||||
|
||||
if best_family == "psk" and f.psk_order == 4 and _baud_near(f, 18000, 0.15):
|
||||
return ("TETRA (pi/4-DQPSK, 18 kbaud)", 0.7, "25 kHz pi/4-DQPSK")
|
||||
|
||||
# ISM / short range devices
|
||||
if within(314.5, 315.5) or within(433.0, 434.9) or within(389.5, 390.5):
|
||||
if best_family in ("ook", "fsk"):
|
||||
kind = "OOK" if best_family == "ook" else "FSK"
|
||||
return (f"ISM short-range device ({kind}: TPMS / remote / sensor)",
|
||||
0.7, f"ISM band burst, {kind}")
|
||||
if within(902, 928) and f.bandwidth > 100_000:
|
||||
return ("902-928 MHz ISM (FHSS / LoRa / smart meter)", 0.6,
|
||||
"wideband ISM emission")
|
||||
|
||||
# Time signals
|
||||
if abs(mhz - 2.5) < 0.005 or abs(mhz - 5.0) < 0.005 or \
|
||||
abs(mhz - 10.0) < 0.005 or abs(mhz - 15.0) < 0.005 or \
|
||||
abs(mhz - 20.0) < 0.005:
|
||||
return ("WWV/WWVH standard time and frequency station", 0.8,
|
||||
"exact WWV carrier frequency")
|
||||
|
||||
# Cellular
|
||||
if (within(824, 894) or within(1850, 1990) or within(614, 698)) \
|
||||
and f.bandwidth > 800_000:
|
||||
return ("Cellular downlink (LTE/5G-NR)", 0.65,
|
||||
"wide flat carrier in a cellular allocation")
|
||||
return None
|
||||
|
||||
|
||||
def _marine_channel(freq_hz: float) -> str:
|
||||
"""Best-effort marine VHF channel label."""
|
||||
known = {156_800_000: " (ch 16 distress)", 156_650_000: " (ch 13 bridge)",
|
||||
157_100_000: " (ch 22A USCG)", 156_450_000: " (ch 9)",
|
||||
156_600_000: " (ch 12)", 156_700_000: " (ch 14)"}
|
||||
for hz, name in known.items():
|
||||
if abs(freq_hz - hz) < 6_000:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify(x: np.ndarray, sample_rate: float, freq_hz: float = 0.0,
|
||||
snr_db: float = 0.0, analyse_audio: bool = True) -> Classification:
|
||||
"""Identify what kind of signal ``x`` is.
|
||||
|
||||
``x`` should be complex baseband centred on the signal. ``freq_hz`` is the
|
||||
real-world centre frequency and is used only for the known-system lookup.
|
||||
"""
|
||||
f = extract_features(x, sample_rate, snr_db=snr_db)
|
||||
filtered = f.extras.pop("_filtered", x)
|
||||
if analyse_audio:
|
||||
try:
|
||||
_analyse_fm_audio(filtered, sample_rate, f)
|
||||
except Exception:
|
||||
pass # audio features are a bonus, never a hard failure
|
||||
|
||||
cands = _score_rules(f)
|
||||
cands.sort(key=lambda c: c[2], reverse=True)
|
||||
label, family, score, reason, mode = cands[0]
|
||||
reasons = [reason]
|
||||
|
||||
if family == "ssb":
|
||||
# Region 2 convention: LSB on 160/80/40 m, USB everywhere else.
|
||||
lower = freq_hz > 0 and freq_hz < 10_000_000
|
||||
mode = "lsb" if lower else "usb"
|
||||
label = f"SSB voice ({mode.upper()})"
|
||||
reasons.append(f"{mode.upper()} assumed from the band convention")
|
||||
|
||||
system = _identify_system(freq_hz, f, family)
|
||||
if system:
|
||||
sys_label, sys_conf, sys_reason = system
|
||||
if sys_conf >= score:
|
||||
label = sys_label
|
||||
score = min(0.97, 0.5 * sys_conf + 0.5 * score + 0.15)
|
||||
reasons.insert(0, sys_reason)
|
||||
else:
|
||||
reasons.append(f"also consistent with {sys_label}")
|
||||
|
||||
# Low SNR means low trust, whatever the rules said.
|
||||
if f.snr_db < 8:
|
||||
score *= 0.65
|
||||
reasons.append(f"low SNR ({f.snr_db:.0f} dB) -- treat with caution")
|
||||
elif f.snr_db < 15:
|
||||
score *= 0.85
|
||||
|
||||
alts = [(c[0], round(min(0.99, c[2]), 2)) for c in cands[1:4]]
|
||||
return Classification(
|
||||
label=label, family=family, confidence=round(min(0.99, score), 3),
|
||||
reasons=reasons, alternatives=alts, suggested_mode=mode, features=f,
|
||||
)
|
||||
879
bandsaunter/cli.py
Executable file
879
bandsaunter/cli.py
Executable file
|
|
@ -0,0 +1,879 @@
|
|||
"""Command line interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from . import __version__
|
||||
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
||||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
||||
is_first_run, list_profiles, load_config, load_default,
|
||||
save_config, save_default)
|
||||
from .device import RtlSdrError, list_devices, set_driver_messages
|
||||
from .librtlsdr import load_error
|
||||
from . import settings as st
|
||||
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
|
||||
from .scanner import Scanner, ScannerCallbacks
|
||||
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
|
||||
from .ui import KeyReader, ScanDisplay, print_band_table, print_hit
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="bandsaunter",
|
||||
description="Scan, record and identify signals with an RTL-SDR.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
bandsaunter interactive setup
|
||||
bandsaunter scan -r 144M-148M -r 420M-450M two ranges
|
||||
bandsaunter scan -b gmrs -b marine-vhf band-plan presets
|
||||
bandsaunter scan -b 2m --record 30 --hang 3 record 30 s max, 3 s squelch tail
|
||||
bandsaunter scan -b 2m --record 0 --hang 6 whole conversations, gaps and all
|
||||
bandsaunter scan -r 14.0M-14.35M --mode cw HF CW (needs direct sampling)
|
||||
bandsaunter config settings menu
|
||||
bandsaunter config hang_seconds=5 set one setting and save it
|
||||
bandsaunter bands --category Aviation browse the US band plan
|
||||
bandsaunter devices list attached dongles
|
||||
bandsaunter scan -b 2m --simulate try it without hardware
|
||||
""")
|
||||
p.add_argument("--version", action="version", version=f"bandsaunter {__version__}")
|
||||
sub = p.add_subparsers(dest="command")
|
||||
|
||||
# -- scan ------------------------------------------------------------
|
||||
s = sub.add_parser("scan", help="run a scan",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
g = s.add_argument_group("what to scan")
|
||||
g.add_argument("-r", "--range", action="append", default=[], metavar="SPEC",
|
||||
help="frequency range, e.g. 144M-148M or 462M-468M/12.5k@nfm; "
|
||||
"repeat for as many pairs as you like")
|
||||
g.add_argument("-b", "--band", action="append", default=[], metavar="KEY",
|
||||
help="US band plan preset key (see `bandsaunter bands`); repeatable")
|
||||
g.add_argument("--mode", default=None,
|
||||
choices=["auto", "nfm", "wfm", "am", "usb", "lsb", "cw", "raw"],
|
||||
help="force a demodulator for every range")
|
||||
g.add_argument("-p", "--profile", metavar="NAME",
|
||||
help="load a saved profile")
|
||||
g.add_argument("--save-profile", metavar="NAME",
|
||||
help="save the resulting configuration and exit")
|
||||
|
||||
st.add_arguments(s)
|
||||
|
||||
g = s.add_argument_group("presentation")
|
||||
g.add_argument("--plain", action="store_true",
|
||||
help="line-per-hit output instead of the live display")
|
||||
g.add_argument("--simulate", action="store_true",
|
||||
help="use a synthetic receiver instead of real hardware")
|
||||
g.add_argument("--dry-run", action="store_true",
|
||||
help="show the sweep plan and exit")
|
||||
g.add_argument("--no-config", action="store_true",
|
||||
help="ignore the saved settings file and start from defaults")
|
||||
g.add_argument("--keep-carriers", action="store_true",
|
||||
help="also record steady unmodulated carriers")
|
||||
g.add_argument("--settings", action="store_true",
|
||||
help="open the settings menu before scanning")
|
||||
g.add_argument("--save", action="store_true",
|
||||
help="save the resulting settings as the default and exit")
|
||||
|
||||
# -- bands ------------------------------------------------------------
|
||||
b = sub.add_parser("bands", help="browse the US band plan")
|
||||
b.add_argument("term", nargs="?", help="search term")
|
||||
b.add_argument("-c", "--category", help="show one category")
|
||||
b.add_argument("--categories", action="store_true", help="list categories")
|
||||
b.add_argument("--json", action="store_true", help="machine-readable output")
|
||||
|
||||
# -- config -----------------------------------------------------------
|
||||
c = sub.add_parser("config", help="view or change the saved settings")
|
||||
c.add_argument("assignment", nargs="*", metavar="KEY=VALUE",
|
||||
help="set one or more settings, e.g. hang_seconds=5")
|
||||
c.add_argument("--show", action="store_true", help="print every setting")
|
||||
c.add_argument("--path", action="store_true",
|
||||
help="print where the settings file lives")
|
||||
c.add_argument("--edit", action="store_true",
|
||||
help="open the settings menu")
|
||||
c.add_argument("--reset", action="store_true",
|
||||
help="delete the saved settings")
|
||||
c.add_argument("--describe", metavar="KEY",
|
||||
help="explain one setting in full")
|
||||
|
||||
# -- transcribe ---------------------------------------------------------
|
||||
tr = sub.add_parser("transcribe",
|
||||
help="transcribe recordings, or check the recognisers")
|
||||
tr.add_argument("path", nargs="*",
|
||||
help="WAV files or directories of them")
|
||||
tr.add_argument("--engines", action="store_true",
|
||||
help="list the speech recognisers and which are installed")
|
||||
tr.add_argument("--engine", default=None, metavar="NAME")
|
||||
tr.add_argument("--model", default=None, metavar="NAME")
|
||||
tr.add_argument("--language", default=None, metavar="CODE")
|
||||
tr.add_argument("--stdout", action="store_true",
|
||||
help="print instead of writing _transcription.txt files")
|
||||
|
||||
# -- devices ----------------------------------------------------------
|
||||
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
|
||||
d.add_argument("--test", action="store_true",
|
||||
help="open the device and capture a test block")
|
||||
|
||||
# -- profiles ----------------------------------------------------------
|
||||
pr = sub.add_parser("profiles", help="list saved profiles")
|
||||
pr.add_argument("--show", metavar="NAME", help="print one profile")
|
||||
|
||||
# -- analyse ------------------------------------------------------------
|
||||
a = sub.add_parser("analyze", aliases=["analyse"],
|
||||
help="identify a signal in a recorded file")
|
||||
a.add_argument("path", help=".cf32/.cs16 IQ file or a .wav from a recording")
|
||||
a.add_argument("--rate", type=float, help="sample rate of the file (Hz)")
|
||||
a.add_argument("--freq", type=float, default=0.0,
|
||||
help="centre frequency in Hz, for band-aware naming")
|
||||
a.add_argument("--morse", action="store_true", help="force a CW decode")
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_config(args) -> tuple[ScanConfig, Path | None]:
|
||||
"""Layer the configuration: saved settings, then a profile, then flags.
|
||||
|
||||
Returns the config and where its saved settings came from, so the user can
|
||||
be told what is in force.
|
||||
"""
|
||||
source = None
|
||||
if getattr(args, "no_config", False):
|
||||
cfg = ScanConfig()
|
||||
else:
|
||||
cfg, source = load_default()
|
||||
if args.profile:
|
||||
cfg = load_config(args.profile)
|
||||
source = Path(cfg._source_path) if hasattr(cfg, "_source_path") else None
|
||||
|
||||
ranges: list[ScanRange] = []
|
||||
for spec in args.range:
|
||||
ranges.extend(parse_range_list(spec))
|
||||
for key in args.band:
|
||||
ranges.extend(parse_range_list(key))
|
||||
if ranges:
|
||||
cfg.ranges = ranges
|
||||
if args.mode and args.mode != "auto":
|
||||
for r in cfg.ranges:
|
||||
r.mode = args.mode
|
||||
|
||||
# Everything else comes straight off the shared settings table, so a flag
|
||||
# cannot exist without the matching entry in the in-app menu.
|
||||
st.apply_args(cfg, args)
|
||||
|
||||
if getattr(args, "keep_carriers", False) and "carrier" not in cfg.accept:
|
||||
cfg.accept = list(cfg.accept) + ["carrier"]
|
||||
return cfg, source
|
||||
|
||||
|
||||
def _print_plan(cfg: ScanConfig) -> None:
|
||||
try:
|
||||
steps = build_plan(cfg.ranges, cfg.sample_rate, cfg.usable_fraction,
|
||||
dc_guard=cfg.dc_guard_hz)
|
||||
except RangeError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return
|
||||
t = Table(title="sweep plan", box=None, header_style="bold")
|
||||
t.add_column("range")
|
||||
t.add_column("from", justify="right")
|
||||
t.add_column("to", justify="right")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("steps", justify="right")
|
||||
counts: dict[int, int] = {}
|
||||
for s in steps:
|
||||
counts[s.range_index] = counts.get(s.range_index, 0) + 1
|
||||
for i, r in enumerate(cfg.ranges):
|
||||
if not r.enabled:
|
||||
continue
|
||||
t.add_row(r.label, fmt_hz(r.start), fmt_hz(r.stop), r.mode,
|
||||
str(counts.get(i, 0)))
|
||||
console.print(t)
|
||||
cycle = len(steps) * cfg.dwell_seconds
|
||||
total = sum(r.span for r in cfg.ranges if r.enabled)
|
||||
gate = (", ".join(cfg.accept) if cfg.require_signal
|
||||
else "everything above the squelch (content check off)")
|
||||
combine = ""
|
||||
if cfg.combine_by_frequency:
|
||||
combine = "\nOne file per frequency"
|
||||
if cfg.announce_timestamps:
|
||||
from .announce import available_engine
|
||||
chosen = (available_engine() if cfg.announce_engine == "auto"
|
||||
else cfg.announce_engine)
|
||||
using = (f"{chosen}" if chosen and chosen != "builtin"
|
||||
else "the built-in synthesiser")
|
||||
combine += f", timestamps spoken by {using}"
|
||||
combine += "."
|
||||
|
||||
console.print(
|
||||
f"[grey62]{len(steps)} tuner steps, {fmt_hz(total)} of spectrum, "
|
||||
f"about {cycle:.1f} s per sweep (excluding time spent recording)."
|
||||
f"\nSquelch +{cfg.threshold_db:g} dB; "
|
||||
+ (f"record up to {cfg.record_seconds:g} s per signal"
|
||||
if cfg.record_seconds else "record for as long as the signal lasts")
|
||||
+ f"; resume after {cfg.hang_seconds:g} s of quiet "
|
||||
f"(shorter gaps are recorded through)."
|
||||
f"\nRecording: {gate}.{combine}[/grey62]")
|
||||
|
||||
|
||||
def _maybe_first_run(cfg: ScanConfig, args) -> None:
|
||||
"""Ask where to save, the first time, when there is someone to ask.
|
||||
|
||||
Skipped when the settings file is being ignored, when nothing is going to
|
||||
be written, and when input is not a terminal -- a script must never block
|
||||
on a question.
|
||||
"""
|
||||
if not is_first_run() or getattr(args, "no_config", False):
|
||||
return
|
||||
if getattr(args, "dry_run", False) or getattr(args, "save_profile", None):
|
||||
return
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
return
|
||||
try:
|
||||
first_run_setup(console, cfg)
|
||||
except TUIAbort:
|
||||
console.print()
|
||||
|
||||
|
||||
def _make_device(cfg: ScanConfig, simulate: bool):
|
||||
if simulate:
|
||||
from .simulator import SimulatedDevice
|
||||
console.print("[magenta]Using the built-in simulator "
|
||||
"(no hardware involved).[/magenta]")
|
||||
return SimulatedDevice(sample_rate=cfg.sample_rate, realtime=True).open()
|
||||
from .device import RtlSdrDevice
|
||||
dev = RtlSdrDevice(index=cfg.device_index, sample_rate=cfg.sample_rate,
|
||||
gain=cfg.gain, ppm=cfg.ppm, agc=cfg.agc,
|
||||
bias_tee=cfg.bias_tee,
|
||||
offset_tuning=cfg.offset_tuning,
|
||||
direct_sampling=cfg.direct_sampling)
|
||||
return dev.open()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_scan(args) -> int:
|
||||
try:
|
||||
cfg, source = _build_config(args)
|
||||
except (RangeError, FileNotFoundError, ValueError, st.SettingError) as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return 2
|
||||
|
||||
_maybe_first_run(cfg, args)
|
||||
|
||||
if getattr(args, "settings", False):
|
||||
try:
|
||||
settings_menu(console, cfg)
|
||||
except TUIAbort:
|
||||
return 0
|
||||
|
||||
if not cfg.ranges:
|
||||
cfg = run_tui(console, cfg, source)
|
||||
if cfg is None:
|
||||
return 0
|
||||
elif source and not cfg.quiet:
|
||||
console.print(f"[grey62]settings from {source}[/grey62]")
|
||||
|
||||
if args.save_profile:
|
||||
path = save_config(cfg, args.save_profile)
|
||||
console.print(f"[green]saved profile to {path}[/green]")
|
||||
return 0
|
||||
if getattr(args, "save", False):
|
||||
path = save_default(cfg)
|
||||
console.print(f"[green]saved as the default settings: {path}[/green]")
|
||||
return 0
|
||||
|
||||
errs = cfg.validate()
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f"[red]{e}[/red]")
|
||||
return 2
|
||||
|
||||
if args.dry_run:
|
||||
_print_plan(cfg)
|
||||
return 0
|
||||
|
||||
if not cfg.quiet:
|
||||
_print_plan(cfg)
|
||||
|
||||
try:
|
||||
device = _make_device(cfg, args.simulate)
|
||||
except RtlSdrError as exc:
|
||||
console.print(Panel(Text(str(exc)), title="[red]cannot open the receiver",
|
||||
border_style="red"))
|
||||
console.print("[grey62]Try `bandsaunter devices` to check what is "
|
||||
"attached, or `--simulate` to run without "
|
||||
"hardware.[/grey62]")
|
||||
return 1
|
||||
|
||||
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
|
||||
try:
|
||||
scanner.prepare()
|
||||
except (ValueError, RangeError, RtlSdrError) as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
device.close()
|
||||
return 2
|
||||
|
||||
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
|
||||
rc = (_run_plain(scanner, cfg) if (args.plain or cfg.quiet or
|
||||
not sys.stdout.isatty())
|
||||
else _run_live(scanner, cfg))
|
||||
_print_summary(scanner)
|
||||
return rc
|
||||
|
||||
|
||||
def _run_plain(scanner: Scanner, cfg: ScanConfig) -> int:
|
||||
scanner.cb.on_record_end = lambda hit: print_hit(console, hit)
|
||||
if not cfg.quiet:
|
||||
scanner.cb.on_status = lambda m: console.print(f"[grey62]{m}[/grey62]")
|
||||
scanner.cb.on_error = lambda e: console.print(f"[red]{type(e).__name__}: {e}[/red]")
|
||||
console.print("[grey62]scanning -- Ctrl-C to stop[/grey62]")
|
||||
try:
|
||||
scanner.run()
|
||||
except KeyboardInterrupt:
|
||||
scanner.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def _run_live(scanner: Scanner, cfg: ScanConfig) -> int:
|
||||
display = ScanDisplay(scanner)
|
||||
display.attach()
|
||||
import threading
|
||||
worker = threading.Thread(target=scanner.run, daemon=True, name="scan")
|
||||
|
||||
with KeyReader() as keys:
|
||||
# crop rather than let an oversized frame scroll: a display taller
|
||||
# than the terminal cannot be redrawn in place, and every refresh
|
||||
# would leave another copy behind.
|
||||
with Live(display.render(), console=console, refresh_per_second=8,
|
||||
screen=False, transient=False,
|
||||
vertical_overflow="crop") as live:
|
||||
worker.start()
|
||||
try:
|
||||
while worker.is_alive():
|
||||
key = keys.get()
|
||||
if key:
|
||||
_handle_key(key, scanner, display)
|
||||
live.update(display.render())
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
scanner.stop()
|
||||
finally:
|
||||
scanner.stop()
|
||||
worker.join(timeout=5.0)
|
||||
live.update(display.render())
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_key(key: str, scanner: Scanner, display: ScanDisplay) -> None:
|
||||
k = key.lower()
|
||||
if k == "q":
|
||||
scanner.stop()
|
||||
elif k == "p":
|
||||
scanner.pause(not scanner.paused)
|
||||
display.on_status("paused" if scanner.paused else "resumed")
|
||||
elif k == "s":
|
||||
scanner.skip()
|
||||
display.on_status("skipping this signal")
|
||||
elif k == "l":
|
||||
freq = display._rec.frequency or scanner.stats.current_freq
|
||||
if freq:
|
||||
scanner.lockout(freq)
|
||||
display.on_status(f"locked out {fmt_hz(freq)}")
|
||||
scanner.skip()
|
||||
elif k in ("+", "="):
|
||||
scanner.cfg.threshold_db += 1.0
|
||||
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
|
||||
elif k == "-":
|
||||
scanner.cfg.threshold_db = max(1.0, scanner.cfg.threshold_db - 1.0)
|
||||
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
|
||||
|
||||
|
||||
def _print_summary(scanner: Scanner) -> None:
|
||||
st = scanner.stats
|
||||
console.print()
|
||||
console.rule("[bold]scan summary[/bold]", style="blue")
|
||||
console.print(
|
||||
f" ran for {st.elapsed:.0f} s over {st.cycles} sweep(s), "
|
||||
f"{st.steps_done} tuner steps\n"
|
||||
f" {st.detections} detection(s), {st.recordings} recording(s) kept, "
|
||||
f"{st.discarded} discarded\n"
|
||||
f" {st.seconds_recorded:.0f} s of audio captured")
|
||||
if st.truncated:
|
||||
limit = scanner.cfg.record_seconds
|
||||
console.print(
|
||||
f"[yellow] {st.truncated} recording(s) were cut off at the "
|
||||
f"{limit:g} s record limit while the signal was still "
|
||||
f"transmitting.[/yellow]\n"
|
||||
f"[grey62] Set 'Record for' to 0 (or --record 0) to follow a "
|
||||
f"transmission to its end; 'Wait for quiet' then decides when to "
|
||||
f"move on.[/grey62]")
|
||||
worker = getattr(scanner, "transcriber", None)
|
||||
if worker is not None and (worker.written or worker.empty or worker.dropped):
|
||||
bits = [f"{worker.written} transcript(s) written"]
|
||||
if worker.empty:
|
||||
bits.append(f"{worker.empty} with no recognisable speech")
|
||||
if worker.dropped:
|
||||
bits.append(f"{worker.dropped} skipped, the recogniser fell behind")
|
||||
console.print(f"[grey62] {', '.join(bits)}[/grey62]")
|
||||
if st.rejected_by_category:
|
||||
drops = ", ".join(f"{n} {cat}"
|
||||
for cat, n in sorted(st.rejected_by_category.items(),
|
||||
key=lambda kv: -kv[1]) if cat)
|
||||
if drops:
|
||||
console.print(f"[grey62] discarded without recording: {drops}[/grey62]")
|
||||
if scanner.hits:
|
||||
counts: dict[str, int] = {}
|
||||
for h in scanner.hits:
|
||||
counts[h.classification or "unclassified"] = \
|
||||
counts.get(h.classification or "unclassified", 0) + 1
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("identified as")
|
||||
t.add_column("count", justify="right")
|
||||
for label, n in sorted(counts.items(), key=lambda kv: -kv[1]):
|
||||
t.add_row(label, str(n))
|
||||
console.print(t)
|
||||
out = Path(scanner.cfg.output_dir)
|
||||
console.print(f"[grey62]recordings in {out.resolve()}, "
|
||||
f"log in {(out / scanner.cfg.log_file).name} "
|
||||
f"and .csv[/grey62]")
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
cfg, source = load_default()
|
||||
|
||||
if args.path:
|
||||
print(DEFAULT_CONFIG_PATH)
|
||||
return 0
|
||||
if args.reset:
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
DEFAULT_CONFIG_PATH.unlink()
|
||||
console.print(f"[green]deleted {DEFAULT_CONFIG_PATH}[/green]")
|
||||
else:
|
||||
console.print("[yellow]nothing saved to delete[/yellow]")
|
||||
return 0
|
||||
if args.describe:
|
||||
setting = st.by_key(args.describe) or next(iter(st.search(args.describe)), None)
|
||||
if setting is None:
|
||||
console.print(f"[red]no setting called {args.describe!r}[/red]")
|
||||
return 1
|
||||
from .tui import setting_help
|
||||
setting_help(console, setting, cfg)
|
||||
return 0
|
||||
|
||||
if args.assignment:
|
||||
changed = []
|
||||
for item in args.assignment:
|
||||
key, _, value = item.partition("=")
|
||||
setting = st.by_key(key.strip())
|
||||
if setting is None:
|
||||
console.print(f"[red]no setting called {key.strip()!r}[/red]")
|
||||
matches = st.search(key.strip())
|
||||
if matches:
|
||||
console.print("[grey62]did you mean: "
|
||||
+ ", ".join(m.key for m in matches[:5])
|
||||
+ "[/grey62]")
|
||||
return 2
|
||||
try:
|
||||
setattr(cfg, setting.key, st.parse_value(setting, value))
|
||||
except st.SettingError as exc:
|
||||
console.print(f"[red]{setting.key}: {exc}[/red]")
|
||||
return 2
|
||||
changed.append(setting)
|
||||
errs = [e for e in cfg.validate() if "frequency ranges" not in e]
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f"[red]{e}[/red]")
|
||||
return 2
|
||||
path = save_default(cfg)
|
||||
for setting in changed:
|
||||
console.print(f"[green]{setting.key} = "
|
||||
f"{st.format_value(setting, getattr(cfg, setting.key))}"
|
||||
f"[/green]")
|
||||
console.print(f"[grey62]saved to {path}[/grey62]")
|
||||
return 0
|
||||
|
||||
if args.edit or not args.show:
|
||||
try:
|
||||
settings_menu(console, cfg)
|
||||
except TUIAbort:
|
||||
console.print()
|
||||
return 0
|
||||
try:
|
||||
save = Confirm.ask("save these settings as the default",
|
||||
default=True)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
save = False
|
||||
if save:
|
||||
console.print(f"[green]saved to {save_default(cfg)}[/green]")
|
||||
return 0
|
||||
|
||||
default = ScanConfig()
|
||||
console.print(f"[grey62]{'saved settings: ' + str(source) if source else 'no settings file yet; showing defaults'}[/grey62]")
|
||||
for group in st.GROUPS:
|
||||
t = Table(title=group, box=None, header_style="bold", title_justify="left")
|
||||
t.add_column("key", style="cyan")
|
||||
t.add_column("value")
|
||||
t.add_column("default", style="grey62")
|
||||
t.add_column("what it does", style="grey62", overflow="fold")
|
||||
for setting in st.in_group(group):
|
||||
value = st.format_value(setting, getattr(cfg, setting.key))
|
||||
dflt = st.format_value(setting, getattr(default, setting.key))
|
||||
t.add_row(setting.key, value, "" if value == dflt else dflt,
|
||||
setting.help)
|
||||
console.print(t)
|
||||
console.print()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bands(args) -> int:
|
||||
if args.categories:
|
||||
t = Table(title="band plan categories", box=None, header_style="bold")
|
||||
t.add_column("category")
|
||||
t.add_column("presets", justify="right")
|
||||
for c in CATEGORIES:
|
||||
t.add_row(c, str(len(in_category(c))))
|
||||
console.print(t)
|
||||
return 0
|
||||
if args.category:
|
||||
presets = in_category(args.category)
|
||||
if not presets:
|
||||
matches = [c for c in CATEGORIES
|
||||
if args.category.lower() in c.lower()]
|
||||
if len(matches) == 1:
|
||||
presets = in_category(matches[0])
|
||||
else:
|
||||
console.print(f"[red]no such category: {args.category}[/red]")
|
||||
console.print("[grey62]try: " + ", ".join(CATEGORIES) + "[/grey62]")
|
||||
return 2
|
||||
title = args.category
|
||||
elif args.term:
|
||||
presets = search(args.term)
|
||||
title = f"matching {args.term!r}"
|
||||
if not presets:
|
||||
console.print(f"[yellow]nothing matched {args.term!r}[/yellow]")
|
||||
return 1
|
||||
else:
|
||||
presets = list(PRESETS)
|
||||
title = f"US band plan ({len(PRESETS)} presets)"
|
||||
|
||||
if args.json:
|
||||
print(json.dumps([{
|
||||
"key": p.key, "name": p.name, "category": p.category,
|
||||
"start": p.start, "stop": p.stop, "step": p.step,
|
||||
"mode": p.mode, "bandwidth": p.bandwidth, "note": p.note,
|
||||
"members": list(p.members),
|
||||
} for p in presets], indent=2))
|
||||
return 0
|
||||
print_band_table(console, presets, title)
|
||||
console.print("[grey62]use a key with: bandsaunter scan -b <key>[/grey62]")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_transcribe(args) -> int:
|
||||
from .transcribe import available_engine, describe_engines, transcribe
|
||||
from .recorder import read_wav
|
||||
|
||||
if args.engines or not args.path:
|
||||
t = Table(title="speech recognisers", box=None, header_style="bold")
|
||||
t.add_column("engine", style="cyan")
|
||||
t.add_column("installed")
|
||||
t.add_column("how to get it", style="grey62")
|
||||
for name, present, how in describe_engines():
|
||||
t.add_row(name,
|
||||
"[green]yes[/green]" if present else "[red]no[/red]", how)
|
||||
console.print(t)
|
||||
chosen = available_engine()
|
||||
console.print(f"[grey62]{'auto would use ' + chosen if chosen else
|
||||
'nothing installed — transcription is unavailable'}"
|
||||
f"[/grey62]")
|
||||
if not args.path:
|
||||
return 0 if chosen else 1
|
||||
|
||||
cfg, _ = load_default()
|
||||
engine = args.engine or cfg.transcribe_engine
|
||||
model = args.model or cfg.transcribe_model
|
||||
language = args.language if args.language is not None \
|
||||
else cfg.transcribe_language
|
||||
|
||||
files: list[Path] = []
|
||||
for item in args.path:
|
||||
p = Path(item)
|
||||
if p.is_dir():
|
||||
files.extend(sorted(p.glob("*.wav")))
|
||||
elif p.exists():
|
||||
files.append(p)
|
||||
else:
|
||||
console.print(f"[red]no such file: {p}[/red]")
|
||||
return 1
|
||||
if not files:
|
||||
console.print("[yellow]nothing to transcribe[/yellow]")
|
||||
return 1
|
||||
|
||||
failures = 0
|
||||
for wav in files:
|
||||
try:
|
||||
audio, rate = read_wav(wav)
|
||||
except (OSError, ValueError) as exc:
|
||||
console.print(f"[red]{wav.name}: {exc}[/red]")
|
||||
failures += 1
|
||||
continue
|
||||
console.print(f"[grey62]{wav.name}: {audio.size / rate:.1f} s[/grey62]")
|
||||
result = transcribe(audio, rate, engine, model, language)
|
||||
if result is None:
|
||||
console.print("[red]no speech recogniser installed — "
|
||||
"see `bandsaunter transcribe --engines`[/red]")
|
||||
return 1
|
||||
if result.note:
|
||||
console.print(f"[yellow]{result.note}[/yellow]")
|
||||
text = result.text.strip() or "[no speech recognised]"
|
||||
if args.stdout:
|
||||
console.print(text)
|
||||
else:
|
||||
out = wav.with_suffix("")
|
||||
out = out.with_name(out.name + "_transcription.txt")
|
||||
out.write_text(text + "\n")
|
||||
console.print(f" [green]{out.name}[/green]: {text[:70]}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def cmd_devices(args) -> int:
|
||||
# This is the command people run when something is wrong, so let the
|
||||
# driver say what it is doing.
|
||||
set_driver_messages(True)
|
||||
err = load_error()
|
||||
if err:
|
||||
console.print(Panel(Text(err), title="[red]librtlsdr not available",
|
||||
border_style="red"))
|
||||
return 1
|
||||
devs = list_devices()
|
||||
if not devs:
|
||||
console.print("[yellow]No RTL-SDR devices found.[/yellow]")
|
||||
console.print(
|
||||
"[grey62]Check `lsusb` for a Realtek RTL2832/RTL2838. If it is "
|
||||
"listed, the DVB-T kernel driver has probably claimed it:\n"
|
||||
" echo 'blacklist dvb_usb_rtl28xxu' | "
|
||||
"sudo tee /etc/modprobe.d/blacklist-rtl.conf\n"
|
||||
" sudo rmmod dvb_usb_rtl28xxu[/grey62]")
|
||||
return 1
|
||||
t = Table(title="RTL-SDR devices", box=None, header_style="bold")
|
||||
t.add_column("#", justify="right")
|
||||
t.add_column("name")
|
||||
t.add_column("manufacturer")
|
||||
t.add_column("product")
|
||||
t.add_column("serial")
|
||||
for d in devs:
|
||||
t.add_row(str(d.index), d.name, d.manufacturer, d.product, d.serial)
|
||||
console.print(t)
|
||||
|
||||
if args.test:
|
||||
from .device import RtlSdrDevice
|
||||
import numpy as np
|
||||
for d in devs:
|
||||
try:
|
||||
with RtlSdrDevice(index=d.index) as dev:
|
||||
dev.tune(100_000_000)
|
||||
x = dev.read_samples(65536, flush=True)
|
||||
rms = float(np.sqrt(np.mean(np.abs(x) ** 2)))
|
||||
gains = dev.available_gains
|
||||
console.print(
|
||||
f" [green]device {d.index} works[/green]: tuner "
|
||||
f"{dev.tuner}, {len(gains)} gain steps "
|
||||
f"({min(gains):.1f}-{max(gains):.1f} dB), "
|
||||
f"test capture RMS {rms:.4f}")
|
||||
except RtlSdrError as exc:
|
||||
console.print(f" [red]device {d.index}: {exc}[/red]")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_profiles(args) -> int:
|
||||
if args.show:
|
||||
try:
|
||||
cfg = load_config(args.show)
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return 1
|
||||
console.print_json(json.dumps(cfg.to_dict(), default=str))
|
||||
return 0
|
||||
profiles = list_profiles()
|
||||
if not profiles:
|
||||
console.print(f"[yellow]no profiles in {DEFAULT_CONFIG_DIR}[/yellow]")
|
||||
console.print("[grey62]create one with: "
|
||||
"bandsaunter scan -b 2m --save-profile myscan[/grey62]")
|
||||
return 0
|
||||
t = Table(title=f"profiles in {DEFAULT_CONFIG_DIR}", box=None,
|
||||
header_style="bold")
|
||||
t.add_column("name")
|
||||
t.add_column("ranges", justify="right")
|
||||
t.add_column("record", justify="right")
|
||||
t.add_column("hang", justify="right")
|
||||
for p in profiles:
|
||||
try:
|
||||
cfg = load_config(str(p))
|
||||
t.add_row(p.stem, str(len(cfg.ranges)),
|
||||
f"{cfg.record_seconds:g}s", f"{cfg.hang_seconds:g}s")
|
||||
except Exception:
|
||||
t.add_row(p.stem, "[red]unreadable[/red]", "", "")
|
||||
console.print(t)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_analyze(args) -> int:
|
||||
import numpy as np
|
||||
from .classify import classify
|
||||
from .morse import decode_morse
|
||||
|
||||
path = Path(args.path)
|
||||
if not path.exists():
|
||||
console.print(f"[red]no such file: {path}[/red]")
|
||||
return 1
|
||||
|
||||
rate = args.rate
|
||||
freq = args.freq
|
||||
# A recording directory carries its own metadata; use it when present.
|
||||
meta_file = path.parent / "meta.json"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
freq = freq or meta.get("frequency_hz", 0.0)
|
||||
if rate is None:
|
||||
rate = (meta.get("iq_rate") if path.suffix != ".wav"
|
||||
else meta.get("audio_rate"))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
if path.suffix == ".wav":
|
||||
import wave
|
||||
with wave.open(str(path)) as w:
|
||||
rate = rate or w.getframerate()
|
||||
raw = w.readframes(w.getnframes())
|
||||
audio = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
|
||||
console.print(f"[grey62]{path.name}: {audio.size/rate:.1f} s of audio "
|
||||
f"at {rate:g} Hz[/grey62]")
|
||||
m = decode_morse(audio, rate)
|
||||
if m.is_morse or args.morse:
|
||||
console.print(Panel(
|
||||
Text.from_markup(
|
||||
f'[bold]{m.text.strip() or "(nothing decoded)"}[/bold]\n\n'
|
||||
f"[grey62]{m.wpm:.0f} WPM, tone {m.tone_hz:.0f} Hz, "
|
||||
f"confidence {m.confidence}[/grey62]"),
|
||||
title="CW / Morse", border_style="green"))
|
||||
else:
|
||||
console.print("[yellow]no Morse found in this audio "
|
||||
f"({'; '.join(m.notes) or 'no keyed tone'})[/yellow]")
|
||||
return 0
|
||||
|
||||
if rate is None:
|
||||
console.print("[red]--rate is required for raw IQ files[/red]")
|
||||
return 2
|
||||
if path.suffix == ".cs16":
|
||||
raw = np.fromfile(path, dtype="<i2").astype(np.float32) / 32768.0
|
||||
iq = raw[0::2] + 1j * raw[1::2]
|
||||
else:
|
||||
iq = np.fromfile(path, dtype=np.complex64)
|
||||
console.print(f"[grey62]{path.name}: {iq.size} samples, "
|
||||
f"{iq.size/rate:.2f} s at {rate:g} Hz[/grey62]")
|
||||
|
||||
cls = classify(iq, rate, freq_hz=freq or 0.0, snr_db=20.0)
|
||||
body = [f"[bold]{cls.label}[/bold] ({cls.confidence*100:.0f}% confident)"]
|
||||
for r in cls.reasons:
|
||||
body.append(f"[grey62]- {r}[/grey62]")
|
||||
if cls.alternatives:
|
||||
body.append("[grey62]other candidates: " +
|
||||
", ".join(f"{n} ({c*100:.0f}%)" for n, c in cls.alternatives) +
|
||||
"[/grey62]")
|
||||
console.print(Panel(Text.from_markup("\n".join(body)),
|
||||
title="identification", border_style="green"))
|
||||
|
||||
f = cls.features
|
||||
if f:
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("measurement")
|
||||
t.add_column("value", justify="right")
|
||||
rows = [("occupied bandwidth", fmt_hz(f.bandwidth)),
|
||||
("spectral flatness", f"{f.flatness:.3f}"),
|
||||
("envelope variation", f"{f.env_cv:.3f}"),
|
||||
("rms deviation", fmt_hz(f.fdev_rms)),
|
||||
("discriminator levels", str(f.freq_modes)),
|
||||
("symbol rate", f"{f.baud:.0f} baud" if f.baud else "-"),
|
||||
("CTCSS tone", f"{f.ctcss_hz:.1f} Hz" if f.ctcss_hz else "-"),
|
||||
("on/off contrast", f"{f.ook_contrast_db:.1f} dB")]
|
||||
for k, v in rows:
|
||||
t.add_row(k, v)
|
||||
console.print(t)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command is None:
|
||||
cfg, source = load_default()
|
||||
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
|
||||
try:
|
||||
first_run_setup(console, cfg)
|
||||
except TUIAbort:
|
||||
console.print()
|
||||
return 0
|
||||
cfg = run_tui(console, cfg, source)
|
||||
if cfg is None:
|
||||
return 0
|
||||
try:
|
||||
device = _make_device(cfg, False)
|
||||
except RtlSdrError as exc:
|
||||
console.print(Panel(Text(str(exc)),
|
||||
title="[red]cannot open the receiver",
|
||||
border_style="red"))
|
||||
return 1
|
||||
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
|
||||
try:
|
||||
scanner.prepare()
|
||||
except (ValueError, RangeError, RtlSdrError) as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
return 2
|
||||
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
|
||||
_run_live(scanner, cfg)
|
||||
_print_summary(scanner)
|
||||
return 0
|
||||
|
||||
handlers = {
|
||||
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
|
||||
"config": cmd_config, "transcribe": cmd_transcribe,
|
||||
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
||||
}
|
||||
try:
|
||||
return handlers[args.command](args)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[grey62]interrupted[/grey62]")
|
||||
return 130
|
||||
except BrokenPipeError:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
277
bandsaunter/config.py
Executable file
277
bandsaunter/config.py
Executable file
|
|
@ -0,0 +1,277 @@
|
|||
"""Scanner configuration: defaults, validation, and YAML profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field, asdict, fields
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .ranges import ScanRange, parse_range_list
|
||||
|
||||
__all__ = ["ScanConfig", "DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH",
|
||||
"load_config", "save_config", "list_profiles", "profile_path",
|
||||
"load_default", "save_default", "delete_profile",
|
||||
"is_first_run", "DEFAULT_OUTPUT_DIR"]
|
||||
|
||||
DEFAULT_CONFIG_DIR = Path(
|
||||
os.environ.get("BANDSAUNTER_CONFIG_DIR",
|
||||
Path.home() / ".config" / "bandsaunter")
|
||||
)
|
||||
|
||||
# Settings saved here are picked up by every run, before any command-line
|
||||
# option is applied. Named profiles live alongside it in the same directory.
|
||||
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
|
||||
|
||||
# Suggested on first run. Somewhere visible rather than under a dot
|
||||
# directory: these are recordings people want to browse and play.
|
||||
DEFAULT_OUTPUT_DIR = "~/bandsaunter"
|
||||
|
||||
|
||||
def is_first_run(directory: Path | None = None) -> bool:
|
||||
"""True when no settings have ever been saved."""
|
||||
return not (Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml").exists()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanConfig:
|
||||
"""Everything that controls a scan run.
|
||||
|
||||
The two dwell settings the scanner is built around:
|
||||
|
||||
``record_seconds``
|
||||
Record for at most this long on one signal before going back to
|
||||
scanning, even if the signal is still up. 0 means "no limit", which
|
||||
is what you want for capturing a whole conversation.
|
||||
|
||||
``hang_seconds``
|
||||
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 natural
|
||||
pauses between overs. "Quiet" means no real signal: silence, static
|
||||
and interference all count towards it, so a burst of noise during a
|
||||
pause does not hold the receiver on a finished conversation.
|
||||
"""
|
||||
|
||||
# -- what to scan ---------------------------------------------------
|
||||
ranges: list[ScanRange] = field(default_factory=list)
|
||||
|
||||
# -- the two headline dwell settings --------------------------------
|
||||
record_seconds: float = 30.0 # max record time per hit (0 = unlimited)
|
||||
hang_seconds: float = 2.0 # quiet time before the scan resumes
|
||||
max_record_seconds: float = 900.0 # absolute ceiling, even when record is 0
|
||||
|
||||
# -- detection ------------------------------------------------------
|
||||
threshold_db: float = 10.0 # dB of margin over the noise
|
||||
min_record_seconds: float = 0.5 # discard blips shorter than this
|
||||
dwell_seconds: float = 0.05 # capture time per tuner step while sweeping
|
||||
probe_seconds: float = 0.4 # look before choosing a demodulator
|
||||
resolution_hz: float = 3_000.0 # FFT bin width during the sweep
|
||||
squelch_margin_db: float = 3.0 # hysteresis: drop below thr - this to end
|
||||
adaptive_floor: bool = True # smooth the per-bin noise baseline
|
||||
detector: str = "peak" # "peak" holds bursts, "avg" is quieter
|
||||
detector_bias_db: float | None = None # None = derive it from the detector
|
||||
|
||||
# -- content gate: only keep captures that carry something ----------
|
||||
require_signal: bool = True # discard captures with no real content
|
||||
accept: list[str] = field(
|
||||
default_factory=lambda: ["voice", "cw", "digital"])
|
||||
min_signal_score: float = 0.45 # confidence needed to keep a capture
|
||||
min_voice_score: float = 0.45 # speech-likeness needed to call it voice
|
||||
verify_seconds: float = 1.5 # how often to re-check a live capture
|
||||
verify_max_seconds: float = 6.0 # give up on a contentless capture by here
|
||||
max_detections_per_step: int = 4
|
||||
|
||||
# -- radio ----------------------------------------------------------
|
||||
device_index: int = 0
|
||||
sample_rate: int = 2_048_000
|
||||
gain: float | str = "auto"
|
||||
ppm: int = 0
|
||||
agc: bool = False
|
||||
bias_tee: bool = False
|
||||
offset_tuning: bool = False
|
||||
direct_sampling: int | str = "auto"
|
||||
usable_fraction: float = 0.75
|
||||
dc_guard_hz: float = 8_000.0 # LO offset that keeps the DC spike clear
|
||||
|
||||
# -- recording ------------------------------------------------------
|
||||
output_dir: str = "~/bandsaunter"
|
||||
save_audio: bool = True
|
||||
save_iq: bool = False
|
||||
audio_rate: int = 16_000
|
||||
iq_format: str = "cf32" # cf32 or cs16
|
||||
classify: bool = True
|
||||
decode_morse: bool = True
|
||||
|
||||
# -- one file per frequency ------------------------------------------
|
||||
combine_by_frequency: bool = False
|
||||
combine_tolerance_hz: float = 6_250.0
|
||||
combine_keep_individual: bool = False
|
||||
announce_timestamps: bool = True
|
||||
announce_frequency: bool = False
|
||||
announce_engine: str = "auto"
|
||||
|
||||
# -- speech to text ---------------------------------------------------
|
||||
transcribe: bool = False
|
||||
transcribe_engine: str = "auto"
|
||||
transcribe_model: str = "base.en"
|
||||
transcribe_language: str = "en"
|
||||
transcribe_min_seconds: float = 1.0
|
||||
|
||||
# -- behaviour ------------------------------------------------------
|
||||
lockout: list[float] = field(default_factory=list)
|
||||
lockout_width: float = 12_500.0
|
||||
revisit_seconds: float = 8.0 # ignore a frequency again this soon
|
||||
max_cycles: int = 0 # 0 = run forever
|
||||
max_runtime_seconds: float = 0.0 # 0 = no limit
|
||||
quiet: bool = False
|
||||
log_file: str = "scan_log.jsonl"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def validate(self) -> list[str]:
|
||||
"""Return a list of human-readable problems (empty when config is sane)."""
|
||||
errs = []
|
||||
if not self.ranges:
|
||||
errs.append("no frequency ranges configured")
|
||||
if self.record_seconds < 0:
|
||||
errs.append("record_seconds cannot be negative")
|
||||
if self.hang_seconds < 0:
|
||||
errs.append("hang_seconds cannot be negative")
|
||||
if self.min_record_seconds < 0:
|
||||
errs.append("min_record_seconds cannot be negative")
|
||||
if self.max_record_seconds < 0:
|
||||
errs.append("max_record_seconds cannot be negative")
|
||||
if self.record_seconds and self.min_record_seconds > self.record_seconds:
|
||||
errs.append("min_record_seconds is longer than record_seconds, so "
|
||||
"no recording would ever be kept")
|
||||
if not (0.005 <= self.dwell_seconds <= 5.0):
|
||||
errs.append("dwell_seconds should be between 0.005 and 5")
|
||||
if self.threshold_db <= 0:
|
||||
errs.append("threshold_db must be positive")
|
||||
if not (0.1 <= self.usable_fraction <= 0.95):
|
||||
errs.append("usable_fraction should be between 0.1 and 0.95")
|
||||
if self.audio_rate < 4000:
|
||||
errs.append("audio_rate is too low to carry voice")
|
||||
from .quality import CATEGORIES
|
||||
bad = [c for c in self.accept if c not in CATEGORIES]
|
||||
if bad:
|
||||
errs.append("unknown accept categories: " + ", ".join(bad)
|
||||
+ " (choose from " + ", ".join(CATEGORIES) + ")")
|
||||
if self.require_signal and not self.accept:
|
||||
errs.append("require_signal is on but no categories are accepted, "
|
||||
"so nothing could ever be recorded")
|
||||
if not (0.0 <= self.min_signal_score <= 1.0):
|
||||
errs.append("min_signal_score must be between 0 and 1")
|
||||
if self.detector not in ("peak", "avg"):
|
||||
errs.append('detector must be "peak" or "avg"')
|
||||
if self.iq_format not in ("cf32", "cs16"):
|
||||
errs.append("iq_format must be cf32 or cs16")
|
||||
for r in self.ranges:
|
||||
if r.stop <= 0 or r.start <= 0:
|
||||
errs.append(f"range {r.label!r} has a non-positive frequency")
|
||||
return errs
|
||||
|
||||
# -- serialisation ---------------------------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["ranges"] = [r.to_dict() for r in self.ranges]
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ScanConfig":
|
||||
d = dict(d or {})
|
||||
raw = d.pop("ranges", [])
|
||||
known = {f.name for f in fields(cls)}
|
||||
unknown = set(d) - known
|
||||
for k in unknown:
|
||||
d.pop(k)
|
||||
cfg = cls(**{k: v for k, v in d.items() if k in known})
|
||||
out = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
out.append(ScanRange.from_dict(item))
|
||||
elif isinstance(item, str):
|
||||
out.extend(parse_range_list(item))
|
||||
cfg.ranges = out
|
||||
if unknown:
|
||||
cfg._unknown_keys = sorted(unknown) # surfaced by the CLI
|
||||
return cfg
|
||||
|
||||
def copy(self) -> "ScanConfig":
|
||||
return ScanConfig.from_dict(self.to_dict())
|
||||
|
||||
|
||||
def profile_path(name: str, directory: Path | None = None) -> Path:
|
||||
directory = Path(directory or DEFAULT_CONFIG_DIR)
|
||||
if name.endswith((".yaml", ".yml")):
|
||||
p = Path(name)
|
||||
return p if p.is_absolute() or p.parent != Path(".") else directory / p.name
|
||||
return directory / f"{name}.yaml"
|
||||
|
||||
|
||||
def save_config(cfg: ScanConfig, name: str, directory: Path | None = None) -> Path:
|
||||
path = profile_path(name, directory)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as fh:
|
||||
yaml.safe_dump(cfg.to_dict(), fh, sort_keys=False, default_flow_style=False)
|
||||
return path
|
||||
|
||||
|
||||
def load_config(name_or_path: str, directory: Path | None = None) -> ScanConfig:
|
||||
path = Path(name_or_path)
|
||||
if not path.exists():
|
||||
path = profile_path(name_or_path, directory)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"no such profile or config file: {name_or_path}")
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
cfg = ScanConfig.from_dict(data)
|
||||
cfg._source_path = str(path)
|
||||
return cfg
|
||||
|
||||
|
||||
def list_profiles(directory: Path | None = None) -> list[Path]:
|
||||
directory = Path(directory or DEFAULT_CONFIG_DIR)
|
||||
if not directory.exists():
|
||||
return []
|
||||
return sorted(p for p in directory.glob("*.yaml"))
|
||||
|
||||
|
||||
def load_default(directory: Path | None = None) -> tuple["ScanConfig", Path | None]:
|
||||
"""Load the saved settings, or defaults if none have been saved.
|
||||
|
||||
Returns ``(config, path)`` where ``path`` is None when nothing was loaded,
|
||||
so the caller can tell the user where its settings came from.
|
||||
"""
|
||||
path = Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml"
|
||||
if not path.exists():
|
||||
return ScanConfig(), None
|
||||
try:
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return ScanConfig(), None
|
||||
cfg = ScanConfig.from_dict(data)
|
||||
cfg._source_path = str(path)
|
||||
return cfg, path
|
||||
|
||||
|
||||
def save_default(cfg: "ScanConfig", directory: Path | None = None) -> Path:
|
||||
"""Write the settings that every later run should start from."""
|
||||
path = Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".yaml.tmp")
|
||||
with open(tmp, "w") as fh:
|
||||
yaml.safe_dump(cfg.to_dict(), fh, sort_keys=False,
|
||||
default_flow_style=False)
|
||||
tmp.replace(path) # atomic, so a crash cannot leave a half file
|
||||
return path
|
||||
|
||||
|
||||
def delete_profile(name: str, directory: Path | None = None) -> bool:
|
||||
path = profile_path(name, directory)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
333
bandsaunter/demod.py
Executable file
333
bandsaunter/demod.py
Executable file
|
|
@ -0,0 +1,333 @@
|
|||
"""Streaming demodulators.
|
||||
|
||||
Each demodulator consumes consecutive blocks of complex baseband at the
|
||||
device sample rate and emits float32 audio in [-1, 1]. All internal filters
|
||||
are stateful, so a recording assembled from many blocks has no seams.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
|
||||
from .dsp import DecimationChain, Mixer
|
||||
|
||||
__all__ = ["make_demodulator", "Demodulator", "MODES", "plan_rates",
|
||||
"DEEMPHASIS_US"]
|
||||
|
||||
MODES = ("nfm", "wfm", "am", "usb", "lsb", "cw", "dsb", "raw")
|
||||
|
||||
DEEMPHASIS_US = 75e-6 # North America; Europe uses 50 us
|
||||
|
||||
|
||||
def _small_prime_divisors(n: int, limit: int = 8192) -> list[int]:
|
||||
"""Divisors of ``n`` built only from 2/3/5 -- the ones that decimate cleanly."""
|
||||
out = {1}
|
||||
for p in (2, 3, 5):
|
||||
cur = set(out)
|
||||
for v in cur:
|
||||
m = v
|
||||
while m * p <= limit and n % (m * p) == 0:
|
||||
m *= p
|
||||
out.add(m)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def plan_rates(sample_rate: int, bandwidth: float, audio_rate: int,
|
||||
min_if_factor: float = 2.5) -> tuple[int, int, int, int]:
|
||||
"""Choose IF and audio decimation for one demod chain.
|
||||
|
||||
Returns ``(if_decim, audio_decim, if_rate, actual_audio_rate)``. The IF
|
||||
rate is kept at least ``min_if_factor`` times the signal bandwidth so the
|
||||
discriminator is not clipped by its own anti-alias filter.
|
||||
"""
|
||||
sample_rate = int(sample_rate)
|
||||
divisors = _small_prime_divisors(sample_rate)
|
||||
|
||||
total = min(divisors, key=lambda d: abs(sample_rate / d - audio_rate))
|
||||
actual_audio = int(round(sample_rate / total))
|
||||
|
||||
need_if = max(bandwidth * min_if_factor, actual_audio * 2.0)
|
||||
if_decim = 1
|
||||
for d in divisors:
|
||||
if d > total:
|
||||
break
|
||||
if sample_rate / d >= need_if and total % d == 0:
|
||||
if_decim = d
|
||||
audio_decim = max(1, total // if_decim)
|
||||
if_rate = int(round(sample_rate / if_decim))
|
||||
return if_decim, audio_decim, if_rate, actual_audio
|
||||
|
||||
|
||||
class _DCBlock:
|
||||
"""One-pole DC blocker, keeps voice from riding on a slow offset."""
|
||||
|
||||
def __init__(self, r: float = 0.995):
|
||||
self.r = r
|
||||
self._x1 = 0.0
|
||||
self._y1 = 0.0
|
||||
|
||||
def __call__(self, x: np.ndarray) -> np.ndarray:
|
||||
y, zf = sps.lfilter([1.0, -1.0], [1.0, -self.r], x,
|
||||
zi=[self.r * self._y1 - self._x1])
|
||||
self._x1 = float(x[-1]) if x.size else self._x1
|
||||
self._y1 = float(y[-1]) if y.size else self._y1
|
||||
return y
|
||||
|
||||
|
||||
class _AGC:
|
||||
"""Slow level control so quiet and loud transmissions land at similar volume."""
|
||||
|
||||
def __init__(self, target: float = 0.25, attack: float = 0.02,
|
||||
release: float = 0.002, max_gain: float = 200.0):
|
||||
self.target = target
|
||||
self.attack = attack
|
||||
self.release = release
|
||||
self.max_gain = max_gain
|
||||
self.gain = 1.0
|
||||
|
||||
def __call__(self, x: np.ndarray) -> np.ndarray:
|
||||
if x.size == 0:
|
||||
return x
|
||||
level = float(np.sqrt(np.mean(x.astype(np.float64) ** 2))) + 1e-9
|
||||
want = min(self.max_gain, self.target / level)
|
||||
a = self.attack if want < self.gain else self.release
|
||||
self.gain = (1.0 - a) * self.gain + a * want
|
||||
return x * self.gain
|
||||
|
||||
|
||||
@dataclass
|
||||
class Demodulator:
|
||||
"""Base class -- subclasses implement :meth:`_demod`."""
|
||||
|
||||
sample_rate: int
|
||||
bandwidth: float
|
||||
audio_rate: int = 16_000
|
||||
mode: str = "raw"
|
||||
|
||||
def __post_init__(self):
|
||||
self.if_decim, self.audio_decim, self.if_rate, self.audio_rate = plan_rates(
|
||||
self.sample_rate, self.bandwidth, self.audio_rate,
|
||||
min_if_factor=self._min_if_factor()
|
||||
)
|
||||
self.if_chain = DecimationChain(self.if_decim, complex_input=True)
|
||||
self.audio_chain = DecimationChain(self.audio_decim, complex_input=False)
|
||||
self.dc = _DCBlock()
|
||||
self.agc = _AGC()
|
||||
self._setup()
|
||||
|
||||
def _min_if_factor(self) -> float:
|
||||
return 2.5
|
||||
|
||||
def _setup(self) -> None:
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
self.if_chain.reset()
|
||||
self.audio_chain.reset()
|
||||
self.dc = _DCBlock()
|
||||
self.agc = _AGC()
|
||||
self._setup()
|
||||
|
||||
# -- pipeline ------------------------------------------------------
|
||||
def to_if(self, x: np.ndarray) -> np.ndarray:
|
||||
"""Complex baseband at the IF rate -- also what the classifier sees."""
|
||||
return self.if_chain(x)
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
raise NotImplementedError
|
||||
|
||||
def _post(self, audio: np.ndarray) -> np.ndarray:
|
||||
return audio
|
||||
|
||||
def step(self, x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return ``(audio, if_iq)``.
|
||||
|
||||
The recorder wants both: audio to write, and the IF-rate complex
|
||||
baseband to hand to the classifier.
|
||||
"""
|
||||
empty_a = np.zeros(0, dtype=np.float32)
|
||||
empty_i = np.zeros(0, dtype=np.complex64)
|
||||
if x.size == 0:
|
||||
return empty_a, empty_i
|
||||
iq = self.to_if(x)
|
||||
if iq.size == 0:
|
||||
return empty_a, empty_i
|
||||
audio = self._demod(iq)
|
||||
audio = self._post(audio)
|
||||
audio = self.audio_chain(audio)
|
||||
if audio.size == 0:
|
||||
return empty_a, iq
|
||||
audio = self.dc(audio)
|
||||
audio = self.agc(audio)
|
||||
return np.clip(audio, -1.0, 1.0).astype(np.float32), iq
|
||||
|
||||
def process(self, x: np.ndarray) -> np.ndarray:
|
||||
return self.step(x)[0]
|
||||
|
||||
|
||||
class FMDemod(Demodulator):
|
||||
"""Quadrature discriminator with optional de-emphasis."""
|
||||
|
||||
deemphasis: float | None = DEEMPHASIS_US
|
||||
|
||||
def _min_if_factor(self) -> float:
|
||||
return 2.0
|
||||
|
||||
def _setup(self) -> None:
|
||||
self._last = np.complex64(0)
|
||||
tau = getattr(self, "deemphasis", DEEMPHASIS_US)
|
||||
if tau:
|
||||
a = math.exp(-1.0 / (tau * self.if_rate))
|
||||
self._de_b = [1.0 - a]
|
||||
self._de_a = [1.0, -a]
|
||||
self._de_zi = np.zeros(1)
|
||||
else:
|
||||
self._de_b = None
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
prev = np.empty(x.size, dtype=np.complex64)
|
||||
prev[0] = self._last
|
||||
prev[1:] = x[:-1]
|
||||
self._last = x[-1]
|
||||
# Scale so full deviation maps to roughly +-1.
|
||||
gain = self.if_rate / (2.0 * math.pi * max(1.0, self.bandwidth / 2.0))
|
||||
return np.angle(x * np.conj(prev)).astype(np.float64) * gain
|
||||
|
||||
def _post(self, audio: np.ndarray) -> np.ndarray:
|
||||
if self._de_b is None:
|
||||
return audio
|
||||
out, self._de_zi = sps.lfilter(self._de_b, self._de_a, audio, zi=self._de_zi)
|
||||
return out
|
||||
|
||||
|
||||
class NFMDemod(FMDemod):
|
||||
pass
|
||||
|
||||
|
||||
class WFMDemod(FMDemod):
|
||||
def _min_if_factor(self) -> float:
|
||||
return 1.4 # 180 kHz signal in a 256 kHz IF is normal for broadcast FM
|
||||
|
||||
|
||||
class AMDemod(Demodulator):
|
||||
"""Envelope detector. Works for airband, shortwave and CB AM."""
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
return np.abs(x).astype(np.float64)
|
||||
|
||||
|
||||
class SSBDemod(Demodulator):
|
||||
"""Single-sideband via a one-sided complex band-pass, then take the real part."""
|
||||
|
||||
upper: bool = True
|
||||
|
||||
def _setup(self) -> None:
|
||||
self._zi = None
|
||||
self._taps = None
|
||||
|
||||
def _sideband_taps(self):
|
||||
if self._taps is None:
|
||||
lo, hi = (200.0, self.bandwidth) if getattr(self, "upper", True) \
|
||||
else (-self.bandwidth, -200.0)
|
||||
centre = 0.5 * (lo + hi)
|
||||
half = 0.5 * abs(hi - lo)
|
||||
proto = sps.firwin(129, min(half / (self.if_rate / 2.0), 0.99))
|
||||
n = np.arange(proto.size) - proto.size // 2
|
||||
self._taps = (proto * np.exp(2j * math.pi * centre * n / self.if_rate))
|
||||
self._zi = np.zeros(self._taps.size - 1, dtype=np.complex128)
|
||||
return self._taps
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
taps = self._sideband_taps()
|
||||
y, self._zi = sps.lfilter(taps, [1.0], x, zi=self._zi)
|
||||
return 2.0 * y.real
|
||||
|
||||
|
||||
class USBDemod(SSBDemod):
|
||||
upper = True
|
||||
|
||||
|
||||
class LSBDemod(SSBDemod):
|
||||
upper = False
|
||||
|
||||
|
||||
class CWDemod(SSBDemod):
|
||||
"""SSB with a narrow filter and a beat-frequency offset, so CW is audible."""
|
||||
|
||||
upper = True
|
||||
bfo_hz: float = 700.0
|
||||
|
||||
def _setup(self) -> None:
|
||||
self._zi = None
|
||||
self._taps = None
|
||||
self._mixer = Mixer(-getattr(self, "bfo_hz", 700.0), self.if_rate)
|
||||
|
||||
def _sideband_taps(self):
|
||||
if self._taps is None:
|
||||
bfo = getattr(self, "bfo_hz", 700.0)
|
||||
half = max(150.0, self.bandwidth / 2.0)
|
||||
lo, hi = bfo - half, bfo + half
|
||||
centre = 0.5 * (lo + hi)
|
||||
proto = sps.firwin(255, min((0.5 * (hi - lo)) / (self.if_rate / 2.0), 0.99))
|
||||
n = np.arange(proto.size) - proto.size // 2
|
||||
self._taps = (proto * np.exp(2j * math.pi * centre * n / self.if_rate))
|
||||
self._zi = np.zeros(self._taps.size - 1, dtype=np.complex128)
|
||||
return self._taps
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
# Shift the (near-DC) carrier up to the BFO pitch before filtering.
|
||||
x = self._mixer(x)
|
||||
taps = self._sideband_taps()
|
||||
y, self._zi = sps.lfilter(taps, [1.0], x, zi=self._zi)
|
||||
return 2.0 * y.real
|
||||
|
||||
|
||||
class DSBDemod(Demodulator):
|
||||
"""Coherent-ish DSB / product detector; also a usable fallback for raw IQ."""
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
return x.real.astype(np.float64) * 2.0
|
||||
|
||||
|
||||
class RawDemod(Demodulator):
|
||||
"""No demodulation -- magnitude only, so 'listening' to data still shows activity."""
|
||||
|
||||
def _demod(self, x: np.ndarray) -> np.ndarray:
|
||||
return np.abs(x).astype(np.float64)
|
||||
|
||||
|
||||
_CLASSES = {
|
||||
"nfm": NFMDemod,
|
||||
"wfm": WFMDemod,
|
||||
"fm": NFMDemod,
|
||||
"am": AMDemod,
|
||||
"usb": USBDemod,
|
||||
"lsb": LSBDemod,
|
||||
"ssb": USBDemod,
|
||||
"cw": CWDemod,
|
||||
"dsb": DSBDemod,
|
||||
"raw": RawDemod,
|
||||
}
|
||||
|
||||
|
||||
# Broadcast FM carries content well past the 8 kHz a 16 kHz audio rate can
|
||||
# hold, so it gets a higher floor than the narrowband modes.
|
||||
_MIN_AUDIO_RATE = {"wfm": 32_000}
|
||||
|
||||
|
||||
def make_demodulator(mode: str, sample_rate: int, bandwidth: float,
|
||||
audio_rate: int = 16_000, **kw) -> Demodulator:
|
||||
mode = (mode or "nfm").lower()
|
||||
audio_rate = max(audio_rate, _MIN_AUDIO_RATE.get(mode, 0))
|
||||
cls = _CLASSES.get(mode, RawDemod)
|
||||
obj = cls(sample_rate=sample_rate, bandwidth=bandwidth,
|
||||
audio_rate=audio_rate, mode=mode)
|
||||
for k, v in kw.items():
|
||||
setattr(obj, k, v)
|
||||
if kw:
|
||||
obj.reset()
|
||||
return obj
|
||||
480
bandsaunter/device.py
Executable file
480
bandsaunter/device.py
Executable file
|
|
@ -0,0 +1,480 @@
|
|||
"""High level RTL-SDR device control built on the ctypes binding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import ctypes
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._quiet import suppress_stderr
|
||||
from .librtlsdr import READ_ASYNC_CB, LibRtlSdr, RtlSdrError
|
||||
|
||||
__all__ = ["RtlSdrDevice", "DeviceInfo", "list_devices", "RtlSdrError",
|
||||
"quiet_driver", "set_driver_messages",
|
||||
"R820T_MIN_HZ", "R820T_MAX_HZ", "DIRECT_SAMPLING_MAX_HZ"]
|
||||
|
||||
# Practical tuning limits for the common R820T/R828D front ends. Anything
|
||||
# below this needs direct sampling (bypassing the tuner entirely).
|
||||
R820T_MIN_HZ = 24_000_000
|
||||
R820T_MAX_HZ = 1_766_000_000
|
||||
DIRECT_SAMPLING_MAX_HZ = 28_800_000 # usable Q-branch span at 2.4 MSPS-ish clock
|
||||
|
||||
# The RTL2832U only locks these sample-rate windows.
|
||||
_RATE_WINDOWS = ((225_001, 300_000), (900_001, 3_200_000))
|
||||
|
||||
# librtlsdr wants read sizes that are a multiple of 512 bytes; 16 kB blocks are
|
||||
# the size its own async reader uses and behave well over USB.
|
||||
_READ_GRANULE = 512
|
||||
|
||||
|
||||
# librtlsdr writes its own messages ("Found Rafael Micro R820T tuner",
|
||||
# "Allocating 15 zero-copy buffers", "[R82XX] PLL not locked!") straight to
|
||||
# file descriptor 2 from C. Those land in the middle of the live display and
|
||||
# break its cursor tracking, so the header ends up drawn several times over.
|
||||
# Set BANDSAUNTER_DRIVER_MESSAGES=1 to see them while debugging.
|
||||
_SHOW_DRIVER_MESSAGES = os.environ.get("BANDSAUNTER_DRIVER_MESSAGES", "") not in \
|
||||
("", "0", "no", "false")
|
||||
|
||||
|
||||
def set_driver_messages(show: bool) -> None:
|
||||
"""Let the driver's own messages through again.
|
||||
|
||||
They are worth seeing while diagnosing a device -- "Detached kernel
|
||||
driver", the tuner it found -- and only a nuisance during the live
|
||||
display, which they draw straight over.
|
||||
"""
|
||||
global _SHOW_DRIVER_MESSAGES
|
||||
_SHOW_DRIVER_MESSAGES = bool(show)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def quiet_driver():
|
||||
"""Silence the driver's own chatter for the duration of a C call.
|
||||
|
||||
Only around the call itself, so a Python traceback on stderr is never
|
||||
swallowed: the window is a few microseconds of library code.
|
||||
"""
|
||||
with suppress_stderr(not _SHOW_DRIVER_MESSAGES):
|
||||
yield
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
index: int
|
||||
name: str
|
||||
manufacturer: str = "?"
|
||||
product: str = "?"
|
||||
serial: str = "?"
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"[{self.index}] {self.name} "
|
||||
f"(sn: {self.serial})")
|
||||
|
||||
|
||||
def list_devices() -> list[DeviceInfo]:
|
||||
"""Enumerate attached RTL-SDR devices (empty list if none / no driver)."""
|
||||
try:
|
||||
lib = LibRtlSdr()
|
||||
except RtlSdrError:
|
||||
return []
|
||||
out = []
|
||||
with quiet_driver():
|
||||
for i in range(lib.device_count()):
|
||||
manu, prod, serial = lib.usb_strings(i)
|
||||
out.append(DeviceInfo(i, lib.device_name(i), manu, prod, serial))
|
||||
return out
|
||||
|
||||
|
||||
def clamp_sample_rate(rate: int) -> int:
|
||||
"""Snap a requested sample rate into a window the RTL2832U can actually lock."""
|
||||
rate = int(rate)
|
||||
for lo, hi in _RATE_WINDOWS:
|
||||
if lo <= rate <= hi:
|
||||
return rate
|
||||
# Choose the nearest legal edge.
|
||||
edges = [e for w in _RATE_WINDOWS for e in w]
|
||||
return min(edges, key=lambda e: abs(e - rate))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RtlSdrDevice:
|
||||
"""Owns one dongle and serialises all access to it.
|
||||
|
||||
The scanner retunes constantly, so this class keeps track of the settling
|
||||
cost of each retune and flushes the USB pipeline so that samples handed
|
||||
back are guaranteed to have been captured *after* the tune completed.
|
||||
"""
|
||||
|
||||
index: int = 0
|
||||
sample_rate: int = 2_048_000
|
||||
gain: float | str = "auto" # dB, or "auto" for the tuner AGC
|
||||
ppm: int = 0
|
||||
agc: bool = False # RTL2832 digital AGC
|
||||
bias_tee: bool = False
|
||||
offset_tuning: bool = False
|
||||
direct_sampling: int | str = "auto" # 0/1/2 or "auto" (enable below 24 MHz)
|
||||
settle_seconds: float = 0.006
|
||||
|
||||
_lib: LibRtlSdr | None = field(default=None, init=False, repr=False)
|
||||
_dev: ctypes.c_void_p | None = field(default=None, init=False, repr=False)
|
||||
_buf: ctypes.Array | None = field(default=None, init=False, repr=False)
|
||||
_buf_len: int = field(default=0, init=False, repr=False)
|
||||
_ds_mode: int = field(default=0, init=False, repr=False)
|
||||
_center: int = field(default=0, init=False, repr=False)
|
||||
_gains: list[float] = field(default_factory=list, init=False, repr=False)
|
||||
tuner: str = field(default="unknown", init=False)
|
||||
retunes: int = field(default=0, init=False)
|
||||
samples_read: int = field(default=0, init=False)
|
||||
_streaming: bool = field(default=False, init=False, repr=False)
|
||||
_stream_thread: object = field(default=None, init=False, repr=False)
|
||||
_stream_cb: object = field(default=None, init=False, repr=False)
|
||||
_stream_chunks: object = field(default=None, init=False, repr=False)
|
||||
_stream_lock: object = field(default=None, init=False, repr=False)
|
||||
_stream_ready: object = field(default=None, init=False, repr=False)
|
||||
_stream_error: object = field(default=None, init=False, repr=False)
|
||||
_stream_have: int = field(default=0, init=False, repr=False)
|
||||
_stream_max: int = field(default=0, init=False, repr=False)
|
||||
_stream_dropped: int = field(default=0, init=False, repr=False)
|
||||
_partial: object = field(default=None, init=False, repr=False)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------
|
||||
def open(self) -> "RtlSdrDevice":
|
||||
self._lib = LibRtlSdr()
|
||||
n = self._lib.device_count()
|
||||
if n == 0:
|
||||
raise RtlSdrError(
|
||||
"no RTL-SDR device found.\n"
|
||||
"Check `lsusb` for a Realtek RTL2832/2838, make sure the DVB-T "
|
||||
"kernel module is blacklisted, and that you have permission to "
|
||||
"open the USB device."
|
||||
)
|
||||
if self.index >= n:
|
||||
raise RtlSdrError(f"device index {self.index} out of range (found {n})")
|
||||
|
||||
with quiet_driver():
|
||||
self._dev = self._lib.open(self.index)
|
||||
self.tuner = self._lib.get_tuner_type(self._dev)
|
||||
self._gains = self._lib.get_tuner_gains(self._dev)
|
||||
|
||||
self.sample_rate = clamp_sample_rate(self.sample_rate)
|
||||
self._lib.set_sample_rate(self._dev, self.sample_rate)
|
||||
self.sample_rate = (self._lib.get_sample_rate(self._dev)
|
||||
or self.sample_rate)
|
||||
|
||||
if self.ppm:
|
||||
self._lib.set_freq_correction(self._dev, self.ppm)
|
||||
self._lib.set_agc_mode(self._dev, self.agc)
|
||||
if self.bias_tee:
|
||||
self._lib.set_bias_tee(self._dev, True)
|
||||
if self.offset_tuning:
|
||||
self._lib.set_offset_tuning(self._dev, True)
|
||||
|
||||
self.apply_gain(self.gain)
|
||||
self._lib.reset_buffer(self._dev)
|
||||
return self
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop_stream()
|
||||
if self._dev is not None and self._lib is not None:
|
||||
try:
|
||||
if self.bias_tee:
|
||||
self._lib.set_bias_tee(self._dev, False)
|
||||
except RtlSdrError:
|
||||
pass
|
||||
with quiet_driver():
|
||||
self._lib.close(self._dev)
|
||||
self._dev = None
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._dev is not None
|
||||
|
||||
# -- configuration -------------------------------------------------
|
||||
@property
|
||||
def available_gains(self) -> list[float]:
|
||||
return list(self._gains)
|
||||
|
||||
def apply_gain(self, gain: float | str) -> None:
|
||||
"""Set tuner gain. ``"auto"`` hands control to the tuner AGC."""
|
||||
self.gain = gain
|
||||
if isinstance(gain, str) and gain.lower() in ("auto", "agc", ""):
|
||||
self._lib.set_tuner_gain_mode(self._dev, False)
|
||||
return
|
||||
self._lib.set_tuner_gain_mode(self._dev, True)
|
||||
target = float(gain)
|
||||
if self._gains:
|
||||
target = min(self._gains, key=lambda g: abs(g - target))
|
||||
self._lib.set_tuner_gain(self._dev, target)
|
||||
|
||||
@property
|
||||
def current_gain(self) -> float | str:
|
||||
if isinstance(self.gain, str):
|
||||
return "auto"
|
||||
try:
|
||||
return self._lib.get_tuner_gain(self._dev)
|
||||
except RtlSdrError:
|
||||
return self.gain
|
||||
|
||||
def set_sample_rate(self, rate: int) -> int:
|
||||
rate = clamp_sample_rate(rate)
|
||||
if rate != self.sample_rate:
|
||||
with quiet_driver():
|
||||
self._lib.set_sample_rate(self._dev, rate)
|
||||
self.sample_rate = self._lib.get_sample_rate(self._dev) or rate
|
||||
self._lib.reset_buffer(self._dev)
|
||||
return self.sample_rate
|
||||
|
||||
def _wants_direct_sampling(self, hz: float) -> int:
|
||||
if self.direct_sampling == "auto":
|
||||
return 2 if hz < R820T_MIN_HZ else 0
|
||||
return int(self.direct_sampling)
|
||||
|
||||
def _set_direct_sampling(self, mode: int) -> None:
|
||||
if mode == self._ds_mode:
|
||||
return
|
||||
with quiet_driver():
|
||||
ok = self._lib.set_direct_sampling(self._dev, mode)
|
||||
if ok:
|
||||
self._ds_mode = mode
|
||||
# Switching the signal path invalidates whatever is in flight.
|
||||
self._lib.reset_buffer(self._dev)
|
||||
elif mode:
|
||||
raise RtlSdrError(
|
||||
"this dongle does not support direct sampling, so frequencies "
|
||||
f"below {R820T_MIN_HZ/1e6:.0f} MHz cannot be tuned"
|
||||
)
|
||||
|
||||
def tune(self, hz: float, settle: bool = True) -> int:
|
||||
"""Tune the front end. Returns the frequency the hardware reports."""
|
||||
hz = int(round(hz))
|
||||
mode = self._wants_direct_sampling(hz)
|
||||
self._set_direct_sampling(mode)
|
||||
|
||||
if mode == 0 and not (R820T_MIN_HZ <= hz <= R820T_MAX_HZ):
|
||||
raise RtlSdrError(
|
||||
f"{hz/1e6:.4f} MHz is outside the tuner's range "
|
||||
f"({R820T_MIN_HZ/1e6:.0f}-{R820T_MAX_HZ/1e6:.0f} MHz)"
|
||||
)
|
||||
|
||||
with quiet_driver():
|
||||
self._lib.set_center_freq(self._dev, hz)
|
||||
self._center = self._lib.get_center_freq(self._dev) or hz
|
||||
self.retunes += 1
|
||||
if settle and self.settle_seconds > 0:
|
||||
time.sleep(self.settle_seconds)
|
||||
return self._center
|
||||
|
||||
@property
|
||||
def center_freq(self) -> int:
|
||||
return self._center
|
||||
|
||||
@property
|
||||
def direct_sampling_mode(self) -> int:
|
||||
"""0 = normal tuner path, 1 = I branch, 2 = Q branch."""
|
||||
return self._ds_mode
|
||||
|
||||
def can_tune(self, hz: float) -> bool:
|
||||
mode = self._wants_direct_sampling(hz)
|
||||
if mode:
|
||||
return 0 < hz <= DIRECT_SAMPLING_MAX_HZ
|
||||
return R820T_MIN_HZ <= hz <= R820T_MAX_HZ
|
||||
|
||||
# -- capture -------------------------------------------------------
|
||||
def _ensure_buffer(self, n_bytes: int) -> ctypes.Array:
|
||||
if self._buf is None or self._buf_len < n_bytes:
|
||||
self._buf = (ctypes.c_ubyte * n_bytes)()
|
||||
self._buf_len = n_bytes
|
||||
return self._buf
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Drop anything the RTL2832 already queued (call right after a retune)."""
|
||||
self._lib.reset_buffer(self._dev)
|
||||
|
||||
def read_samples(self, count: int, flush: bool = False) -> np.ndarray:
|
||||
"""Read ``count`` complex samples, returned as complex64 in [-1, 1).
|
||||
|
||||
``flush=True`` discards the in-flight USB buffer first so the samples
|
||||
are known to post-date the most recent tune.
|
||||
"""
|
||||
if self._dev is None:
|
||||
raise RtlSdrError("device is not open")
|
||||
if flush:
|
||||
self._lib.reset_buffer(self._dev)
|
||||
|
||||
n_bytes = int(count) * 2
|
||||
# Round up to the USB granule; librtlsdr short-reads otherwise.
|
||||
n_bytes = int(math.ceil(n_bytes / _READ_GRANULE) * _READ_GRANULE)
|
||||
buf = self._ensure_buffer(n_bytes)
|
||||
|
||||
got = self._lib.read_sync(self._dev, buf, n_bytes)
|
||||
if got <= 0:
|
||||
raise RtlSdrError("USB read returned no data")
|
||||
|
||||
raw = np.frombuffer(buf, dtype=np.uint8, count=got)
|
||||
if raw.size % 2:
|
||||
raw = raw[:-1]
|
||||
# 127.4 rather than 127.5 matches the RTL2832's actual DC bias.
|
||||
iq = raw.astype(np.float32)
|
||||
iq -= 127.4
|
||||
iq *= np.float32(1.0 / 128.0)
|
||||
out = iq.view(np.complex64) if iq.flags.c_contiguous else iq.copy().view(np.complex64)
|
||||
out = out[:count]
|
||||
self.samples_read += out.size
|
||||
return out
|
||||
|
||||
def read_seconds(self, seconds: float, flush: bool = False) -> np.ndarray:
|
||||
return self.read_samples(int(self.sample_rate * seconds), flush=flush)
|
||||
|
||||
# -- continuous streaming --------------------------------------------
|
||||
# Synchronous reads lose whatever the dongle sends between calls: the USB
|
||||
# host only moves data while a transfer is outstanding, so every
|
||||
# microsecond spent demodulating is a microsecond of samples thrown away.
|
||||
# The recording then holds less than it should and plays back too fast.
|
||||
# Streaming keeps a ring of transfers queued so the capture is gapless.
|
||||
|
||||
def start_stream(self, buf_num: int = 15, buf_len: int = 262144,
|
||||
max_seconds: float = 4.0) -> None:
|
||||
if self._streaming:
|
||||
return
|
||||
if self._dev is None:
|
||||
raise RtlSdrError("device is not open")
|
||||
self._stream_chunks = deque()
|
||||
self._stream_have = 0
|
||||
self._stream_max = int(max_seconds * self.sample_rate * 2)
|
||||
self._stream_dropped = 0
|
||||
self._stream_lock = threading.Lock()
|
||||
self._stream_ready = threading.Event()
|
||||
self._stream_error = None
|
||||
self._partial = np.zeros(0, dtype=np.complex64)
|
||||
|
||||
def _on_buffer(buf_ptr, length, _ctx):
|
||||
try:
|
||||
raw = np.ctypeslib.as_array(buf_ptr, shape=(int(length),)).copy()
|
||||
except Exception:
|
||||
return
|
||||
with self._stream_lock:
|
||||
if self._stream_have + raw.size > self._stream_max:
|
||||
# The consumer has fallen behind for real; drop the oldest
|
||||
# so the capture stays current, and record that it happened.
|
||||
while self._stream_chunks and \
|
||||
self._stream_have + raw.size > self._stream_max:
|
||||
old = self._stream_chunks.popleft()
|
||||
self._stream_have -= old.size
|
||||
self._stream_dropped += old.size
|
||||
self._stream_chunks.append(raw)
|
||||
self._stream_have += raw.size
|
||||
self._stream_ready.set()
|
||||
|
||||
self._stream_cb = READ_ASYNC_CB(_on_buffer)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
with quiet_driver():
|
||||
self._lib.read_async(self._dev, self._stream_cb,
|
||||
buf_num, buf_len)
|
||||
except RtlSdrError as exc:
|
||||
self._stream_error = exc
|
||||
self._stream_ready.set()
|
||||
|
||||
with quiet_driver():
|
||||
self._lib.reset_buffer(self._dev)
|
||||
self._streaming = True
|
||||
self._stream_thread = threading.Thread(target=_run, daemon=True,
|
||||
name="rtlsdr-stream")
|
||||
self._stream_thread.start()
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
if not self._streaming:
|
||||
return
|
||||
self._streaming = False
|
||||
try:
|
||||
with quiet_driver():
|
||||
self._lib.cancel_async(self._dev)
|
||||
except Exception:
|
||||
pass
|
||||
if self._stream_thread is not None:
|
||||
self._stream_thread.join(timeout=2.0)
|
||||
self._stream_thread = None
|
||||
self._stream_cb = None
|
||||
|
||||
@property
|
||||
def streaming(self) -> bool:
|
||||
return self._streaming
|
||||
|
||||
@property
|
||||
def dropped_samples(self) -> int:
|
||||
return self._stream_dropped // 2
|
||||
|
||||
def read_stream(self, count: int, timeout: float = 3.0) -> np.ndarray:
|
||||
"""Pull ``count`` complex samples from the running stream."""
|
||||
if not self._streaming:
|
||||
return self.read_samples(count)
|
||||
need_bytes = int(count) * 2
|
||||
deadline = time.time() + timeout
|
||||
parts = []
|
||||
have = 0
|
||||
if self._partial.size:
|
||||
parts.append(self._partial)
|
||||
have += self._partial.size * 2
|
||||
self._partial = np.zeros(0, dtype=np.complex64)
|
||||
|
||||
while have < need_bytes:
|
||||
if self._stream_error is not None:
|
||||
raise self._stream_error
|
||||
with self._stream_lock:
|
||||
chunk = self._stream_chunks.popleft() if self._stream_chunks else None
|
||||
if chunk is not None:
|
||||
self._stream_have -= chunk.size
|
||||
elif not self._stream_chunks:
|
||||
self._stream_ready.clear()
|
||||
if chunk is None:
|
||||
if time.time() > deadline:
|
||||
raise RtlSdrError("timed out waiting for samples")
|
||||
self._stream_ready.wait(0.2)
|
||||
continue
|
||||
parts.append(self._to_complex(chunk))
|
||||
have += chunk.size
|
||||
|
||||
out = np.concatenate([p if p.dtype == np.complex64 else p for p in parts])
|
||||
if out.size > count:
|
||||
self._partial = out[count:]
|
||||
out = out[:count]
|
||||
self.samples_read += out.size
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _to_complex(raw: np.ndarray) -> np.ndarray:
|
||||
if raw.size % 2:
|
||||
raw = raw[:-1]
|
||||
iq = raw.astype(np.float32)
|
||||
iq -= 127.4
|
||||
iq *= np.float32(1.0 / 128.0)
|
||||
return iq.view(np.complex64)
|
||||
|
||||
# -- diagnostics ---------------------------------------------------
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"index": self.index,
|
||||
"tuner": self.tuner,
|
||||
"sample_rate": self.sample_rate,
|
||||
"center_freq": self._center,
|
||||
"gain": self.current_gain,
|
||||
"ppm": self.ppm,
|
||||
"direct_sampling": self._ds_mode,
|
||||
"retunes": self.retunes,
|
||||
"samples_read": self.samples_read,
|
||||
}
|
||||
443
bandsaunter/dsp.py
Executable file
443
bandsaunter/dsp.py
Executable file
|
|
@ -0,0 +1,443 @@
|
|||
"""Signal processing primitives shared by the sweeper and the recorder.
|
||||
|
||||
Everything here is stateless unless it has to be; the pieces that run across
|
||||
consecutive blocks of a live capture (decimators, filters, FM discriminator)
|
||||
carry explicit state so block boundaries do not produce clicks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
from scipy import ndimage as ndi
|
||||
|
||||
__all__ = [
|
||||
"welch_psd", "psd_dbfs", "noise_floor", "noise_floor_curve",
|
||||
"detector_bias_db",
|
||||
"NoiseFloorTracker",
|
||||
"FIRDecimator", "DecimationChain", "design_decimation",
|
||||
"frequency_shift", "Mixer", "bandpass_complex", "occupied_bandwidth",
|
||||
"spectral_flatness", "instantaneous_frequency", "envelope",
|
||||
"goertzel", "db", "next_fast_len", "estimate_snr",
|
||||
]
|
||||
|
||||
_EPS = 1e-20
|
||||
|
||||
|
||||
def db(x):
|
||||
return 10.0 * np.log10(np.maximum(np.asarray(x, dtype=np.float64), _EPS))
|
||||
|
||||
|
||||
def next_fast_len(n: int) -> int:
|
||||
"""Round up to a power of two (fast and predictable for our block sizes)."""
|
||||
return 1 << int(math.ceil(math.log2(max(2, n))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spectrum estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def welch_psd(x: np.ndarray, nfft: int, overlap: float = 0.5,
|
||||
window: str = "hann", max_segments: int = 64,
|
||||
combine: str = "mean"):
|
||||
"""Periodogram of a complex baseband block.
|
||||
|
||||
Returns ``(freqs_normalised, power_linear)`` with the spectrum shifted so
|
||||
index 0 is the most negative frequency. ``freqs_normalised`` runs over
|
||||
[-0.5, 0.5) and is scaled by the caller's sample rate.
|
||||
|
||||
``combine="mean"`` averages the segments, which gives the lowest-variance
|
||||
estimate of a steady signal. ``combine="max"`` keeps the strongest value
|
||||
seen in each bin instead -- essential while sweeping, because a keyed or
|
||||
bursty transmission (CW, packet, a short over) is off for part of the
|
||||
dwell and averaging buries it in the noise.
|
||||
"""
|
||||
x = np.asarray(x)
|
||||
nfft = int(nfft)
|
||||
if x.size < nfft:
|
||||
pad = np.zeros(nfft, dtype=np.complex64)
|
||||
pad[: x.size] = x
|
||||
x = pad
|
||||
|
||||
step = max(1, int(nfft * (1.0 - overlap)))
|
||||
n_seg = 1 + (x.size - nfft) // step
|
||||
if n_seg > max_segments:
|
||||
# Spread the segments across the whole block rather than truncating.
|
||||
idx = np.linspace(0, x.size - nfft, max_segments).astype(np.int64)
|
||||
n_seg = max_segments
|
||||
else:
|
||||
idx = np.arange(n_seg, dtype=np.int64) * step
|
||||
|
||||
win = sps.get_window(window, nfft).astype(np.float32)
|
||||
norm = np.sum(win.astype(np.float64) ** 2)
|
||||
|
||||
peak_hold = combine == "max"
|
||||
acc = np.zeros(nfft, dtype=np.float64)
|
||||
for start in idx:
|
||||
seg = x[start:start + nfft] * win
|
||||
spec = np.fft.fft(seg, nfft)
|
||||
mag2 = spec.real.astype(np.float64) ** 2 + spec.imag.astype(np.float64) ** 2
|
||||
if peak_hold:
|
||||
np.maximum(acc, mag2, out=acc)
|
||||
else:
|
||||
acc += mag2
|
||||
acc /= norm if peak_hold else (len(idx) * norm)
|
||||
|
||||
power = np.fft.fftshift(acc)
|
||||
freqs = np.fft.fftshift(np.fft.fftfreq(nfft, d=1.0))
|
||||
return freqs, power
|
||||
|
||||
|
||||
def psd_dbfs(x: np.ndarray, nfft: int, **kw):
|
||||
"""Welch PSD expressed in dB relative to a full-scale sine."""
|
||||
freqs, power = welch_psd(x, nfft, **kw)
|
||||
return freqs, db(power)
|
||||
|
||||
|
||||
def noise_floor(psd_db: np.ndarray, percentile: float = 30.0) -> float:
|
||||
"""Robust noise-floor estimate: a low percentile is immune to strong carriers."""
|
||||
if psd_db.size == 0:
|
||||
return -120.0
|
||||
return float(np.percentile(psd_db, percentile))
|
||||
|
||||
|
||||
def estimate_snr(psd_db: np.ndarray, floor_db: float | None = None) -> float:
|
||||
if floor_db is None:
|
||||
floor_db = noise_floor(psd_db)
|
||||
return float(np.max(psd_db) - floor_db)
|
||||
|
||||
|
||||
def noise_floor_curve(psd_db: np.ndarray, window: int = 0,
|
||||
percentile: float = 25.0) -> np.ndarray:
|
||||
"""Per-bin noise floor that follows the receiver passband shape.
|
||||
|
||||
A sliding low percentile along the frequency axis tracks the analogue
|
||||
filter roll-off and any tilt in the band, while stepping straight over
|
||||
signals: even if half the window is occupied, the 25th percentile still
|
||||
lands in the noise. Unlike a learned average this has no warm-up sweep
|
||||
and never hides a station just because it is always transmitting.
|
||||
"""
|
||||
psd_db = np.asarray(psd_db, dtype=np.float64)
|
||||
n = psd_db.size
|
||||
if n < 8:
|
||||
return np.full_like(psd_db, float(np.median(psd_db)))
|
||||
if window <= 0:
|
||||
window = max(9, n // 4)
|
||||
window = min(window, n) | 1
|
||||
return ndi.percentile_filter(psd_db, percentile, size=window, mode="nearest")
|
||||
|
||||
|
||||
def detector_bias_db(n_segments: int, n_bins: int,
|
||||
combine: str = "max") -> float:
|
||||
"""How far noise alone rides above the measured floor, in dB.
|
||||
|
||||
Peak-hold keeps the largest value each bin reached across the dwell, so a
|
||||
noise-only bin sits well above the floor that a percentile estimates --
|
||||
and a threshold quoted against that floor would fire on static. The
|
||||
offset is a property of the detector, not of the band: for a periodogram
|
||||
bin of Gaussian noise (exponentially distributed power), holding the max
|
||||
of ``n_segments`` and then looking for the largest of ``n_bins`` gives a
|
||||
predictable excess over the 25th percentile.
|
||||
|
||||
Deliberately not measured from the data. A spread estimated from the
|
||||
spectrum is inflated wherever signals are dense -- across the broadcast FM
|
||||
band it reads five times higher than on empty spectrum, which would
|
||||
suppress exactly the stations the scan is looking for.
|
||||
"""
|
||||
if combine != "max" or n_segments < 2:
|
||||
return 0.0
|
||||
k = float(max(2, n_segments))
|
||||
m = float(max(8, n_bins))
|
||||
p25 = -math.log1p(-0.25 ** (1.0 / k)) # 25th percentile of max-of-k
|
||||
tail = math.log(k * m) # roughly the largest of m bins
|
||||
ideal = 10.0 * math.log10(max(tail / p25, 1.0))
|
||||
# Real captures fall short of the independence the maths assumes -- the
|
||||
# segments overlap and the window correlates neighbouring bins -- so
|
||||
# measured noise runs a few dB above the ideal figure.
|
||||
return float(min(12.0, ideal + 3.0))
|
||||
|
||||
|
||||
@dataclass
|
||||
class NoiseFloorTracker:
|
||||
"""Smoothed per-bin noise floor for one tuning step.
|
||||
|
||||
The floor for each sweep comes from :func:`noise_floor_curve`; this class
|
||||
only averages it across sweeps so the threshold does not jitter with the
|
||||
randomness of a single short capture. Because the curve already excludes
|
||||
signals, averaging it cannot make a real transmission disappear.
|
||||
"""
|
||||
|
||||
alpha: float = 0.3 # weight of each new sweep
|
||||
window: int = 0
|
||||
percentile: float = 25.0
|
||||
floor: np.ndarray | None = field(default=None, init=False)
|
||||
sweeps: int = field(default=0, init=False)
|
||||
|
||||
def update(self, psd_db: np.ndarray) -> np.ndarray:
|
||||
curve = noise_floor_curve(psd_db, self.window, self.percentile)
|
||||
if self.floor is None or self.floor.shape != curve.shape:
|
||||
self.floor = curve
|
||||
else:
|
||||
self.floor = (1.0 - self.alpha) * self.floor + self.alpha * curve
|
||||
self.sweeps += 1
|
||||
return self.floor
|
||||
|
||||
def excess(self, psd_db: np.ndarray) -> np.ndarray:
|
||||
"""dB above the noise floor."""
|
||||
if self.floor is None:
|
||||
return np.zeros_like(psd_db)
|
||||
return np.asarray(psd_db, dtype=np.float64) - self.floor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decimation / filtering with state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FIRDecimator:
|
||||
"""Stateful low-pass + integer decimation for complex or real streams.
|
||||
|
||||
Keeps the filter delay line between calls, so feeding it consecutive blocks
|
||||
of a live capture produces exactly the same result as filtering the whole
|
||||
capture at once.
|
||||
|
||||
Only the samples that survive decimation are computed. Filtering at the
|
||||
input rate and then throwing away ``factor - 1`` of every ``factor``
|
||||
outputs does ``factor`` times the necessary work, and at 2 MS/s that alone
|
||||
was enough to stop the capture keeping up with real time -- which shows up
|
||||
as recordings that play too fast, because the samples the host could not
|
||||
keep up with are dropped by the driver.
|
||||
"""
|
||||
|
||||
def __init__(self, factor: int, taps_per_phase: int = 12,
|
||||
cutoff: float = 0.45, complex_input: bool = True,
|
||||
window: str = "hamming"):
|
||||
self.factor = int(factor)
|
||||
if self.factor < 1:
|
||||
raise ValueError("decimation factor must be >= 1")
|
||||
self.complex_input = bool(complex_input)
|
||||
if self.factor == 1:
|
||||
self.taps = None
|
||||
return
|
||||
ntaps = max(15, int(taps_per_phase * self.factor) | 1)
|
||||
self.taps = sps.firwin(ntaps, cutoff / self.factor,
|
||||
window=window).astype(np.float32)
|
||||
# Reversed once, so each output is a plain dot product.
|
||||
self._rtaps = np.ascontiguousarray(self.taps[::-1])
|
||||
self._dtype = np.complex64 if complex_input else np.float32
|
||||
self._tail = np.zeros(ntaps - 1, dtype=self._dtype)
|
||||
self._phase = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
if self.taps is not None:
|
||||
self._tail[:] = 0
|
||||
self._phase = 0
|
||||
|
||||
def __call__(self, x: np.ndarray) -> np.ndarray:
|
||||
if self.factor == 1:
|
||||
return x
|
||||
x = np.asarray(x, dtype=self._dtype)
|
||||
ntaps = self._rtaps.size
|
||||
buf = np.concatenate((self._tail, x))
|
||||
# y[n] = dot(buf[n : n + ntaps], reversed_taps) reproduces exactly what
|
||||
# filtering the whole stream would give, with the tail carrying the
|
||||
# delay line across the block boundary.
|
||||
start = (-self._phase) % self.factor
|
||||
self._tail = buf[-(ntaps - 1):].copy() if ntaps > 1 else self._tail
|
||||
self._phase = (self._phase + x.size) % self.factor
|
||||
if start >= x.size:
|
||||
return np.zeros(0, dtype=self._dtype)
|
||||
windows = np.lib.stride_tricks.sliding_window_view(buf, ntaps)
|
||||
return windows[start:x.size:self.factor] @ self._rtaps
|
||||
|
||||
|
||||
def design_decimation(factor: int, max_stage: int = 8) -> list[int]:
|
||||
"""Split a decimation factor into cascadable stages (cheapest first)."""
|
||||
factor = int(factor)
|
||||
if factor <= 1:
|
||||
return []
|
||||
stages = []
|
||||
remaining = factor
|
||||
for p in (8, 5, 4, 3, 2):
|
||||
while remaining % p == 0 and p <= max_stage:
|
||||
stages.append(p)
|
||||
remaining //= p
|
||||
if remaining > 1:
|
||||
stages.append(remaining)
|
||||
stages.sort(reverse=True)
|
||||
return stages
|
||||
|
||||
|
||||
class DecimationChain:
|
||||
"""Cascade of :class:`FIRDecimator` stages for a large total factor."""
|
||||
|
||||
def __init__(self, factor: int, complex_input: bool = True,
|
||||
taps_per_phase: int = 12):
|
||||
self.factor = int(factor)
|
||||
self.stages = [
|
||||
FIRDecimator(f, taps_per_phase=taps_per_phase,
|
||||
complex_input=complex_input)
|
||||
for f in design_decimation(self.factor)
|
||||
]
|
||||
|
||||
def reset(self) -> None:
|
||||
for s in self.stages:
|
||||
s.reset()
|
||||
|
||||
def __call__(self, x: np.ndarray) -> np.ndarray:
|
||||
for s in self.stages:
|
||||
if x.size == 0:
|
||||
return x
|
||||
x = s(x)
|
||||
return x
|
||||
|
||||
|
||||
class Mixer:
|
||||
"""Continuous-phase complex mixer (digital LO) for frequency translation.
|
||||
|
||||
A shift of exactly a quarter of the sample rate is special-cased: the
|
||||
rotation is then the four-step cycle 1, -j, -1, +j, so no trigonometry is
|
||||
needed at all. The scanner always offsets its local oscillator by exactly
|
||||
that, to keep the DC spike off the signal, so this is the path that runs.
|
||||
"""
|
||||
|
||||
_QUARTER = np.array([1.0 + 0.0j, -1.0j, -1.0 + 0.0j, 1.0j],
|
||||
dtype=np.complex64)
|
||||
|
||||
def __init__(self, shift_hz: float, sample_rate: float):
|
||||
self.sample_rate = float(sample_rate)
|
||||
self.shift_hz = float(shift_hz)
|
||||
self._phase = 0.0
|
||||
self._n = 0
|
||||
self._cache: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._phase = 0.0
|
||||
self._n = 0
|
||||
|
||||
def retune(self, shift_hz: float) -> None:
|
||||
self.shift_hz = float(shift_hz)
|
||||
self._cache.clear()
|
||||
|
||||
@property
|
||||
def _is_quarter_rate(self) -> bool:
|
||||
return abs(abs(self.shift_hz) * 4.0 - self.sample_rate) < 1e-6
|
||||
|
||||
def _quarter_pattern(self, n: int, phase: int) -> np.ndarray:
|
||||
key = (n, phase)
|
||||
pat = self._cache.get(key)
|
||||
if pat is None:
|
||||
idx = (np.arange(n, dtype=np.int64) + phase) & 3
|
||||
rot = self._QUARTER if self.shift_hz > 0 else self._QUARTER.conj()
|
||||
pat = rot[idx]
|
||||
if len(self._cache) < 8:
|
||||
self._cache[key] = pat
|
||||
return pat
|
||||
|
||||
def __call__(self, x: np.ndarray) -> np.ndarray:
|
||||
if self.shift_hz == 0.0:
|
||||
return x
|
||||
n = x.size
|
||||
if self._is_quarter_rate:
|
||||
out = x * self._quarter_pattern(n, self._n & 3)
|
||||
self._n = (self._n + n) & 3
|
||||
return out.astype(np.complex64, copy=False)
|
||||
step = -2.0 * math.pi * self.shift_hz / self.sample_rate
|
||||
ph = self._phase + step * np.arange(n, dtype=np.float64)
|
||||
self._phase = float((self._phase + step * n) % (2.0 * math.pi))
|
||||
return (x * np.exp(1j * ph)).astype(np.complex64)
|
||||
|
||||
|
||||
def frequency_shift(x: np.ndarray, shift_hz: float, sample_rate: float) -> np.ndarray:
|
||||
"""One-shot version of :class:`Mixer` for offline blocks."""
|
||||
n = np.arange(x.size, dtype=np.float64)
|
||||
lo = np.exp(-2j * math.pi * shift_hz * n / float(sample_rate))
|
||||
return (x * lo).astype(np.complex64)
|
||||
|
||||
|
||||
def bandpass_complex(x: np.ndarray, sample_rate: float,
|
||||
low_hz: float, high_hz: float, ntaps: int = 129) -> np.ndarray:
|
||||
"""Asymmetric (single-sideband capable) complex band-pass.
|
||||
|
||||
Designs a real low-pass of the target width and shifts it to sit between
|
||||
``low_hz`` and ``high_hz``, which may both be positive (USB) or both
|
||||
negative (LSB).
|
||||
"""
|
||||
centre = 0.5 * (low_hz + high_hz)
|
||||
half = max(1.0, 0.5 * abs(high_hz - low_hz))
|
||||
nyq = sample_rate / 2.0
|
||||
cutoff = min(half / nyq, 0.99)
|
||||
proto = sps.firwin(ntaps | 1, cutoff).astype(np.float64)
|
||||
n = np.arange(proto.size) - proto.size // 2
|
||||
taps = proto * np.exp(2j * math.pi * centre * n / sample_rate)
|
||||
return sps.lfilter(taps, [1.0], x).astype(np.complex64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Measurements used by the classifier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def occupied_bandwidth(psd_lin: np.ndarray, bin_hz: float,
|
||||
fraction: float = 0.99) -> tuple[float, float]:
|
||||
"""Return ``(bandwidth_hz, centre_offset_hz)`` holding ``fraction`` of power.
|
||||
|
||||
The centre offset is measured from the middle bin, so it doubles as a
|
||||
frequency-error estimate for retuning onto a detected signal.
|
||||
"""
|
||||
p = np.asarray(psd_lin, dtype=np.float64)
|
||||
if p.size == 0 or not np.any(p > 0):
|
||||
return 0.0, 0.0
|
||||
total = p.sum()
|
||||
csum = np.cumsum(p)
|
||||
lo_t = total * (1.0 - fraction) / 2.0
|
||||
hi_t = total * (1.0 - (1.0 - fraction) / 2.0)
|
||||
lo = int(np.searchsorted(csum, lo_t))
|
||||
hi = int(np.searchsorted(csum, hi_t))
|
||||
hi = min(hi, p.size - 1)
|
||||
bw = max(bin_hz, (hi - lo + 1) * bin_hz)
|
||||
|
||||
idx = np.arange(p.size, dtype=np.float64)
|
||||
centroid = float((idx * p).sum() / total)
|
||||
offset = (centroid - (p.size - 1) / 2.0) * bin_hz
|
||||
return bw, offset
|
||||
|
||||
|
||||
def spectral_flatness(psd_lin: np.ndarray) -> float:
|
||||
"""Wiener entropy: ~1 for noise-like/wideband digital, ~0 for a pure tone."""
|
||||
p = np.asarray(psd_lin, dtype=np.float64)
|
||||
p = p[p > 0]
|
||||
if p.size == 0:
|
||||
return 0.0
|
||||
gm = np.exp(np.mean(np.log(p)))
|
||||
am = np.mean(p)
|
||||
return float(gm / am) if am > 0 else 0.0
|
||||
|
||||
|
||||
def instantaneous_frequency(x: np.ndarray, sample_rate: float) -> np.ndarray:
|
||||
"""Discriminator output in Hz (the derivative of the unwrapped phase)."""
|
||||
if x.size < 2:
|
||||
return np.zeros(0, dtype=np.float64)
|
||||
prod = x[1:] * np.conj(x[:-1])
|
||||
return np.angle(prod) * (sample_rate / (2.0 * math.pi))
|
||||
|
||||
|
||||
def envelope(x: np.ndarray) -> np.ndarray:
|
||||
return np.abs(x).astype(np.float64)
|
||||
|
||||
|
||||
def goertzel(x: np.ndarray, sample_rate: float, target_hz: float) -> float:
|
||||
"""Power at a single frequency -- cheap tone detection (CTCSS, pilots)."""
|
||||
n = x.size
|
||||
if n == 0:
|
||||
return 0.0
|
||||
k = int(0.5 + (n * target_hz) / sample_rate)
|
||||
w = 2.0 * math.pi * k / n
|
||||
coeff = 2.0 * math.cos(w)
|
||||
s1 = s2 = 0.0
|
||||
xr = np.asarray(x, dtype=np.float64)
|
||||
for v in xr:
|
||||
s0 = v + coeff * s1 - s2
|
||||
s2, s1 = s1, s0
|
||||
return float(s1 * s1 + s2 * s2 - coeff * s1 * s2) / n
|
||||
338
bandsaunter/librtlsdr.py
Executable file
338
bandsaunter/librtlsdr.py
Executable file
|
|
@ -0,0 +1,338 @@
|
|||
"""Minimal ctypes binding to librtlsdr.
|
||||
|
||||
Only the subset of the API that the scanner needs is bound. Kept separate from
|
||||
``device.py`` so that the raw C surface stays easy to audit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
|
||||
__all__ = ["LibRtlSdr", "RtlSdrError", "load", "is_available", "load_error"]
|
||||
|
||||
|
||||
class RtlSdrError(RuntimeError):
|
||||
"""Raised when librtlsdr reports a failure or is unusable."""
|
||||
|
||||
|
||||
# librtlsdr returns negative errno-ish codes; these are the ones worth naming.
|
||||
_ERRNO_TEXT = {
|
||||
-1: "device handle is invalid",
|
||||
-2: "device not found or already claimed",
|
||||
-3: "operation not supported by this tuner",
|
||||
-5: "USB transfer error (cable, power, or driver problem)",
|
||||
-6: "device is busy",
|
||||
-12: "out of memory",
|
||||
}
|
||||
|
||||
|
||||
def _describe(code: int) -> str:
|
||||
return _ERRNO_TEXT.get(code, f"librtlsdr error {code}")
|
||||
|
||||
|
||||
_CANDIDATES = (
|
||||
"librtlsdr.so.2",
|
||||
"librtlsdr.so.0",
|
||||
"librtlsdr.so",
|
||||
"librtlsdr.dylib",
|
||||
"rtlsdr.dll",
|
||||
"librtlsdr.dll",
|
||||
)
|
||||
|
||||
_lib = None
|
||||
_load_error: str | None = None
|
||||
|
||||
|
||||
def _try_load():
|
||||
"""Locate and dlopen librtlsdr, returning (handle, error_message)."""
|
||||
tried = []
|
||||
|
||||
env = os.environ.get("BANDSAUNTER_LIBRTLSDR")
|
||||
names = ([env] if env else []) + list(_CANDIDATES)
|
||||
|
||||
found = ctypes.util.find_library("rtlsdr")
|
||||
if found:
|
||||
names.append(found)
|
||||
|
||||
for name in names:
|
||||
try:
|
||||
return ctypes.CDLL(name), None
|
||||
except OSError as exc: # pragma: no cover - platform dependent
|
||||
tried.append(f"{name}: {exc}")
|
||||
|
||||
hint = (
|
||||
"librtlsdr was not found. On Debian/Ubuntu install it with:\n"
|
||||
" sudo apt install rtl-sdr librtlsdr0\n"
|
||||
"On Fedora: sudo dnf install rtl-sdr\n"
|
||||
"On macOS: brew install librtlsdr\n"
|
||||
"Or set BANDSAUNTER_LIBRTLSDR=/path/to/librtlsdr.so"
|
||||
)
|
||||
return None, hint + "\n\nTried:\n " + "\n ".join(tried)
|
||||
|
||||
|
||||
# Callback librtlsdr invokes from its own thread for each filled buffer.
|
||||
READ_ASYNC_CB = ctypes.CFUNCTYPE(None, ctypes.POINTER(ctypes.c_ubyte),
|
||||
ctypes.c_uint32, ctypes.c_void_p)
|
||||
|
||||
|
||||
def _bind(lib):
|
||||
c = ctypes.c_int
|
||||
u32 = ctypes.c_uint32
|
||||
p = ctypes.c_void_p
|
||||
cp = ctypes.c_char_p
|
||||
|
||||
sig = {
|
||||
"rtlsdr_get_device_count": ([], ctypes.c_uint32),
|
||||
"rtlsdr_get_device_name": ([u32], cp),
|
||||
"rtlsdr_get_device_usb_strings": ([u32, cp, cp, cp], c),
|
||||
"rtlsdr_get_index_by_serial": ([cp], c),
|
||||
"rtlsdr_open": ([ctypes.POINTER(p), u32], c),
|
||||
"rtlsdr_close": ([p], c),
|
||||
"rtlsdr_set_center_freq": ([p, u32], c),
|
||||
"rtlsdr_get_center_freq": ([p], u32),
|
||||
"rtlsdr_set_freq_correction": ([p, c], c),
|
||||
"rtlsdr_get_freq_correction": ([p], c),
|
||||
"rtlsdr_get_tuner_type": ([p], c),
|
||||
"rtlsdr_get_tuner_gains": ([p, ctypes.POINTER(c)], c),
|
||||
"rtlsdr_set_tuner_gain": ([p, c], c),
|
||||
"rtlsdr_get_tuner_gain": ([p], c),
|
||||
"rtlsdr_set_tuner_gain_mode": ([p, c], c),
|
||||
"rtlsdr_set_tuner_bandwidth": ([p, u32], c),
|
||||
"rtlsdr_set_sample_rate": ([p, u32], c),
|
||||
"rtlsdr_get_sample_rate": ([p], u32),
|
||||
"rtlsdr_set_agc_mode": ([p, c], c),
|
||||
"rtlsdr_set_direct_sampling": ([p, c], c),
|
||||
"rtlsdr_get_direct_sampling": ([p], c),
|
||||
"rtlsdr_set_offset_tuning": ([p, c], c),
|
||||
"rtlsdr_get_offset_tuning": ([p], c),
|
||||
"rtlsdr_set_bias_tee": ([p, c], c),
|
||||
"rtlsdr_reset_buffer": ([p], c),
|
||||
"rtlsdr_read_sync": ([p, p, c, ctypes.POINTER(c)], c),
|
||||
"rtlsdr_read_async": ([p, READ_ASYNC_CB, p, u32, u32], c),
|
||||
"rtlsdr_cancel_async": ([p], c),
|
||||
}
|
||||
|
||||
missing = []
|
||||
for name, (argtypes, restype) in sig.items():
|
||||
fn = getattr(lib, name, None)
|
||||
if fn is None:
|
||||
missing.append(name)
|
||||
continue
|
||||
fn.argtypes = argtypes
|
||||
fn.restype = restype
|
||||
|
||||
# A few entry points only exist in newer librtlsdr; the wrapper degrades
|
||||
# gracefully for those rather than refusing to run.
|
||||
hard_required = {
|
||||
"rtlsdr_open",
|
||||
"rtlsdr_close",
|
||||
"rtlsdr_set_center_freq",
|
||||
"rtlsdr_set_sample_rate",
|
||||
"rtlsdr_read_sync",
|
||||
"rtlsdr_reset_buffer",
|
||||
}
|
||||
fatal = hard_required.intersection(missing)
|
||||
if fatal:
|
||||
raise RtlSdrError(
|
||||
"librtlsdr is missing required symbols: " + ", ".join(sorted(fatal))
|
||||
)
|
||||
return lib
|
||||
|
||||
|
||||
def load():
|
||||
"""Return the loaded librtlsdr handle, raising RtlSdrError if unavailable."""
|
||||
global _lib, _load_error
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if _load_error is not None:
|
||||
raise RtlSdrError(_load_error)
|
||||
lib, err = _try_load()
|
||||
if lib is None:
|
||||
_load_error = err
|
||||
raise RtlSdrError(err)
|
||||
_lib = _bind(lib)
|
||||
return _lib
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
try:
|
||||
load()
|
||||
return True
|
||||
except RtlSdrError:
|
||||
return False
|
||||
|
||||
|
||||
def load_error() -> str | None:
|
||||
"""The dlopen failure message, or None if the library loaded fine."""
|
||||
try:
|
||||
load()
|
||||
return None
|
||||
except RtlSdrError as exc:
|
||||
return str(exc)
|
||||
|
||||
|
||||
class LibRtlSdr:
|
||||
"""Thin, checked wrapper around the C calls.
|
||||
|
||||
Every method raises :class:`RtlSdrError` on a negative return code so the
|
||||
layers above can assume success.
|
||||
"""
|
||||
|
||||
TUNER_NAMES = {
|
||||
0: "unknown",
|
||||
1: "Elonics E4000",
|
||||
2: "Fitipower FC0012",
|
||||
3: "Fitipower FC0013",
|
||||
4: "FCI FC2580",
|
||||
5: "Rafael Micro R820T/R820T2",
|
||||
6: "Rafael Micro R828D",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.lib = load()
|
||||
|
||||
# -- enumeration ---------------------------------------------------
|
||||
def device_count(self) -> int:
|
||||
return int(self.lib.rtlsdr_get_device_count())
|
||||
|
||||
def device_name(self, index: int) -> str:
|
||||
name = self.lib.rtlsdr_get_device_name(index)
|
||||
return name.decode("utf-8", "replace") if name else "?"
|
||||
|
||||
def usb_strings(self, index: int):
|
||||
buf = [ctypes.create_string_buffer(256) for _ in range(3)]
|
||||
rc = self.lib.rtlsdr_get_device_usb_strings(index, *buf)
|
||||
if rc < 0:
|
||||
return ("?", "?", "?")
|
||||
return tuple(b.value.decode("utf-8", "replace") for b in buf)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------
|
||||
def open(self, index: int) -> ctypes.c_void_p:
|
||||
handle = ctypes.c_void_p()
|
||||
rc = self.lib.rtlsdr_open(ctypes.byref(handle), index)
|
||||
if rc < 0:
|
||||
extra = ""
|
||||
if rc == -2 or rc == -6:
|
||||
extra = (
|
||||
"\nThe DVB-T kernel driver may have claimed it. Blacklist it:\n"
|
||||
" echo 'blacklist dvb_usb_rtl28xxu' | "
|
||||
"sudo tee /etc/modprobe.d/blacklist-rtl.conf\n"
|
||||
" sudo rmmod dvb_usb_rtl28xxu\n"
|
||||
"Also make sure your user can access the USB device "
|
||||
"(udev rule / plugdev group)."
|
||||
)
|
||||
raise RtlSdrError(f"could not open device {index}: {_describe(rc)}{extra}")
|
||||
return handle
|
||||
|
||||
def close(self, dev) -> None:
|
||||
if dev:
|
||||
self.lib.rtlsdr_close(dev)
|
||||
|
||||
# -- checked setters -----------------------------------------------
|
||||
def _check(self, rc: int, what: str, soft: bool = False) -> bool:
|
||||
if rc < 0:
|
||||
if soft:
|
||||
return False
|
||||
raise RtlSdrError(f"{what} failed: {_describe(rc)}")
|
||||
return True
|
||||
|
||||
def set_center_freq(self, dev, hz: int) -> None:
|
||||
self._check(self.lib.rtlsdr_set_center_freq(dev, int(hz)),
|
||||
f"tuning to {hz/1e6:.6f} MHz")
|
||||
|
||||
def get_center_freq(self, dev) -> int:
|
||||
return int(self.lib.rtlsdr_get_center_freq(dev))
|
||||
|
||||
def set_sample_rate(self, dev, hz: int) -> None:
|
||||
self._check(self.lib.rtlsdr_set_sample_rate(dev, int(hz)),
|
||||
f"setting sample rate {hz}")
|
||||
|
||||
def get_sample_rate(self, dev) -> int:
|
||||
return int(self.lib.rtlsdr_get_sample_rate(dev))
|
||||
|
||||
def set_freq_correction(self, dev, ppm: int) -> bool:
|
||||
# librtlsdr returns -2 when the value is already set; that is benign.
|
||||
rc = self.lib.rtlsdr_set_freq_correction(dev, int(ppm))
|
||||
return rc >= 0 or rc == -2
|
||||
|
||||
def get_tuner_type(self, dev) -> str:
|
||||
try:
|
||||
return self.TUNER_NAMES.get(int(self.lib.rtlsdr_get_tuner_type(dev)),
|
||||
"unknown")
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def get_tuner_gains(self, dev) -> list[float]:
|
||||
n = self.lib.rtlsdr_get_tuner_gains(dev, None)
|
||||
if n <= 0:
|
||||
return []
|
||||
arr = (ctypes.c_int * n)()
|
||||
self.lib.rtlsdr_get_tuner_gains(dev, arr)
|
||||
return [v / 10.0 for v in arr]
|
||||
|
||||
def set_tuner_gain_mode(self, dev, manual: bool) -> None:
|
||||
self._check(self.lib.rtlsdr_set_tuner_gain_mode(dev, 1 if manual else 0),
|
||||
"setting tuner gain mode")
|
||||
|
||||
def set_tuner_gain(self, dev, db: float) -> None:
|
||||
self._check(self.lib.rtlsdr_set_tuner_gain(dev, int(round(db * 10))),
|
||||
f"setting tuner gain {db} dB")
|
||||
|
||||
def get_tuner_gain(self, dev) -> float:
|
||||
return self.lib.rtlsdr_get_tuner_gain(dev) / 10.0
|
||||
|
||||
def set_tuner_bandwidth(self, dev, hz: int) -> bool:
|
||||
fn = getattr(self.lib, "rtlsdr_set_tuner_bandwidth", None)
|
||||
if fn is None:
|
||||
return False
|
||||
return self._check(fn(dev, int(hz)), "setting tuner bandwidth", soft=True)
|
||||
|
||||
def set_agc_mode(self, dev, on: bool) -> bool:
|
||||
return self._check(self.lib.rtlsdr_set_agc_mode(dev, 1 if on else 0),
|
||||
"setting RTL2832 AGC", soft=True)
|
||||
|
||||
def set_direct_sampling(self, dev, mode: int) -> bool:
|
||||
"""0 = off, 1 = I branch, 2 = Q branch."""
|
||||
return self._check(self.lib.rtlsdr_set_direct_sampling(dev, int(mode)),
|
||||
"setting direct sampling", soft=True)
|
||||
|
||||
def get_direct_sampling(self, dev) -> int:
|
||||
try:
|
||||
return int(self.lib.rtlsdr_get_direct_sampling(dev))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def set_offset_tuning(self, dev, on: bool) -> bool:
|
||||
return self._check(self.lib.rtlsdr_set_offset_tuning(dev, 1 if on else 0),
|
||||
"setting offset tuning", soft=True)
|
||||
|
||||
def set_bias_tee(self, dev, on: bool) -> bool:
|
||||
fn = getattr(self.lib, "rtlsdr_set_bias_tee", None)
|
||||
if fn is None:
|
||||
return False
|
||||
return self._check(fn(dev, 1 if on else 0), "setting bias tee", soft=True)
|
||||
|
||||
def reset_buffer(self, dev) -> None:
|
||||
self._check(self.lib.rtlsdr_reset_buffer(dev), "resetting USB buffer")
|
||||
|
||||
def read_async(self, dev, callback, buf_num: int = 15,
|
||||
buf_len: int = 262144) -> None:
|
||||
"""Stream continuously until :meth:`cancel_async`. Blocks the caller."""
|
||||
rc = self.lib.rtlsdr_read_async(dev, callback, None,
|
||||
int(buf_num), int(buf_len))
|
||||
if rc < 0:
|
||||
raise RtlSdrError(f"async read failed: {_describe(rc)}")
|
||||
|
||||
def cancel_async(self, dev) -> None:
|
||||
self.lib.rtlsdr_cancel_async(dev)
|
||||
|
||||
def read_sync(self, dev, buf, count: int) -> int:
|
||||
n_read = ctypes.c_int(0)
|
||||
rc = self.lib.rtlsdr_read_sync(
|
||||
dev, ctypes.cast(buf, ctypes.c_void_p), count, ctypes.byref(n_read)
|
||||
)
|
||||
if rc < 0:
|
||||
raise RtlSdrError(f"USB read failed: {_describe(rc)}")
|
||||
return int(n_read.value)
|
||||
345
bandsaunter/morse.py
Executable file
345
bandsaunter/morse.py
Executable file
|
|
@ -0,0 +1,345 @@
|
|||
"""CW / Morse detection and decoding.
|
||||
|
||||
Takes demodulated audio (or a complex baseband block), finds the keyed tone,
|
||||
recovers the element timing without being told the speed, and decodes the
|
||||
text. Also reports a confidence so the scanner can tell real Morse from any
|
||||
other on-off keyed carrier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
|
||||
__all__ = ["decode_morse", "MorseResult", "MORSE_TABLE", "encode_morse"]
|
||||
|
||||
|
||||
MORSE_TABLE: dict[str, str] = {
|
||||
".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E",
|
||||
"..-.": "F", "--.": "G", "....": "H", "..": "I", ".---": "J",
|
||||
"-.-": "K", ".-..": "L", "--": "M", "-.": "N", "---": "O",
|
||||
".--.": "P", "--.-": "Q", ".-.": "R", "...": "S", "-": "T",
|
||||
"..-": "U", "...-": "V", ".--": "W", "-..-": "X", "-.--": "Y",
|
||||
"--..": "Z",
|
||||
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
|
||||
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
|
||||
".-.-.-": ".", "--..--": ",", "..--..": "?", ".----.": "'",
|
||||
"-.-.--": "!", "-..-.": "/", "-.--.": "(", "-.--.-": ")",
|
||||
".-...": "&", "---...": ":", "-.-.-.": ";", "-...-": "=",
|
||||
".-.-.": "+", "-....-": "-", "..--.-": "_", ".-..-.": '"',
|
||||
"...-..-": "$", ".--.-.": "@",
|
||||
# Common prosigns, rendered between angle brackets.
|
||||
"...-.-": "<SK>", "-.-.-": "<KA>", ".-.-": "<AA>", "...-.": "<SN>",
|
||||
"-...-.-": "<BK>", "........": "<HH>", ".-.-.-.-": "<ERR>",
|
||||
}
|
||||
|
||||
_REVERSE = {v: k for k, v in MORSE_TABLE.items() if len(v) == 1}
|
||||
|
||||
# Morse timing is defined against PARIS = 50 dot units per word.
|
||||
_DOTS_PER_WORD = 50.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MorseResult:
|
||||
text: str = ""
|
||||
wpm: float = 0.0
|
||||
tone_hz: float = 0.0
|
||||
dot_seconds: float = 0.0
|
||||
confidence: float = 0.0
|
||||
n_elements: int = 0
|
||||
n_characters: int = 0
|
||||
timing_fit: float = 0.0
|
||||
undecoded: int = 0
|
||||
snr_db: float = 0.0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_morse(self) -> bool:
|
||||
# Nobody sends below 5 or above 60 WPM. A "decode" outside that range
|
||||
# is the timing estimator latching onto something that is not Morse --
|
||||
# speech syllables, for instance.
|
||||
return (self.confidence >= 0.5 and self.n_characters >= 3
|
||||
and 5.0 <= self.wpm <= 60.0
|
||||
# Enough elements to be a transmission rather than a handful
|
||||
# of noise crossings, and timing that genuinely fits Morse.
|
||||
and self.n_elements >= 8 and self.timing_fit >= 0.7
|
||||
and self.undecoded <= 0.3 * max(1, self.n_characters))
|
||||
|
||||
def summary(self) -> str:
|
||||
if not self.text:
|
||||
return "keyed carrier, no readable Morse"
|
||||
return f"{self.wpm:.0f} WPM: {self.text.strip()}"
|
||||
|
||||
|
||||
def encode_morse(text: str) -> str:
|
||||
"""Encode text to Morse -- handy for generating test signals."""
|
||||
out = []
|
||||
for ch in text.upper():
|
||||
if ch == " ":
|
||||
out.append("/")
|
||||
elif ch in _REVERSE:
|
||||
out.append(_REVERSE[ch])
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_tone(audio: np.ndarray, fs: float,
|
||||
lo: float = 200.0, hi: float = 3000.0) -> tuple[float, float]:
|
||||
"""Locate the keyed tone. Returns ``(freq_hz, prominence_db)``."""
|
||||
n = 1 << int(math.floor(math.log2(max(1024, min(audio.size, 1 << 16)))))
|
||||
if audio.size < n:
|
||||
return 0.0, 0.0
|
||||
x = audio[:n].astype(np.float64)
|
||||
x -= x.mean()
|
||||
spec = np.abs(np.fft.rfft(x * np.hanning(n), n))
|
||||
freqs = np.fft.rfftfreq(n, 1.0 / fs)
|
||||
band = (freqs >= lo) & (freqs <= min(hi, fs / 2.2))
|
||||
if not np.any(band):
|
||||
return 0.0, 0.0
|
||||
sub, subf = spec[band], freqs[band]
|
||||
k = int(np.argmax(sub))
|
||||
prom = 20.0 * math.log10((sub[k] + 1e-12) / (np.median(sub) + 1e-12))
|
||||
return float(subf[k]), float(prom)
|
||||
|
||||
|
||||
def _tone_envelope(audio: np.ndarray, fs: float, tone_hz: float,
|
||||
width: float = 150.0) -> tuple[np.ndarray, float]:
|
||||
"""Band-pass around the tone and return its envelope at a reduced rate."""
|
||||
nyq = fs / 2.0
|
||||
lo = max(20.0, tone_hz - width) / nyq
|
||||
hi = min(0.99, (tone_hz + width) / nyq)
|
||||
if hi <= lo:
|
||||
return np.abs(audio), fs
|
||||
taps = sps.firwin(255, [lo, hi], pass_zero=False)
|
||||
filtered = sps.lfilter(taps, [1.0], audio)[255:]
|
||||
env = np.abs(sps.hilbert(filtered))
|
||||
|
||||
# 500 Hz of envelope bandwidth resolves elements down to ~2 ms.
|
||||
dec = max(1, int(fs // 1000))
|
||||
if dec > 1:
|
||||
env = sps.decimate(env, dec, ftype="fir", zero_phase=True)
|
||||
return env, fs / dec
|
||||
|
||||
|
||||
def _threshold(env: np.ndarray) -> float:
|
||||
"""Split the envelope into key-down and key-up populations."""
|
||||
lo = float(np.percentile(env, 15))
|
||||
hi = float(np.percentile(env, 85))
|
||||
if hi <= lo:
|
||||
return float(np.median(env))
|
||||
# Midway in the log domain: keying contrast is multiplicative, not additive.
|
||||
return float(math.sqrt(max(lo, 1e-12) * max(hi, 1e-12)))
|
||||
|
||||
|
||||
def _despeckle(on: np.ndarray, min_len: int) -> np.ndarray:
|
||||
"""Remove key transitions too short to be real elements.
|
||||
|
||||
Filter ring at the edges of each element crosses the threshold for a
|
||||
sample or two. Left in, those specks become the shortest "element" and
|
||||
drag the dot-length estimate down to nothing, so flatten anything below
|
||||
the fastest plausible keying speed into its neighbours.
|
||||
"""
|
||||
if min_len <= 1 or on.size == 0:
|
||||
return on
|
||||
out = on.copy()
|
||||
for _ in range(4):
|
||||
changed = False
|
||||
for state, start, length in [(s, a, b) for s, a, b in _run_spans(out)]:
|
||||
if length < min_len:
|
||||
out[start:start + length] = not state
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _run_spans(on: np.ndarray):
|
||||
if on.size == 0:
|
||||
return
|
||||
change = np.flatnonzero(np.diff(on.astype(np.int8)))
|
||||
bounds = np.concatenate(([0], change + 1, [on.size]))
|
||||
for i in range(bounds.size - 1):
|
||||
yield bool(on[bounds[i]]), int(bounds[i]), int(bounds[i + 1] - bounds[i])
|
||||
|
||||
|
||||
def _runs(on: np.ndarray) -> list[tuple[bool, int]]:
|
||||
if on.size == 0:
|
||||
return []
|
||||
change = np.flatnonzero(np.diff(on.astype(np.int8)))
|
||||
bounds = np.concatenate(([0], change + 1, [on.size]))
|
||||
return [(bool(on[bounds[i]]), int(bounds[i + 1] - bounds[i]))
|
||||
for i in range(bounds.size - 1)]
|
||||
|
||||
|
||||
def _estimate_dot(on_lengths: list[int], off_lengths: list[int]) -> float:
|
||||
"""Two-means clustering in the log domain gives a speed-agnostic dot length."""
|
||||
vals = np.array([v for v in on_lengths if v > 0], dtype=np.float64)
|
||||
if vals.size == 0:
|
||||
return 0.0
|
||||
if vals.size < 4:
|
||||
return float(np.min(vals))
|
||||
|
||||
logs = np.log(vals)
|
||||
lo, hi = float(logs.min()), float(logs.max())
|
||||
if hi - lo < 0.35: # all one length -- everything is a dot
|
||||
return float(np.median(vals))
|
||||
c0, c1 = lo, hi
|
||||
for _ in range(30):
|
||||
d0 = np.abs(logs - c0)
|
||||
d1 = np.abs(logs - c1)
|
||||
g0, g1 = logs[d0 <= d1], logs[d1 < d0]
|
||||
if g0.size == 0 or g1.size == 0:
|
||||
break
|
||||
n0, n1 = float(g0.mean()), float(g1.mean())
|
||||
if abs(n0 - c0) < 1e-6 and abs(n1 - c1) < 1e-6:
|
||||
c0, c1 = n0, n1
|
||||
break
|
||||
c0, c1 = n0, n1
|
||||
dot = math.exp(c0)
|
||||
dash = math.exp(c1)
|
||||
# A dash is three dots; if the two clusters are close to that ratio, use
|
||||
# both to refine the estimate.
|
||||
if 2.0 <= dash / max(dot, 1e-9) <= 4.5:
|
||||
dot = 0.5 * (dot + dash / 3.0)
|
||||
# The inter-element gap is also one dot long -- a useful cross-check.
|
||||
offs = np.array([v for v in off_lengths if v > 0], dtype=np.float64)
|
||||
if offs.size >= 3:
|
||||
short = offs[offs <= np.percentile(offs, 40)]
|
||||
if short.size and 0.5 * dot <= np.median(short) <= 2.0 * dot:
|
||||
dot = 0.5 * (dot + float(np.median(short)))
|
||||
return dot
|
||||
|
||||
|
||||
def decode_morse(audio: np.ndarray, sample_rate: float,
|
||||
min_elements: int = 6) -> MorseResult:
|
||||
"""Decode CW from a block of demodulated audio.
|
||||
|
||||
``audio`` should be real audio containing the beat note (what the ``cw``
|
||||
demodulator produces). Speed is estimated from the signal itself, so no
|
||||
WPM setting is needed.
|
||||
"""
|
||||
res = MorseResult()
|
||||
audio = np.asarray(audio, dtype=np.float64).ravel()
|
||||
if audio.size < int(sample_rate * 0.3):
|
||||
res.notes.append("too short to decode")
|
||||
return res
|
||||
|
||||
tone, prom = _find_tone(audio, sample_rate)
|
||||
res.tone_hz = tone
|
||||
res.snr_db = prom
|
||||
if tone <= 0 or prom < 6.0:
|
||||
res.notes.append("no steady tone found")
|
||||
return res
|
||||
|
||||
env, fs_env = _tone_envelope(audio, sample_rate, tone)
|
||||
if env.size < 32:
|
||||
res.notes.append("envelope too short")
|
||||
return res
|
||||
|
||||
thr = _threshold(env)
|
||||
on = env > thr
|
||||
# 6 ms is a dot at about 200 WPM -- far beyond any real operator or
|
||||
# machine, so anything shorter is filter ring, not keying.
|
||||
on = _despeckle(on, max(2, int(0.006 * fs_env)))
|
||||
runs = _runs(on)
|
||||
# Drop the leading and trailing partial runs -- they are cut off by the
|
||||
# capture window and would corrupt the timing estimate.
|
||||
if len(runs) >= 3:
|
||||
runs = runs[1:-1]
|
||||
if len(runs) < min_elements:
|
||||
res.notes.append("not enough keying transitions")
|
||||
return res
|
||||
|
||||
on_lengths = [n for state, n in runs if state]
|
||||
off_lengths = [n for state, n in runs if not state]
|
||||
if not on_lengths:
|
||||
res.notes.append("carrier never keys down")
|
||||
return res
|
||||
|
||||
dot = _estimate_dot(on_lengths, off_lengths)
|
||||
if dot <= 0:
|
||||
res.notes.append("could not estimate element length")
|
||||
return res
|
||||
res.dot_seconds = dot / fs_env
|
||||
if not (0.008 <= res.dot_seconds <= 0.5):
|
||||
res.notes.append(f"implausible element length ({res.dot_seconds*1e3:.0f} ms)")
|
||||
return res
|
||||
res.wpm = 1.2 / res.dot_seconds * (_DOTS_PER_WORD / 50.0)
|
||||
|
||||
# ---- element decision --------------------------------------------
|
||||
symbols: list[str] = []
|
||||
text_parts: list[str] = []
|
||||
current = ""
|
||||
undecoded = 0
|
||||
|
||||
def flush():
|
||||
nonlocal current, undecoded
|
||||
if not current:
|
||||
return
|
||||
ch = MORSE_TABLE.get(current)
|
||||
if ch is None:
|
||||
undecoded += 1
|
||||
text_parts.append("<?>")
|
||||
else:
|
||||
text_parts.append(ch)
|
||||
current = ""
|
||||
|
||||
for state, n in runs:
|
||||
units = n / dot
|
||||
if state:
|
||||
symbols.append("." if units < 2.0 else "-")
|
||||
current += symbols[-1]
|
||||
else:
|
||||
if units < 2.0:
|
||||
continue # gap between elements of a letter
|
||||
flush()
|
||||
if units >= 5.0:
|
||||
text_parts.append(" ") # word gap
|
||||
flush()
|
||||
|
||||
text = "".join(text_parts)
|
||||
# Collapse runs of spaces the timing may have produced.
|
||||
text = " ".join(text.split(" ")) if text else ""
|
||||
res.text = text
|
||||
res.n_elements = len(symbols)
|
||||
res.n_characters = sum(1 for p in text_parts if p not in (" ",))
|
||||
res.undecoded = undecoded
|
||||
|
||||
# ---- confidence ----------------------------------------------------
|
||||
if res.n_characters == 0:
|
||||
res.confidence = 0.0
|
||||
res.notes.append("no characters resolved")
|
||||
return res
|
||||
|
||||
good_ratio = 1.0 - (undecoded / max(1, res.n_characters))
|
||||
# Real Morse has a clean bimodal element histogram: mostly 1 and 3 units.
|
||||
units = np.array([n / dot for state, n in runs if state], dtype=np.float64)
|
||||
# Real keying lands tightly on one and three units. A loose tolerance
|
||||
# here lets the random run lengths of a noisy channel "fit" Morse.
|
||||
fit = float(np.mean(np.minimum(np.abs(units - 1.0), np.abs(units - 3.0)) < 0.35))
|
||||
res.timing_fit = round(fit, 3)
|
||||
speed_ok = 1.0 if 5.0 <= res.wpm <= 60.0 else 0.4
|
||||
conf = 0.45 * good_ratio + 0.35 * fit + 0.20 * speed_ok
|
||||
if res.n_characters < 3:
|
||||
conf *= 0.5
|
||||
|
||||
# A uniform pulse train decodes to "EEEE" or "TTTT" and looks perfect by
|
||||
# the metrics above. Real text mixes dots and dashes and uses more than
|
||||
# one or two letters, so require both.
|
||||
dash_ratio = float(np.mean(units > 2.0)) if units.size else 0.0
|
||||
res.notes.append(f"{dash_ratio*100:.0f}% dashes")
|
||||
if dash_ratio < 0.10 or dash_ratio > 0.80:
|
||||
conf *= 0.35
|
||||
res.notes.append("element mix is not typical of Morse text")
|
||||
distinct = {p for p in text_parts if p not in (" ", "<?>")}
|
||||
if len(distinct) < 2:
|
||||
conf *= 0.35
|
||||
res.notes.append("only one distinct character decoded")
|
||||
res.confidence = round(min(0.99, max(0.0, conf)), 3)
|
||||
if res.confidence < 0.5:
|
||||
res.notes.append("timing does not fit Morse cleanly")
|
||||
return res
|
||||
377
bandsaunter/quality.py
Normal file
377
bandsaunter/quality.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
"""Deciding whether a capture actually carries a signal worth keeping.
|
||||
|
||||
Power alone cannot tell a transmission from a hump of interference, so every
|
||||
capture is assessed for *content* before it is kept:
|
||||
|
||||
* **voice** -- speech structure in the demodulated audio
|
||||
* **cw** -- a keyed carrier whose timing resolves as Morse
|
||||
* **digital** -- a symbol rate, discrete FSK levels, or an M-PSK phase line
|
||||
* **carrier** -- a steady unmodulated carrier (real, but carries nothing)
|
||||
* **noise** -- no structure at all: static, interference, receiver artefacts
|
||||
|
||||
Only the categories the user asked for are recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
__all__ = ["VoiceMetrics", "voice_metrics", "Assessment", "assess",
|
||||
"CATEGORIES", "RAYLEIGH_CV"]
|
||||
|
||||
CATEGORIES = ("voice", "cw", "digital", "carrier", "noise")
|
||||
|
||||
# Envelope coefficient of variation for complex Gaussian noise: |x| is
|
||||
# Rayleigh distributed, so std/mean is exactly sqrt(4/pi - 1).
|
||||
RAYLEIGH_CV = math.sqrt(4.0 / math.pi - 1.0) # 0.5227
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceMetrics:
|
||||
"""Speech-likeness measurements taken on demodulated audio."""
|
||||
|
||||
voiced_fraction: float = 0.0 # frames with a clear pitch period
|
||||
voiced_frames: int = 0 # how much evidence that fraction rests on
|
||||
pitch_hz: float = 0.0 # median pitch of the voiced frames
|
||||
pitch_variation: float = 0.0 # speech pitch drifts; a buzz does not
|
||||
dynamic_range_db: float = 0.0 # speech pauses between phrases
|
||||
syllabic: float = 0.0 # envelope modulation in the 2-8 Hz band
|
||||
band_concentration: float = 0.0 # energy inside 250-3400 Hz
|
||||
spectral_flux: float = 0.0 # formants moving between frames
|
||||
active_fraction: float = 0.0 # how much of the clip is above silence
|
||||
score: float = 0.0
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"voiced {self.voiced_fraction:.2f}, pitch "
|
||||
f"{self.pitch_hz:.0f} Hz +-{self.pitch_variation*100:.0f}%, "
|
||||
f"dynamics {self.dynamic_range_db:.0f} dB, "
|
||||
f"syllabic {self.syllabic:.2f}")
|
||||
|
||||
|
||||
def _frames(x: np.ndarray, fs: float, win_s: float = 0.032,
|
||||
hop_s: float = 0.010):
|
||||
n = int(win_s * fs)
|
||||
hop = max(1, int(hop_s * fs))
|
||||
if x.size < n * 3:
|
||||
return np.zeros((0, n)), hop
|
||||
count = 1 + (x.size - n) // hop
|
||||
idx = np.arange(n)[None, :] + hop * np.arange(count)[:, None]
|
||||
return x[idx], hop
|
||||
|
||||
|
||||
def _pitch_track(frames: np.ndarray, fs: float,
|
||||
f_lo: float = 70.0, f_hi: float = 400.0):
|
||||
"""Normalised autocorrelation peak per frame, over the human pitch range.
|
||||
|
||||
Computed for every frame at once through the FFT. A direct
|
||||
autocorrelation is quadratic in the frame length, and running it frame by
|
||||
frame was slow enough to stall the capture loop -- which costs samples,
|
||||
and shows up as a recording that plays too fast.
|
||||
|
||||
Returns ``(strengths, pitches_hz)``.
|
||||
"""
|
||||
n = frames.shape[1]
|
||||
if frames.shape[0] == 0 or n < 16:
|
||||
return np.zeros(0), np.zeros(0)
|
||||
f = frames - frames.mean(axis=1, keepdims=True)
|
||||
energy = np.einsum("ij,ij->i", f, f)
|
||||
|
||||
nfft = 1 << int(math.ceil(math.log2(2 * n)))
|
||||
spec = np.fft.rfft(f, nfft, axis=1)
|
||||
ac = np.fft.irfft(spec * np.conj(spec), nfft, axis=1)[:, :n]
|
||||
|
||||
lo = max(1, int(fs / f_hi))
|
||||
hi = min(n - 1, int(fs / f_lo))
|
||||
if hi <= lo + 4:
|
||||
return np.zeros(frames.shape[0]), np.zeros(frames.shape[0])
|
||||
|
||||
seg = ac[:, lo:hi]
|
||||
k = np.argmax(seg, axis=1)
|
||||
peak = seg[np.arange(seg.shape[0]), k]
|
||||
strength = np.where(energy > 1e-12, peak / np.maximum(energy, 1e-12), 0.0)
|
||||
# A maximum sitting on the edge of the search range is the search running
|
||||
# out of room, not a periodicity. Noise does this constantly, and the
|
||||
# result is a stream of "pitches" pinned at exactly the limit.
|
||||
edge = (k <= 1) | (k >= seg.shape[1] - 2)
|
||||
strength = np.where(edge, 0.0, strength)
|
||||
pitch = np.where(edge, 0.0, fs / (lo + k))
|
||||
return strength, pitch
|
||||
|
||||
|
||||
def voice_metrics(audio: np.ndarray, fs: float) -> VoiceMetrics:
|
||||
"""Measure how speech-like a block of audio is.
|
||||
|
||||
Speech has four properties that static, hum, tones and data bursts do not
|
||||
have together: a pitch period in the 70-400 Hz range during voiced sounds,
|
||||
pauses between phrases, an envelope that varies at the syllable rate, and
|
||||
formants that move from frame to frame.
|
||||
"""
|
||||
m = VoiceMetrics()
|
||||
audio = np.asarray(audio, dtype=np.float64).ravel()
|
||||
if audio.size < int(fs * 0.4):
|
||||
return m
|
||||
audio = audio - audio.mean()
|
||||
peak = float(np.abs(audio).max())
|
||||
if peak < 1e-6:
|
||||
return m
|
||||
audio = audio / peak
|
||||
|
||||
frames, hop = _frames(audio, fs)
|
||||
if frames.shape[0] < 16:
|
||||
return m
|
||||
win = np.hanning(frames.shape[1])
|
||||
|
||||
energy = np.sqrt(np.mean((frames * win) ** 2, axis=1)) + 1e-12
|
||||
e_db = 20.0 * np.log10(energy)
|
||||
|
||||
# Speech pauses show up as a wide spread between loud and quiet frames.
|
||||
m.dynamic_range_db = float(np.percentile(e_db, 95) - np.percentile(e_db, 15))
|
||||
|
||||
# Only look for pitch where there is something to look at.
|
||||
gate = e_db > (np.percentile(e_db, 95) - 25.0)
|
||||
m.active_fraction = float(gate.mean())
|
||||
|
||||
idx = np.flatnonzero(gate)[:400]
|
||||
if idx.size:
|
||||
strengths, pitches = _pitch_track(frames[idx], fs)
|
||||
voiced = strengths > 0.38
|
||||
m.voiced_fraction = float(voiced.mean())
|
||||
m.voiced_frames = int(voiced.sum())
|
||||
if np.any(voiced):
|
||||
track = np.asarray(pitches)[voiced]
|
||||
m.pitch_hz = float(np.median(track))
|
||||
if m.pitch_hz > 0 and track.size > 3:
|
||||
# Intonation: a talker's pitch wanders, mains hum and a
|
||||
# switching-supply buzz sit on exactly one frequency.
|
||||
m.pitch_variation = float(np.std(track) / m.pitch_hz)
|
||||
|
||||
# Syllable-rate envelope modulation, 2-8 Hz.
|
||||
frame_rate = fs / hop
|
||||
env = energy - energy.mean()
|
||||
if env.size >= 64 and frame_rate > 24:
|
||||
n = 1 << int(math.floor(math.log2(env.size)))
|
||||
spec = np.abs(np.fft.rfft(env[:n] * np.hanning(n), n))
|
||||
mf = np.fft.rfftfreq(n, 1.0 / frame_rate)
|
||||
syl = (mf >= 2.0) & (mf <= 8.0)
|
||||
ref = (mf >= 0.5) & (mf <= 20.0)
|
||||
if np.any(syl) and np.any(ref):
|
||||
total = float(np.sum(spec[ref] ** 2))
|
||||
if total > 0:
|
||||
m.syllabic = float(np.sum(spec[syl] ** 2) / total)
|
||||
|
||||
# Energy inside the voice band, and how much the spectrum moves.
|
||||
mag = np.abs(np.fft.rfft(frames * win, axis=1))
|
||||
freqs = np.fft.rfftfreq(frames.shape[1], 1.0 / fs)
|
||||
band = (freqs >= 250.0) & (freqs <= 3400.0)
|
||||
tot = np.sum(mag ** 2) + 1e-12
|
||||
m.band_concentration = float(np.sum(mag[:, band] ** 2) / tot)
|
||||
|
||||
norm = mag / (np.linalg.norm(mag, axis=1, keepdims=True) + 1e-12)
|
||||
if norm.shape[0] > 1:
|
||||
m.spectral_flux = float(np.mean(np.linalg.norm(np.diff(norm, axis=0),
|
||||
axis=1)))
|
||||
|
||||
# ---- combine -------------------------------------------------------
|
||||
# A pitch track that moves is the one measurement here that only a voice
|
||||
# produces, so it gates the score rather than contributing a share of it.
|
||||
# Dynamics, syllable-rate modulation and energy landing in the voice band
|
||||
# are all things that static does too: weighted alongside voicing they
|
||||
# were enough to carry noise over the line on their own, which is exactly
|
||||
# how hiss ended up being recorded as speech.
|
||||
core = min(1.0, m.voiced_fraction / 0.30)
|
||||
# Pitch drift is the guard against tones and hum, but measuring drift
|
||||
# needs several voiced frames to measure it across. A short over -- a
|
||||
# two-second "QSL, 73" -- cannot supply them, so the requirement eases
|
||||
# when the evidence is thin; the steady-tone guard below still applies.
|
||||
if m.voiced_frames >= 20:
|
||||
core *= min(1.0, m.pitch_variation / 0.04)
|
||||
else:
|
||||
core *= min(1.0, 0.45 + m.pitch_variation / 0.04)
|
||||
|
||||
dyn_term = min(1.0, max(0.0, (m.dynamic_range_db - 6.0) / 20.0))
|
||||
syl_term = min(1.0, max(0.0, (m.syllabic - 0.18) / 0.30))
|
||||
band_term = min(1.0, max(0.0, (m.band_concentration - 0.25) / 0.45))
|
||||
support = 0.40 * dyn_term + 0.35 * syl_term + 0.25 * band_term
|
||||
|
||||
score = core * (0.55 + 0.45 * support)
|
||||
|
||||
# A steady tone is perfectly periodic and would score full marks on pitch;
|
||||
# what it does not have is a spectrum that changes or any dynamics.
|
||||
if m.spectral_flux < 0.045 and m.dynamic_range_db < 6.0:
|
||||
score *= 0.25
|
||||
m.score = float(min(1.0, score))
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Assessment:
|
||||
category: str = "noise"
|
||||
score: float = 0.0
|
||||
accept: bool = False
|
||||
reason: str = ""
|
||||
voice: VoiceMetrics = field(default_factory=VoiceMetrics)
|
||||
noise_likeness: float = 0.0
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.category} ({self.score:.2f}): {self.reason}"
|
||||
|
||||
|
||||
def noise_likeness(f) -> float:
|
||||
"""0 = structured signal, 1 = indistinguishable from receiver noise.
|
||||
|
||||
Gaussian noise has a known envelope statistic, a flat spectrum, no
|
||||
carrier, no discrete frequency levels and no symbol rate. Scoring all of
|
||||
those together is far more reliable than any one of them.
|
||||
"""
|
||||
votes = []
|
||||
# Envelope statistics sitting on the Rayleigh value.
|
||||
votes.append(max(0.0, 1.0 - abs(f.env_cv - RAYLEIGH_CV) / 0.16))
|
||||
# Flat, featureless spectrum.
|
||||
votes.append(min(1.0, max(0.0, (f.flatness - 0.35) / 0.45)))
|
||||
# No carrier line worth the name.
|
||||
votes.append(min(1.0, max(0.0, (14.0 - f.papr_spectral) / 10.0)))
|
||||
# No symbol rate.
|
||||
votes.append(1.0 if f.baud_strength < 11.0 else 0.0)
|
||||
# No discrete discriminator levels.
|
||||
votes.append(1.0 if f.freq_modes <= 1 else 0.0)
|
||||
# No keying.
|
||||
votes.append(min(1.0, max(0.0, (9.0 - f.ook_contrast_db) / 9.0)))
|
||||
return float(np.mean(votes))
|
||||
|
||||
|
||||
def digital_structure(f) -> tuple[float, list[str]]:
|
||||
"""Score how much symbol structure a signal shows, with the evidence.
|
||||
|
||||
A symbol *rate* on its own proves nothing -- the cyclostationary estimator
|
||||
will always return its best peak, and noise and bare carriers both produce
|
||||
one. So an identified keying scheme is required first, and the baud
|
||||
figure only corroborates it.
|
||||
"""
|
||||
score = 0.0
|
||||
bits: list[str] = []
|
||||
|
||||
if f.freq_modes >= 2 and f.mode_spacing > 0 and f.level_dwell > 0.32:
|
||||
score += 0.45
|
||||
bits.append(f"{f.freq_modes} discrete frequency levels "
|
||||
f"{f.mode_spacing:.0f} Hz apart")
|
||||
# A phase line needs a symbol rate behind it that holds still; without
|
||||
# one, an Mth-power peak is just the strongest thing in a noisy spectrum.
|
||||
stable_baud = (f.baud_strength > 14 and 50.0 <= f.baud <= 100_000.0
|
||||
and f.baud_stability > 0.80)
|
||||
if f.psk_strength > 22 and f.psk_order and f.ifreq_kurtosis > 3.0 \
|
||||
and f.psk_strength > f.papr_spectral + 8.0 \
|
||||
and f.carrier_ratio < 0.10 and stable_baud:
|
||||
score += 0.40
|
||||
bits.append(f"{f.psk_order}-PSK phase line")
|
||||
# On/off contrast on its own is not evidence of keying: a signal fading
|
||||
# in and out at the squelch edge produces plenty of it. Real OOK data
|
||||
# also has a symbol rate, so require both.
|
||||
plausible_baud = stable_baud
|
||||
# Keying means the on and off runs land on a common symbol grid. Contrast
|
||||
# and a baud estimate are not enough on their own: a signal fading across
|
||||
# the squelch produces both, with run lengths that fit no grid at all.
|
||||
if (f.ook_contrast_db > 16 and 0.05 < f.ook_duty < 0.95
|
||||
and plausible_baud and f.keying_regularity > 0.55):
|
||||
score += 0.35
|
||||
bits.append(f"on/off keying, {f.ook_contrast_db:.0f} dB contrast, "
|
||||
f"runs on a {f.keying_regularity*100:.0f}% regular grid")
|
||||
|
||||
if score <= 0:
|
||||
return 0.0, []
|
||||
|
||||
if plausible_baud:
|
||||
score += 0.20
|
||||
bits.append(f"{f.baud:.0f} baud symbol rate")
|
||||
return score, bits
|
||||
|
||||
|
||||
def assess(classification, audio: np.ndarray, audio_rate: float,
|
||||
morse=None, min_voice: float = 0.45, freq_hz: float = 0.0,
|
||||
accept: tuple[str, ...] = ("voice", "cw", "digital"),
|
||||
min_score: float = 0.45) -> Assessment:
|
||||
"""Decide what a capture contains and whether it should be kept.
|
||||
|
||||
Deliberately does not route on the classifier's label. The label is a
|
||||
best guess that can be wrong -- speech on a quiet FM channel can look like
|
||||
two-level FSK, for instance -- and a capture should be kept or dropped on
|
||||
what is measurably *in* it, not on what it was called.
|
||||
"""
|
||||
f = classification.features
|
||||
a = Assessment()
|
||||
if f is None:
|
||||
a.reason = "no measurements available"
|
||||
return a
|
||||
|
||||
a.noise_likeness = noise_likeness(f)
|
||||
fam = classification.family
|
||||
vm = voice_metrics(audio, audio_rate)
|
||||
a.voice = vm
|
||||
dig_score, dig_bits = digital_structure(f)
|
||||
|
||||
# 1. Morse that actually decoded.
|
||||
if morse is not None and morse.is_morse:
|
||||
a.category = "cw"
|
||||
a.score = float(morse.confidence)
|
||||
a.reason = f'Morse decoded at {morse.wpm:.0f} WPM: "{morse.text.strip()[:40]}"'
|
||||
|
||||
# 2. Speech, whatever the modulation was called.
|
||||
elif vm.score >= min_voice:
|
||||
a.category = "voice"
|
||||
a.score = vm.score
|
||||
a.reason = f"speech in the audio ({vm.describe()})"
|
||||
|
||||
# 3. Symbol structure.
|
||||
elif dig_score > 0.3:
|
||||
a.category = "digital"
|
||||
a.score = min(0.95, 0.35 + dig_score)
|
||||
a.reason = "digital modulation: " + ", ".join(dig_bits)
|
||||
|
||||
# 4. A keyed carrier whose timing would not resolve as Morse.
|
||||
elif fam == "cw" and f.ook_contrast_db > 12:
|
||||
a.category = "cw"
|
||||
a.score = 0.55
|
||||
a.reason = (f"keyed carrier, {f.ook_contrast_db:.0f} dB on/off contrast "
|
||||
"(timing did not resolve as Morse)")
|
||||
|
||||
# 5. Broadcast FM gets a lenient path because most of it is music, which
|
||||
# has no speech pitch track to find. That leniency is confined to
|
||||
# signals that really are broadcast: in the FM band, or carrying a
|
||||
# 19 kHz stereo pilot. Without the restriction, any wideband hump --
|
||||
# a clock harmonic, say -- walks straight through it.
|
||||
elif (f.stereo_pilot or 87.5e6 <= freq_hz <= 108.1e6) \
|
||||
and f.bandwidth > 50_000 and f.fdev_rms > 2_000 \
|
||||
and (vm.spectral_flux > 0.15 or vm.dynamic_range_db > 8):
|
||||
a.category = "voice"
|
||||
a.score = max(0.55, vm.score)
|
||||
a.reason = ("programme audio on a wideband FM carrier"
|
||||
+ (" with a 19 kHz stereo pilot" if f.stereo_pilot else ""))
|
||||
|
||||
# 6. A carrier that is really just a carrier.
|
||||
elif fam == "carrier" or (f.am_depth < 0.004 and f.fdev_rms < 150
|
||||
and f.ook_contrast_db < 6):
|
||||
a.category = "carrier"
|
||||
a.score = 0.6
|
||||
a.reason = "steady unmodulated carrier"
|
||||
|
||||
else:
|
||||
a.category = "noise"
|
||||
a.score = vm.score
|
||||
a.reason = (f"no speech, no symbol structure and no keying "
|
||||
f"({vm.describe()})")
|
||||
|
||||
# A strong noise verdict overrides any label, however confident. A
|
||||
# score bar here left a hole: a single piece of "digital" evidence scored
|
||||
# 0.80 and sailed past a check that only applied below 0.75.
|
||||
if a.category != "noise" and a.noise_likeness > 0.82:
|
||||
a.category = "noise"
|
||||
a.reason = (f"statistics match receiver noise "
|
||||
f"(noise likeness {a.noise_likeness:.2f})")
|
||||
a.score = 0.0
|
||||
|
||||
a.accept = a.category in accept and a.score >= min_score
|
||||
return a
|
||||
315
bandsaunter/ranges.py
Executable file
315
bandsaunter/ranges.py
Executable file
|
|
@ -0,0 +1,315 @@
|
|||
"""Frequency ranges, parsing, and turning them into a sweep plan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
|
||||
|
||||
__all__ = ["ScanRange", "TuneStep", "parse_frequency", "parse_range",
|
||||
"parse_range_list", "build_plan", "fmt_hz", "RangeError"]
|
||||
|
||||
|
||||
class RangeError(ValueError):
|
||||
"""Raised for un-parseable user frequency input."""
|
||||
|
||||
|
||||
_SUFFIX = {
|
||||
"": 1.0, "hz": 1.0,
|
||||
"k": 1e3, "khz": 1e3,
|
||||
"m": 1e6, "mhz": 1e6,
|
||||
"g": 1e9, "ghz": 1e9,
|
||||
}
|
||||
|
||||
_NUM = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]*)\s*$")
|
||||
|
||||
|
||||
def parse_frequency(text: str, default_unit: str = "") -> float:
|
||||
"""Parse ``146.52M``, ``146520000``, ``433.92 MHz``, ``14074k`` -> Hz.
|
||||
|
||||
A bare number with no suffix is read as MHz when it is small enough to be
|
||||
unambiguous (under 10000), otherwise as Hz -- which is how people
|
||||
actually write frequencies.
|
||||
"""
|
||||
if text is None:
|
||||
raise RangeError("empty frequency")
|
||||
s = str(text).strip().replace(",", "").replace("_", "")
|
||||
if not s:
|
||||
raise RangeError("empty frequency")
|
||||
m = _NUM.match(s)
|
||||
if not m:
|
||||
raise RangeError(f"cannot read {text!r} as a frequency")
|
||||
value, suffix = float(m.group(1)), m.group(2).lower()
|
||||
|
||||
if not suffix:
|
||||
suffix = default_unit.lower()
|
||||
if not suffix:
|
||||
# No unit anywhere: guess from magnitude.
|
||||
return value * 1e6 if value < 10_000 else value
|
||||
if suffix not in _SUFFIX:
|
||||
raise RangeError(f"unknown frequency unit {m.group(2)!r} in {text!r}")
|
||||
return value * _SUFFIX[suffix]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanRange:
|
||||
"""One start/stop pair the user wants swept."""
|
||||
|
||||
start: float
|
||||
stop: float
|
||||
step: float = 12_500.0
|
||||
mode: str = "auto"
|
||||
bandwidth: float = 0.0
|
||||
label: str = ""
|
||||
preset_key: str = ""
|
||||
enabled: bool = True
|
||||
threshold_db: float | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.start = float(self.start)
|
||||
self.stop = float(self.stop)
|
||||
if self.stop < self.start:
|
||||
self.start, self.stop = self.stop, self.start
|
||||
if self.stop == self.start:
|
||||
# A single frequency: give it enough width to actually measure.
|
||||
pad = max(self.step, 12_500.0) / 2.0
|
||||
self.start -= pad
|
||||
self.stop += pad
|
||||
if self.step <= 0:
|
||||
self.step = 12_500.0
|
||||
if not self.label:
|
||||
self.label = f"{fmt_hz(self.start)}-{fmt_hz(self.stop)}"
|
||||
|
||||
@property
|
||||
def span(self) -> float:
|
||||
return self.stop - self.start
|
||||
|
||||
@classmethod
|
||||
def from_preset(cls, preset: BandPreset, **over) -> "ScanRange":
|
||||
kw = dict(start=preset.start, stop=preset.stop, step=preset.step,
|
||||
mode=preset.mode, bandwidth=preset.bandwidth,
|
||||
label=preset.name, preset_key=preset.key)
|
||||
kw.update(over)
|
||||
return cls(**kw)
|
||||
|
||||
def _covering(self, hz: float, need_bandwidth: bool = False):
|
||||
"""Band-plan segments to resolve against at one frequency.
|
||||
|
||||
A range that came from a band-plan preset resolves against segments of
|
||||
the same service first. Without that, scanning the whole of 70 cm
|
||||
would hand 433-435 MHz to the ISM preset that overlaps it there, and
|
||||
an amateur repeater would be treated as an unlicensed device.
|
||||
"""
|
||||
covering = [p for p in presets_covering(hz)
|
||||
if (p.bandwidth > 0 if need_bandwidth else p.mode != "auto")]
|
||||
own = by_key(self.preset_key) if self.preset_key else None
|
||||
if own is not None:
|
||||
same = [p for p in covering if p.category == own.category]
|
||||
if same:
|
||||
return same
|
||||
return covering
|
||||
|
||||
def resolved_mode(self, freq: float | None = None) -> str:
|
||||
"""The demodulator to use, resolving ``auto`` against the band plan."""
|
||||
if self.mode and self.mode != "auto":
|
||||
return self.mode
|
||||
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
|
||||
# Whole-band entries are skipped: they defer to the segments beneath
|
||||
# them, so resolving to one would hand "auto" straight back.
|
||||
covering = self._covering(hz)
|
||||
if covering:
|
||||
return min(covering, key=lambda p: p.span).mode
|
||||
return "nfm"
|
||||
|
||||
def resolved_bandwidth(self, freq: float | None = None) -> float:
|
||||
if self.bandwidth > 0:
|
||||
return self.bandwidth
|
||||
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
|
||||
covering = self._covering(hz, need_bandwidth=True)
|
||||
if covering:
|
||||
return min(covering, key=lambda p: p.span).bandwidth
|
||||
return max(self.step, 12_500.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ScanRange":
|
||||
known = {f for f in cls.__dataclass_fields__}
|
||||
return cls(**{k: v for k, v in d.items() if k in known})
|
||||
|
||||
def describe(self) -> str:
|
||||
state = "" if self.enabled else " (disabled)"
|
||||
return (f"{self.label}: {fmt_hz(self.start)} - {fmt_hz(self.stop)} "
|
||||
f"step {fmt_hz(self.step)} mode {self.mode}{state}")
|
||||
|
||||
|
||||
_RANGE_SPLIT = re.compile(r"\s*(?:-{1,2}|\.{2}|to|:{2})\s*", re.I)
|
||||
|
||||
|
||||
def parse_range(text: str) -> ScanRange:
|
||||
"""Parse one range specification.
|
||||
|
||||
Accepted forms::
|
||||
|
||||
144M-148M explicit start and stop
|
||||
144-148M unit carried over to both ends
|
||||
146.52M a single frequency
|
||||
144M-148M/25k with an explicit step
|
||||
144M-148M/25k@nfm with a step and a demodulator
|
||||
gmrs any band-plan preset key
|
||||
|
||||
"""
|
||||
if text is None:
|
||||
raise RangeError("empty range")
|
||||
s = str(text).strip()
|
||||
if not s:
|
||||
raise RangeError("empty range")
|
||||
|
||||
mode = "auto"
|
||||
if "@" in s:
|
||||
s, _, mode = s.partition("@")
|
||||
mode = mode.strip().lower() or "auto"
|
||||
|
||||
step = None
|
||||
if "/" in s:
|
||||
s, _, step_s = s.partition("/")
|
||||
step = parse_frequency(step_s, default_unit="khz")
|
||||
|
||||
s = s.strip()
|
||||
preset = by_key(s)
|
||||
if preset is not None and preset.is_group:
|
||||
raise RangeError(
|
||||
f"{preset.key!r} covers {len(preset.expand())} separate ranges; "
|
||||
"it can only be used where a list of ranges is accepted")
|
||||
if preset is not None:
|
||||
r = ScanRange.from_preset(preset)
|
||||
if step:
|
||||
r.step = step
|
||||
if mode != "auto":
|
||||
r.mode = mode
|
||||
return r
|
||||
|
||||
parts = [p for p in _RANGE_SPLIT.split(s) if p.strip()]
|
||||
if len(parts) == 1:
|
||||
hz = parse_frequency(parts[0])
|
||||
r = ScanRange(hz, hz, step or 12_500.0, mode)
|
||||
elif len(parts) == 2:
|
||||
# "144-148M": the unit on the right end applies to the left too.
|
||||
right_unit = _NUM.match(parts[1])
|
||||
unit = right_unit.group(2) if right_unit else ""
|
||||
lo = parse_frequency(parts[0], default_unit=unit)
|
||||
hi = parse_frequency(parts[1])
|
||||
r = ScanRange(lo, hi, step or 12_500.0, mode)
|
||||
else:
|
||||
raise RangeError(f"cannot read {text!r} as a frequency range")
|
||||
return r
|
||||
|
||||
|
||||
def parse_range_list(text: str | list[str]) -> list[ScanRange]:
|
||||
"""Parse a comma/newline separated list into ranges (unlimited count).
|
||||
|
||||
A band-plan key that stands for a set of others -- ``all-cw``, say --
|
||||
expands here into one range per member.
|
||||
"""
|
||||
if isinstance(text, (list, tuple)):
|
||||
items = list(text)
|
||||
else:
|
||||
items = re.split(r"[,\n;]+", str(text))
|
||||
out = []
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
preset = by_key(item)
|
||||
if preset is not None and preset.is_group:
|
||||
out.extend(ScanRange.from_preset(m) for m in preset.expand())
|
||||
else:
|
||||
out.append(parse_range(item))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sweep planning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TuneStep:
|
||||
"""One tuner position, plus the slice of its spectrum we trust."""
|
||||
|
||||
center: float
|
||||
low: float # first frequency this step is responsible for
|
||||
high: float # last frequency this step is responsible for
|
||||
range_index: int
|
||||
range_label: str = ""
|
||||
mode: str = "auto"
|
||||
index: int = 0 # position in the plan; keys the noise-floor memory
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.high - self.low
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{fmt_hz(self.center)} covering {fmt_hz(self.low)}-{fmt_hz(self.high)}"
|
||||
|
||||
|
||||
def build_plan(ranges: list[ScanRange], sample_rate: float,
|
||||
usable_fraction: float = 0.75,
|
||||
dc_guard: float = 8_000.0,
|
||||
max_steps: int = 200_000,
|
||||
tunable=None) -> list[TuneStep]:
|
||||
"""Split every enabled range into tuner steps.
|
||||
|
||||
The tuner is deliberately *not* centred on the span it covers. An RTL2832
|
||||
always shows a DC spike at whatever it is tuned to, so a centred step would
|
||||
blank a hole in the middle of every range -- and for a range narrower than
|
||||
one capture, that hole is the whole point of interest. Instead each step
|
||||
parks the local oscillator ``dc_guard`` below the span it is responsible
|
||||
for, so the covered frequencies sit entirely on one side of DC, clear of
|
||||
both the spike and the anti-alias roll-off.
|
||||
|
||||
``tunable`` is an optional predicate; when the offset LO would fall outside
|
||||
the hardware's range the step falls back to centring.
|
||||
"""
|
||||
if sample_rate <= 0 or usable_fraction <= 0:
|
||||
raise RangeError("sample rate and usable fraction must be positive")
|
||||
# Half the usable width, because only one side of DC is used per step.
|
||||
usable = float(sample_rate) * float(usable_fraction) / 2.0
|
||||
if usable <= dc_guard:
|
||||
raise RangeError("usable bandwidth is too small for the DC guard band")
|
||||
|
||||
steps: list[TuneStep] = []
|
||||
for i, r in enumerate(ranges):
|
||||
if not r.enabled:
|
||||
continue
|
||||
n = max(1, int(math.ceil(r.span / usable)))
|
||||
if len(steps) + n > max_steps:
|
||||
raise RangeError(
|
||||
f"plan would need over {max_steps} tuner steps; "
|
||||
"narrow the ranges or raise the sample rate"
|
||||
)
|
||||
width = r.span / n
|
||||
for k in range(n):
|
||||
low = r.start + k * width
|
||||
high = low + width
|
||||
center = low - dc_guard
|
||||
if tunable is not None and not tunable(center):
|
||||
center = 0.5 * (low + high) # fall back rather than lose the step
|
||||
steps.append(TuneStep(
|
||||
center=center,
|
||||
low=low, high=high,
|
||||
range_index=i, range_label=r.label,
|
||||
mode=r.mode, index=len(steps),
|
||||
))
|
||||
return steps
|
||||
|
||||
|
||||
def plan_summary(ranges: list[ScanRange], steps: list[TuneStep],
|
||||
dwell_seconds: float) -> str:
|
||||
total = sum(r.span for r in ranges if r.enabled)
|
||||
n_en = sum(1 for r in ranges if r.enabled)
|
||||
cycle = len(steps) * dwell_seconds
|
||||
return (f"{n_en} range(s), {fmt_hz(total)} total span, {len(steps)} tuner "
|
||||
f"steps, ~{cycle:.1f} s per full sweep")
|
||||
591
bandsaunter/recorder.py
Executable file
591
bandsaunter/recorder.py
Executable file
|
|
@ -0,0 +1,591 @@
|
|||
"""Writing hits to disk: audio, raw IQ, metadata sidecars and the run log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .bandplan import fmt_hz, presets_covering
|
||||
|
||||
__all__ = ["Recording", "ScanLog", "HitRecord", "safe_freq_name",
|
||||
"build_stem"]
|
||||
|
||||
|
||||
def safe_freq_name(hz: float) -> str:
|
||||
"""A filename-friendly frequency: 146520000 Hz -> ``0146.520000MHz``.
|
||||
|
||||
Padded to four digits so that names sort by frequency as text. Without
|
||||
it a directory listing puts 1090 MHz before 146 MHz, since "1" sorts
|
||||
before "4".
|
||||
"""
|
||||
return f"{float(hz) / 1e6:011.6f}MHz"
|
||||
|
||||
|
||||
def build_stem(started_at: float, frequency: float, modulation: str) -> str:
|
||||
"""``146.520000MHz--2026-08-20_14_23_05-nfm`` -- one flat name per capture.
|
||||
|
||||
Every artefact of a capture (audio, IQ, metadata, transcript) shares this
|
||||
stem, so a recording and its sidecars sit next to each other. Frequency
|
||||
leads, so a directory listing groups everything heard on one channel
|
||||
together, and the timestamp orders each group.
|
||||
"""
|
||||
when = datetime.fromtimestamp(started_at).strftime("%Y-%m-%d_%H_%M_%S")
|
||||
mod = "".join(c if (c.isalnum() or c in "+") else "-"
|
||||
for c in (modulation or "unknown").lower()).strip("-")
|
||||
return f"{safe_freq_name(frequency)}--{when}-{mod or 'unknown'}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HitRecord:
|
||||
"""One detection, from first trigger to final classification."""
|
||||
|
||||
frequency: float
|
||||
started_at: float
|
||||
started_iso: str = ""
|
||||
duration: float = 0.0
|
||||
peak_dbfs: float = -999.0
|
||||
snr_db: float = 0.0
|
||||
bandwidth: float = 0.0
|
||||
mode: str = "nfm"
|
||||
range_label: str = ""
|
||||
band_labels: list[str] = field(default_factory=list)
|
||||
|
||||
classification: str = ""
|
||||
family: str = ""
|
||||
confidence: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
alternatives: list = field(default_factory=list)
|
||||
|
||||
morse_text: str = ""
|
||||
morse_wpm: float = 0.0
|
||||
ctcss_hz: float = 0.0
|
||||
baud: float = 0.0
|
||||
|
||||
category: str = "" # voice / cw / digital / carrier / noise
|
||||
signal_score: float = 0.0
|
||||
voice_score: float = 0.0
|
||||
noise_likeness: float = 0.0
|
||||
content_reason: str = ""
|
||||
|
||||
filename: str = ""
|
||||
audio_path: str = ""
|
||||
combined_path: str = ""
|
||||
transcript_path: str = ""
|
||||
iq_path: str = ""
|
||||
meta_path: str = ""
|
||||
directory: str = ""
|
||||
stop_reason: str = ""
|
||||
kept: bool = True
|
||||
features: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def one_line(self) -> str:
|
||||
bits = [f"{fmt_hz(self.frequency):>14s}",
|
||||
f"{self.duration:5.1f}s",
|
||||
f"SNR {self.snr_db:5.1f} dB",
|
||||
self.classification or "unclassified"]
|
||||
if self.morse_text:
|
||||
bits.append(f'CW "{self.morse_text.strip()[:40]}"')
|
||||
elif self.ctcss_hz:
|
||||
bits.append(f"CTCSS {self.ctcss_hz:.1f}")
|
||||
return " ".join(bits)
|
||||
|
||||
|
||||
class Recording:
|
||||
"""Streams one detection to disk.
|
||||
|
||||
Audio and IQ are appended block by block, so a long recording never has to
|
||||
fit in memory. A bounded slice of IQ is kept in RAM for the classifier.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, frequency: float, mode: str,
|
||||
audio_rate: int, iq_rate: float | None = None,
|
||||
save_audio: bool = True, save_iq: bool = False,
|
||||
iq_format: str = "cf32",
|
||||
classify_seconds: float = 10.0,
|
||||
range_label: str = ""):
|
||||
self.frequency = float(frequency)
|
||||
self.mode = mode
|
||||
self.audio_rate = int(audio_rate)
|
||||
self.iq_rate = float(iq_rate or 0.0)
|
||||
self.save_audio = save_audio
|
||||
self.save_iq = save_iq
|
||||
self.iq_format = iq_format
|
||||
self.range_label = range_label
|
||||
|
||||
self.started_at = time.time()
|
||||
self.dir = Path(root)
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
# Provisional name: the modulation is not known until the capture has
|
||||
# been analysed, so the files are renamed when the hit is finalised.
|
||||
self.stem = build_stem(self.started_at, frequency, mode)
|
||||
|
||||
self._wav: wave.Wave_write | None = None
|
||||
self._iq_fh = None
|
||||
self.audio_samples = 0
|
||||
self.iq_samples = 0
|
||||
|
||||
# Bounded buffer of IF-rate IQ for classification. Capped by sample
|
||||
# count as well as duration so a wideband capture cannot balloon.
|
||||
self._class_budget = min(
|
||||
2_000_000, int(max(1.0, classify_seconds) * (self.iq_rate or 1)))
|
||||
self._class_chunks: list[np.ndarray] = []
|
||||
self._class_have = 0
|
||||
|
||||
# Bounded buffer of audio for the Morse decoder.
|
||||
self._audio_budget = int(30.0 * self.audio_rate)
|
||||
self._audio_chunks: list[np.ndarray] = []
|
||||
self._audio_have = 0
|
||||
# The same audio, but only the stretches where the squelch said a
|
||||
# signal was present. A short over followed by the hang tail is half
|
||||
# silence, and judging speech across the silence dilutes it away.
|
||||
self._active_chunks: list[np.ndarray] = []
|
||||
self._active_marks: list[float] = [] # elapsed time at each chunk's end
|
||||
self._active_have = 0
|
||||
|
||||
self.peak_dbfs = -999.0
|
||||
self.snr_db = 0.0
|
||||
self.bandwidth = 0.0
|
||||
|
||||
# -- paths -----------------------------------------------------------
|
||||
@property
|
||||
def audio_path(self) -> Path:
|
||||
return self.dir / f"{self.stem}.wav"
|
||||
|
||||
@property
|
||||
def iq_path(self) -> Path:
|
||||
ext = "cf32" if self.iq_format == "cf32" else "cs16"
|
||||
return self.dir / f"{self.stem}.{ext}"
|
||||
|
||||
@property
|
||||
def meta_path(self) -> Path:
|
||||
return self.dir / f"{self.stem}.json"
|
||||
|
||||
@property
|
||||
def sigmf_path(self) -> Path:
|
||||
return self.dir / f"{self.stem}.sigmf-meta"
|
||||
|
||||
def rename_for(self, modulation: str) -> str:
|
||||
"""Rename everything written so far to reflect the identified mode."""
|
||||
new_stem = build_stem(self.started_at, self.frequency, modulation)
|
||||
if new_stem == self.stem:
|
||||
return self.stem
|
||||
# Never clobber an existing capture: two signals can land in the same
|
||||
# second on the same frequency when a scan re-visits quickly.
|
||||
candidate, n = new_stem, 2
|
||||
while (self.dir / f"{candidate}.wav").exists() and candidate != self.stem:
|
||||
candidate = f"{new_stem}_{n}"
|
||||
n += 1
|
||||
for suffix in (".wav", ".cf32", ".cs16", ".sigmf-meta", ".json"):
|
||||
old_p = self.dir / f"{self.stem}{suffix}"
|
||||
if old_p.exists():
|
||||
try:
|
||||
old_p.rename(self.dir / f"{candidate}{suffix}")
|
||||
except OSError:
|
||||
return self.stem
|
||||
self.stem = candidate
|
||||
return self.stem
|
||||
|
||||
# -- writing ---------------------------------------------------------
|
||||
def _open_wav(self):
|
||||
if self._wav is None:
|
||||
self._wav = wave.open(str(self.audio_path), "wb")
|
||||
self._wav.setnchannels(1)
|
||||
self._wav.setsampwidth(2)
|
||||
self._wav.setframerate(self.audio_rate)
|
||||
return self._wav
|
||||
|
||||
def write_audio(self, audio: np.ndarray, present: bool = True,
|
||||
at: float = -1.0) -> None:
|
||||
if audio.size == 0:
|
||||
return
|
||||
if self.save_audio:
|
||||
pcm = np.clip(audio, -1.0, 1.0)
|
||||
pcm = (pcm * 32767.0).astype("<i2")
|
||||
self._open_wav().writeframes(pcm.tobytes())
|
||||
self.audio_samples += int(audio.size)
|
||||
block = np.asarray(audio, dtype=np.float32)
|
||||
if self._audio_have < self._audio_budget:
|
||||
self._audio_chunks.append(block)
|
||||
self._audio_have += audio.size
|
||||
if present and self._active_have < self._audio_budget:
|
||||
self._active_chunks.append(block)
|
||||
self._active_marks.append(
|
||||
at if at >= 0 else self.audio_samples / max(self.audio_rate, 1))
|
||||
self._active_have += audio.size
|
||||
|
||||
def write_iq(self, iq: np.ndarray) -> None:
|
||||
"""Store IF-rate complex samples (for the file and the classifier)."""
|
||||
if iq.size == 0:
|
||||
return
|
||||
if self.save_iq:
|
||||
if self._iq_fh is None:
|
||||
self._iq_fh = open(self.iq_path, "wb")
|
||||
if self.iq_format == "cs16":
|
||||
inter = np.empty(iq.size * 2, dtype="<i2")
|
||||
inter[0::2] = np.clip(iq.real * 32767.0, -32768, 32767)
|
||||
inter[1::2] = np.clip(iq.imag * 32767.0, -32768, 32767)
|
||||
self._iq_fh.write(inter.tobytes())
|
||||
else:
|
||||
self._iq_fh.write(iq.astype(np.complex64).tobytes())
|
||||
self.iq_samples += int(iq.size)
|
||||
if self._class_have < self._class_budget:
|
||||
self._class_chunks.append(np.asarray(iq, dtype=np.complex64))
|
||||
self._class_have += iq.size
|
||||
|
||||
# -- buffers for analysis --------------------------------------------
|
||||
def classification_iq(self, limit: int = 0) -> np.ndarray:
|
||||
"""Buffered IF-rate IQ, optionally only the most recent ``limit``.
|
||||
|
||||
The limit matters: the periodic content check runs inside the capture
|
||||
loop, and analysing several megasamples there stalls the reader long
|
||||
enough for the dongle to drop what arrives meanwhile.
|
||||
"""
|
||||
if not self._class_chunks:
|
||||
return np.zeros(0, dtype=np.complex64)
|
||||
if limit and self._class_have > limit:
|
||||
taken, total = [], 0
|
||||
for chunk in reversed(self._class_chunks):
|
||||
taken.append(chunk)
|
||||
total += chunk.size
|
||||
if total >= limit:
|
||||
break
|
||||
return np.concatenate(list(reversed(taken)))[-limit:]
|
||||
return np.concatenate(self._class_chunks)
|
||||
|
||||
def active_since(self, t0: float) -> np.ndarray:
|
||||
"""Audio the squelch called signal, from ``t0`` seconds onward.
|
||||
|
||||
Judging a live capture on everything heard so far means the speech at
|
||||
the start keeps vouching for the channel long after it has gone quiet.
|
||||
A recent window is what answers "is there still something here".
|
||||
"""
|
||||
if not self._active_chunks:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
keep = [c for c, mark in zip(self._active_chunks, self._active_marks)
|
||||
if mark >= t0]
|
||||
if not keep:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
return np.concatenate(keep)
|
||||
|
||||
def classification_audio(self, active_only: bool = True,
|
||||
limit: int = 0) -> np.ndarray:
|
||||
"""Audio for content analysis.
|
||||
|
||||
Defaults to only the stretches the squelch called signal, falling back
|
||||
to everything when that is too short to judge.
|
||||
"""
|
||||
chunks = (self._active_chunks
|
||||
if active_only and self._active_have >= int(0.6 * self.audio_rate)
|
||||
else self._audio_chunks)
|
||||
if not chunks:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
out = np.concatenate(chunks)
|
||||
return out[-limit:] if limit and out.size > limit else out
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
if self.audio_rate and self.audio_samples:
|
||||
return self.audio_samples / self.audio_rate
|
||||
if self.iq_rate and self.iq_samples:
|
||||
return self.iq_samples / self.iq_rate
|
||||
return time.time() - self.started_at
|
||||
|
||||
# -- finishing --------------------------------------------------------
|
||||
def close(self) -> None:
|
||||
if self._wav is not None:
|
||||
self._wav.close()
|
||||
self._wav = None
|
||||
if self._iq_fh is not None:
|
||||
self._iq_fh.close()
|
||||
self._iq_fh = None
|
||||
|
||||
def discard(self) -> None:
|
||||
"""Delete everything this recording wrote (too short, or squelched out)."""
|
||||
self.close()
|
||||
for p in (self.audio_path, self.iq_path, self.meta_path,
|
||||
self.sigmf_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def write_metadata(self, hit: HitRecord) -> Path:
|
||||
meta = {
|
||||
"bandsaunter_version": 1,
|
||||
"frequency_hz": self.frequency,
|
||||
"frequency": fmt_hz(self.frequency),
|
||||
"mode": self.mode,
|
||||
"started_utc": datetime.fromtimestamp(
|
||||
self.started_at, timezone.utc).isoformat(),
|
||||
"started_local": datetime.fromtimestamp(self.started_at).isoformat(),
|
||||
"duration_seconds": round(self.duration, 3),
|
||||
"audio_rate": self.audio_rate,
|
||||
"iq_rate": self.iq_rate,
|
||||
"audio_samples": self.audio_samples,
|
||||
"iq_samples": self.iq_samples,
|
||||
"range_label": self.range_label,
|
||||
"bands": [p.name for p in presets_covering(self.frequency)],
|
||||
"hit": hit.to_dict(),
|
||||
}
|
||||
with open(self.meta_path, "w") as fh:
|
||||
json.dump(meta, fh, indent=2, default=_json_default)
|
||||
if self.save_iq and self.iq_samples:
|
||||
self._write_sigmf(hit)
|
||||
return self.meta_path
|
||||
|
||||
def _write_sigmf(self, hit: HitRecord) -> None:
|
||||
"""A SigMF sidecar so the IQ opens in standard SDR tooling."""
|
||||
sigmf = {
|
||||
"global": {
|
||||
"core:datatype": "cf32_le" if self.iq_format == "cf32" else "ci16_le",
|
||||
"core:sample_rate": self.iq_rate,
|
||||
"core:version": "1.0.0",
|
||||
"core:description": hit.classification or "bandsaunter capture",
|
||||
"core:recorder": "bandsaunter",
|
||||
},
|
||||
"captures": [{
|
||||
"core:sample_start": 0,
|
||||
"core:frequency": self.frequency,
|
||||
"core:datetime": datetime.fromtimestamp(
|
||||
self.started_at, timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}],
|
||||
"annotations": [{
|
||||
"core:sample_start": 0,
|
||||
"core:sample_count": self.iq_samples,
|
||||
"core:freq_lower_edge": self.frequency - hit.bandwidth / 2,
|
||||
"core:freq_upper_edge": self.frequency + hit.bandwidth / 2,
|
||||
"core:label": hit.classification or "signal",
|
||||
}],
|
||||
}
|
||||
with open(self.sigmf_path, "w") as fh:
|
||||
json.dump(sigmf, fh, indent=2, default=_json_default)
|
||||
|
||||
|
||||
def _json_default(o):
|
||||
if isinstance(o, (np.floating, np.integer)):
|
||||
return o.item()
|
||||
if isinstance(o, np.ndarray):
|
||||
return o.tolist()
|
||||
if isinstance(o, (set, tuple)):
|
||||
return list(o)
|
||||
return str(o)
|
||||
|
||||
|
||||
class ScanLog:
|
||||
"""Append-only JSONL log plus a human-readable CSV of every hit."""
|
||||
|
||||
CSV_COLUMNS = ("timestamp", "frequency_hz", "frequency", "duration_s",
|
||||
"snr_db", "bandwidth_hz", "mode", "category",
|
||||
"classification", "confidence", "signal_score",
|
||||
"morse_text", "ctcss_hz", "baud", "range", "filename")
|
||||
|
||||
def __init__(self, root: Path, jsonl_name: str = "scan_log.jsonl"):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.jsonl = self.root / jsonl_name
|
||||
self.csv = self.root / (Path(jsonl_name).stem + ".csv")
|
||||
if not self.csv.exists():
|
||||
with open(self.csv, "w") as fh:
|
||||
fh.write(",".join(self.CSV_COLUMNS) + "\n")
|
||||
|
||||
def append(self, hit: HitRecord) -> None:
|
||||
with open(self.jsonl, "a") as fh:
|
||||
fh.write(json.dumps(hit.to_dict(), default=_json_default) + "\n")
|
||||
row = [
|
||||
datetime.fromtimestamp(hit.started_at).isoformat(timespec="seconds"),
|
||||
f"{hit.frequency:.0f}", fmt_hz(hit.frequency),
|
||||
f"{hit.duration:.2f}", f"{hit.snr_db:.1f}", f"{hit.bandwidth:.0f}",
|
||||
hit.mode, hit.category, hit.classification,
|
||||
f"{hit.confidence:.2f}", f"{hit.signal_score:.2f}",
|
||||
hit.morse_text.strip(), f"{hit.ctcss_hz:.1f}", f"{hit.baud:.0f}",
|
||||
hit.range_label, hit.filename,
|
||||
]
|
||||
with open(self.csv, "a") as fh:
|
||||
fh.write(",".join(_csv_escape(str(v)) for v in row) + "\n")
|
||||
|
||||
|
||||
def _csv_escape(v: str) -> str:
|
||||
if any(c in v for c in ',"\n'):
|
||||
return '"' + v.replace('"', '""') + '"'
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One file per frequency, appended to as the scan goes on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WAV_HEADER = 44 # canonical PCM header: RIFF + fmt + data
|
||||
|
||||
|
||||
def append_wav(path: Path, audio: np.ndarray, rate: int) -> int:
|
||||
"""Append mono audio to a WAV, creating it if needed.
|
||||
|
||||
The file is left valid after every append -- sizes in the header are
|
||||
rewritten each time -- so a long-running scan can be interrupted at any
|
||||
point and the recording so far still plays. The ``wave`` module cannot
|
||||
do this: it only writes its header when the file is closed.
|
||||
"""
|
||||
pcm = (np.clip(np.asarray(audio, dtype=np.float32), -1.0, 1.0)
|
||||
* 32767.0).astype("<i2").tobytes()
|
||||
if not pcm:
|
||||
return 0
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(_wav_header(rate, len(pcm)))
|
||||
fh.write(pcm)
|
||||
return len(pcm) // 2
|
||||
|
||||
with open(path, "r+b") as fh:
|
||||
fh.seek(24)
|
||||
existing_rate = int.from_bytes(fh.read(4), "little")
|
||||
fh.seek(40)
|
||||
data_bytes = int.from_bytes(fh.read(4), "little")
|
||||
if existing_rate != rate:
|
||||
raise ValueError(
|
||||
f"{path.name} holds {existing_rate} Hz audio, not {rate} Hz")
|
||||
fh.seek(_WAV_HEADER + data_bytes)
|
||||
fh.write(pcm)
|
||||
new_bytes = data_bytes + len(pcm)
|
||||
fh.seek(4)
|
||||
fh.write((36 + new_bytes).to_bytes(4, "little"))
|
||||
fh.seek(40)
|
||||
fh.write(new_bytes.to_bytes(4, "little"))
|
||||
return len(pcm) // 2
|
||||
|
||||
|
||||
def _wav_header(rate: int, data_bytes: int, channels: int = 1,
|
||||
bits: int = 16) -> bytes:
|
||||
byte_rate = rate * channels * bits // 8
|
||||
block_align = channels * bits // 8
|
||||
return b"".join((
|
||||
b"RIFF", (36 + data_bytes).to_bytes(4, "little"), b"WAVE",
|
||||
b"fmt ", (16).to_bytes(4, "little"), (1).to_bytes(2, "little"),
|
||||
channels.to_bytes(2, "little"), rate.to_bytes(4, "little"),
|
||||
byte_rate.to_bytes(4, "little"), block_align.to_bytes(2, "little"),
|
||||
bits.to_bytes(2, "little"),
|
||||
b"data", data_bytes.to_bytes(4, "little"),
|
||||
))
|
||||
|
||||
|
||||
def read_wav(path: Path) -> tuple[np.ndarray, int]:
|
||||
"""Read a mono 16-bit WAV back as floats."""
|
||||
with wave.open(str(path)) as w:
|
||||
rate = w.getframerate()
|
||||
channels = w.getnchannels()
|
||||
raw = w.readframes(w.getnframes())
|
||||
audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
|
||||
if channels > 1:
|
||||
audio = audio.reshape(-1, channels).mean(axis=1)
|
||||
return audio, rate
|
||||
|
||||
|
||||
def _resample(audio: np.ndarray, src: int, dst: int) -> np.ndarray:
|
||||
if src == dst or audio.size == 0:
|
||||
return audio
|
||||
from math import gcd
|
||||
from scipy.signal import resample_poly
|
||||
g = gcd(int(src), int(dst))
|
||||
return resample_poly(audio, int(dst) // g, int(src) // g).astype(np.float32)
|
||||
|
||||
|
||||
class FrequencyLog:
|
||||
"""Collects every transmission heard on one frequency into one file.
|
||||
|
||||
Each capture is appended after a spoken timestamp, so the result plays
|
||||
back as a running commentary of what was heard and when.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, tolerance_hz: float = 6_250.0,
|
||||
announce: bool = True, announce_frequency: bool = False,
|
||||
engine: str = "auto", gap_seconds: float = 0.4):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.tolerance = float(tolerance_hz)
|
||||
self.announce = announce
|
||||
self.announce_frequency = announce_frequency
|
||||
self.engine = engine
|
||||
self.gap_seconds = gap_seconds
|
||||
# centre frequency -> (path, sample rate)
|
||||
self._groups: list[tuple[float, Path, int]] = []
|
||||
# The date only has to be said when it changes, per file.
|
||||
self._last_date: dict[Path, object] = {}
|
||||
self.appended = 0
|
||||
|
||||
# -- grouping ---------------------------------------------------------
|
||||
def _group_for(self, frequency: float, rate: int) -> tuple[Path, int]:
|
||||
best = None
|
||||
best_delta = self.tolerance
|
||||
for i, (centre, path, existing_rate) in enumerate(self._groups):
|
||||
delta = abs(centre - frequency)
|
||||
if delta <= best_delta:
|
||||
best, best_delta = i, delta
|
||||
if best is not None:
|
||||
centre, path, existing_rate = self._groups[best]
|
||||
return path, existing_rate
|
||||
|
||||
path = self.root / f"{safe_freq_name(round(frequency / 100) * 100)}.wav"
|
||||
if path.exists():
|
||||
# Carry on with a file from an earlier run rather than clashing
|
||||
# with it: its sample rate is what everything must match.
|
||||
try:
|
||||
with wave.open(str(path)) as w:
|
||||
rate = w.getframerate()
|
||||
except (wave.Error, OSError):
|
||||
pass
|
||||
self._groups.append((frequency, path, rate))
|
||||
return path, rate
|
||||
|
||||
# -- appending ---------------------------------------------------------
|
||||
def add(self, frequency: float, audio: np.ndarray, rate: int,
|
||||
when: datetime | None = None) -> Path:
|
||||
"""Append one transmission, preceded by its spoken timestamp."""
|
||||
when = when or datetime.now()
|
||||
path, target_rate = self._group_for(frequency, int(rate))
|
||||
|
||||
pieces = []
|
||||
if self.announce:
|
||||
from .announce import speak_timestamp
|
||||
# Say the full date on the first transmission of each day and the
|
||||
# time alone after that. Repeating the date before every short
|
||||
# over would take longer to say than most of them last.
|
||||
say_date = self._last_date.get(path) != when.date()
|
||||
self._last_date[path] = when.date()
|
||||
spoken = speak_timestamp(
|
||||
when, float(target_rate),
|
||||
frequency=frequency if self.announce_frequency else None,
|
||||
engine=self.engine, with_date=say_date)
|
||||
if spoken.size:
|
||||
pieces.append(spoken)
|
||||
pieces.append(np.zeros(int(self.gap_seconds * target_rate),
|
||||
dtype=np.float32))
|
||||
|
||||
body = _resample(np.asarray(audio, dtype=np.float32), int(rate),
|
||||
target_rate)
|
||||
pieces.append(body)
|
||||
pieces.append(np.zeros(int(self.gap_seconds * target_rate),
|
||||
dtype=np.float32))
|
||||
|
||||
append_wav(path, np.concatenate(pieces), target_rate)
|
||||
self.appended += 1
|
||||
return path
|
||||
|
||||
def add_file(self, frequency: float, wav_path: Path,
|
||||
when: datetime | None = None) -> Path:
|
||||
audio, rate = read_wav(wav_path)
|
||||
return self.add(frequency, audio, rate, when)
|
||||
|
||||
@property
|
||||
def files(self) -> list[Path]:
|
||||
return [p for _, p, _ in self._groups]
|
||||
1073
bandsaunter/scanner.py
Executable file
1073
bandsaunter/scanner.py
Executable file
File diff suppressed because it is too large
Load diff
666
bandsaunter/settings.py
Normal file
666
bandsaunter/settings.py
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
"""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
|
||||
|
||||
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 = ""
|
||||
|
||||
@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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SETTINGS: 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. "
|
||||
"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"),
|
||||
|
||||
# -- 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_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"),
|
||||
|
||||
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", "freq_list",
|
||||
"never stop on these frequencies",
|
||||
"Useful for a local pager transmitter or a birdie the receiver makes "
|
||||
"itself. Frequencies may be written 162.55M, 162550000 or 162.55 MHz. "
|
||||
"The lock-out key during a scan adds the current frequency here for "
|
||||
"the rest of the run.",
|
||||
flags=("--lockout",), metavar="FREQ"),
|
||||
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",)),
|
||||
)
|
||||
|
||||
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"
|
||||
value = float(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 == "freq_list":
|
||||
from .ranges import RangeError, parse_frequency
|
||||
if not raw or raw.lower() in ("(none)", "none", "-"):
|
||||
return []
|
||||
out = []
|
||||
for part in raw.replace(";", ",").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
out.append(parse_frequency(part))
|
||||
except RangeError as exc:
|
||||
raise SettingError(str(exc)) from None
|
||||
return out
|
||||
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 == "freq_list":
|
||||
return ", ".join(fmt_hz(v) for v 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",
|
||||
"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 == "freq_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 == "freq_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)
|
||||
419
bandsaunter/simulator.py
Executable file
419
bandsaunter/simulator.py
Executable file
|
|
@ -0,0 +1,419 @@
|
|||
"""A synthetic RTL-SDR.
|
||||
|
||||
Implements the same interface as :class:`~bandsaunter.device.RtlSdrDevice` but
|
||||
generates IQ from a set of virtual transmitters. Used by the test suite and
|
||||
by ``bandsaunter --simulate``, which lets the whole scanner be exercised without a
|
||||
dongle attached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage as _ndi
|
||||
|
||||
from .morse import encode_morse
|
||||
|
||||
__all__ = ["SimulatedDevice", "VirtualTransmitter", "default_transmitters"]
|
||||
|
||||
|
||||
_SPEECH_RATE = 16_000
|
||||
_SPEECH_CACHE: dict = {}
|
||||
|
||||
|
||||
def _speech_loop(seed: int, pitch: float = 120.0, seconds: float = 8.0,
|
||||
phrases: bool = True) -> np.ndarray:
|
||||
"""Render a loop of synthetic speech at :data:`_SPEECH_RATE` (cached).
|
||||
|
||||
Glottal pulse train with pitch drift and jitter, shaped by three swept
|
||||
formant resonators, gated into syllables with pauses between phrases --
|
||||
the structure that separates a voice from a tone or a hum.
|
||||
"""
|
||||
key = (seed, round(pitch, 1), seconds, phrases)
|
||||
cached = _SPEECH_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from scipy import signal as sps
|
||||
rng = np.random.default_rng(seed)
|
||||
fs = float(_SPEECH_RATE)
|
||||
n = int(seconds * fs)
|
||||
t = np.arange(n) / fs
|
||||
|
||||
f0 = pitch * (1.0 + 0.06 * np.sin(2 * np.pi * 0.7 * t)
|
||||
+ 0.03 * np.sin(2 * np.pi * 0.23 * t + 1.1))
|
||||
phase = np.cumsum(2.0 * np.pi * f0 / fs)
|
||||
exc = np.zeros(n)
|
||||
exc[np.flatnonzero(np.diff(np.floor(phase / (2 * np.pi))) > 0)] = 1.0
|
||||
exc -= exc.mean()
|
||||
|
||||
out = np.zeros(n)
|
||||
seg = int(fs * 0.04)
|
||||
for base, bw, amp in ((650.0, 90.0, 1.0), (1200.0, 110.0, 0.55),
|
||||
(2600.0, 160.0, 0.30)):
|
||||
sweep = base * (1.0 + 0.22 * np.sin(2 * np.pi * 1.9 * t
|
||||
+ rng.uniform(0, 6.283)))
|
||||
y = np.zeros(n)
|
||||
for a in range(0, n - seg, seg):
|
||||
fc = float(np.mean(sweep[a:a + seg]))
|
||||
b, aa = sps.iirpeak(min(fc, fs / 2 * 0.95) / (fs / 2),
|
||||
max(2.0, fc / bw))
|
||||
y[a:a + seg] = sps.lfilter(b, aa, exc[a:a + seg])
|
||||
out += amp * y
|
||||
|
||||
# A little fricative noise on some syllables.
|
||||
out += 0.05 * rng.standard_normal(n) * (
|
||||
np.abs(np.sin(2 * np.pi * 2.3 * t)) > 0.8)
|
||||
out /= max(np.abs(out).max(), 1e-9)
|
||||
|
||||
# Transmitter audio processing. Every real transmitter compresses hard
|
||||
# before the modulator, which is why speech on the air sits near full
|
||||
# modulation instead of at the -25 dB average that raw speech has.
|
||||
# Without this the simulated signals are far too lightly modulated to
|
||||
# resemble anything on a real band.
|
||||
out = np.tanh(3.5 * out)
|
||||
|
||||
# The talker's own syllables and pauses come after the compressor.
|
||||
syl = 0.55 + 0.45 * np.sin(2 * np.pi * 4.0 * t)
|
||||
out *= syl
|
||||
if phrases:
|
||||
# Pauses between phrases, for a transmitter that stays keyed.
|
||||
phrase = (np.sin(2 * np.pi * 0.35 * t) > -0.5).astype(float)
|
||||
k = max(1, int(0.02 * fs))
|
||||
out *= sps.lfilter(np.ones(k) / k, [1.0], phrase)
|
||||
out /= max(np.abs(out).max(), 1e-9)
|
||||
|
||||
_SPEECH_CACHE[key] = out
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualTransmitter:
|
||||
"""One synthetic signal on the air."""
|
||||
|
||||
frequency: float
|
||||
mode: str = "nfm" # nfm wfm am usb cw fsk2 fsk4 psk carrier ook
|
||||
power: float = 0.35 # linear amplitude
|
||||
bandwidth: float = 12_500.0
|
||||
label: str = ""
|
||||
# Scheduling: transmit for ``on_seconds`` out of every ``period_seconds``.
|
||||
period_seconds: float = 0.0 # 0 = always on
|
||||
on_seconds: float = 0.0
|
||||
phase_offset: float = 0.0
|
||||
# Mode-specific extras
|
||||
ctcss: float = 0.0
|
||||
message: str = "CQ CQ DE W1AW K"
|
||||
wpm: float = 18.0
|
||||
baud: float = 4800.0
|
||||
deviation: float = 2_500.0
|
||||
pitch_hz: float = 120.0 # synthetic talker's voice pitch
|
||||
|
||||
_phase: float = field(default=0.0, init=False, repr=False)
|
||||
_rng: np.random.Generator = field(default=None, init=False, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self._seed = abs(hash(self.label or self.frequency)) % 2**31
|
||||
self._rng = np.random.default_rng(self._seed)
|
||||
if not self.label:
|
||||
self.label = f"{self.frequency/1e6:.4f} MHz {self.mode}"
|
||||
|
||||
def active(self, t: float) -> bool:
|
||||
if self.period_seconds <= 0 or self.on_seconds <= 0:
|
||||
return True
|
||||
phase = (t + self.phase_offset) % self.period_seconds
|
||||
return phase < self.on_seconds
|
||||
|
||||
# -- waveform generation --------------------------------------------
|
||||
def generate(self, t0: float, n: int, fs: float) -> np.ndarray:
|
||||
"""Complex baseband centred on this transmitter's own frequency."""
|
||||
if not self.active(t0):
|
||||
return np.zeros(n, dtype=np.complex64)
|
||||
t = t0 + np.arange(n, dtype=np.float64) / fs
|
||||
m = self.mode
|
||||
|
||||
if m == "carrier":
|
||||
out = np.exp(1j * self._advance(np.zeros(n), fs))
|
||||
elif m in ("nfm", "wfm"):
|
||||
dev = self.deviation if m == "nfm" else 75_000.0
|
||||
audio = self._audio(t)
|
||||
if self.ctcss:
|
||||
audio = 0.85 * audio + 0.15 * np.sin(2 * np.pi * self.ctcss * t)
|
||||
out = np.exp(1j * self._advance(dev * audio, fs))
|
||||
elif m == "am":
|
||||
out = ((1.0 + 0.6 * self._audio(t)) *
|
||||
np.exp(1j * self._advance(np.zeros(n), fs)))
|
||||
elif m in ("usb", "lsb"):
|
||||
audio = self._audio(t)
|
||||
from scipy.signal import hilbert
|
||||
an = hilbert(audio)
|
||||
out = an if m == "usb" else np.conj(an)
|
||||
elif m == "cw":
|
||||
env = self._morse_envelope(t, fs)
|
||||
out = env * np.exp(1j * self._advance(np.zeros(n), fs))
|
||||
elif m == "ook":
|
||||
env = (self._symbols(t, fs, 2, self.baud) > 0).astype(np.float64)
|
||||
out = env * np.exp(1j * self._advance(np.zeros(n), fs))
|
||||
elif m in ("fsk2", "fsk4"):
|
||||
levels = 2 if m == "fsk2" else 4
|
||||
sym = self._symbols(t, fs, levels, self.baud)
|
||||
lv = (sym - (levels - 1) / 2.0) / max(1.0, (levels - 1) / 2.0)
|
||||
out = np.exp(1j * self._advance(lv * self.deviation, fs))
|
||||
elif m == "psk":
|
||||
sym = self._symbols(t, fs, 4, self.baud)
|
||||
out = np.exp(1j * (2 * np.pi * sym / 4.0))
|
||||
else:
|
||||
out = (self._rng.standard_normal(n) +
|
||||
1j * self._rng.standard_normal(n)) * 0.3
|
||||
|
||||
return (self.power * out).astype(np.complex64)
|
||||
|
||||
def _advance(self, inst_freq: np.ndarray, fs: float) -> np.ndarray:
|
||||
"""Integrate an instantaneous-frequency series, keeping phase continuous."""
|
||||
ph = self._phase + np.cumsum(2.0 * np.pi * inst_freq / fs)
|
||||
self._phase = float(ph[-1] % (2.0 * np.pi)) if ph.size else self._phase
|
||||
return ph
|
||||
|
||||
def _audio(self, t: np.ndarray) -> np.ndarray:
|
||||
"""Speech on the RF time base.
|
||||
|
||||
A synthetic talker (glottal pulses through moving formants, gated into
|
||||
syllables with pauses) is rendered once at audio rate and cached, then
|
||||
read out against absolute time. Sine tones would be far cheaper but
|
||||
would not exercise the speech detector at all.
|
||||
"""
|
||||
# A transmitter that keys on and off is modelling push-to-talk, where
|
||||
# the operator talks for as long as the key is down. Its speech loop
|
||||
# therefore has no phrase pauses of its own -- the keying supplies
|
||||
# them -- and each over starts at a fresh point in the loop, so a
|
||||
# short over carries speech rather than whatever silence happened to
|
||||
# line up with it.
|
||||
ptt = self.period_seconds > 0 and self.on_seconds > 0
|
||||
loop = _speech_loop(self._seed, self.pitch_hz, phrases=not ptt)
|
||||
if ptt:
|
||||
shifted = t + self.phase_offset
|
||||
into_over = shifted % self.period_seconds
|
||||
over = np.floor(shifted / self.period_seconds)
|
||||
start = (over * 2.7) % (loop.size / _SPEECH_RATE)
|
||||
pos = ((start + into_over) * _SPEECH_RATE) % loop.size
|
||||
else:
|
||||
pos = (t * _SPEECH_RATE) % loop.size
|
||||
# Linear interpolation, not nearest sample. Holding each audio sample
|
||||
# across 128 RF samples is a zero-order hold, and its images put
|
||||
# audible energy tens of kilohertz either side of the carrier -- which
|
||||
# made a nominally 8 kHz AM signal measure 25 kHz wide.
|
||||
i0 = pos.astype(np.int64)
|
||||
frac = pos - i0
|
||||
i1 = (i0 + 1) % loop.size
|
||||
return loop[i0] * (1.0 - frac) + loop[i1] * frac
|
||||
|
||||
def _symbols(self, t: np.ndarray, fs: float, levels: int,
|
||||
baud: float) -> np.ndarray:
|
||||
"""Deterministic pseudo-random symbols on an absolute-time grid."""
|
||||
idx = np.floor(t * baud).astype(np.int64)
|
||||
# A cheap hash keeps the stream identical across block boundaries.
|
||||
h = (idx * 2654435761) % 4294967296
|
||||
return (h % levels).astype(np.float64)
|
||||
|
||||
def _morse_envelope(self, t: np.ndarray, fs: float) -> np.ndarray:
|
||||
key = self._morse_key(self.message, self.wpm)
|
||||
dot = 1.2 / self.wpm
|
||||
total = len(key) * dot
|
||||
pos = ((t % total) / dot).astype(np.int64)
|
||||
env = key[np.clip(pos, 0, len(key) - 1)].astype(np.float64)
|
||||
# Soften the edges the way real keying shaping does. Two box filters
|
||||
# give a triangular window in O(n) -- a direct convolution with a
|
||||
# 6 ms kernel at 2 MSPS is thousands of taps and dominates runtime.
|
||||
k = max(3, int(0.003 * fs))
|
||||
if env.size > 2 * k:
|
||||
env = _ndi.uniform_filter1d(env, k, mode="nearest")
|
||||
env = _ndi.uniform_filter1d(env, k, mode="nearest")
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _morse_key(msg: str, wpm: float) -> np.ndarray:
|
||||
"""One element per dot-unit: 1 = key down, 0 = key up."""
|
||||
units: list[int] = [0] * 2
|
||||
toks = encode_morse(msg).split(" ")
|
||||
for i, tok in enumerate(toks):
|
||||
if tok == "/":
|
||||
units += [0] * 7
|
||||
continue
|
||||
if i > 0 and toks[i - 1] != "/":
|
||||
units += [0] * 3
|
||||
for j, el in enumerate(tok):
|
||||
if j > 0:
|
||||
units += [0]
|
||||
units += [1] * (1 if el == "." else 3)
|
||||
units += [0] * 2
|
||||
return np.array(units, dtype=np.float64)
|
||||
|
||||
|
||||
def default_transmitters() -> list[VirtualTransmitter]:
|
||||
"""A demo band populated with one of each interesting signal type."""
|
||||
V = VirtualTransmitter
|
||||
return [
|
||||
V(146_520_000, "nfm", 0.40, 12_500, "2 m simplex voice", ctcss=100.0,
|
||||
period_seconds=14, on_seconds=6),
|
||||
V(146_940_000, "nfm", 0.30, 12_500, "2 m repeater", ctcss=131.8,
|
||||
period_seconds=19, on_seconds=5, phase_offset=7),
|
||||
V(144_100_000, "cw", 0.30, 500, "2 m CW beacon", wpm=18,
|
||||
message="VVV DE W1AW/B FN31"),
|
||||
V(162_400_000, "nfm", 0.35, 12_500, "NOAA weather radio"),
|
||||
V(121_500_000, "am", 0.30, 8_000, "airband AM", period_seconds=17,
|
||||
on_seconds=5, phase_offset=3),
|
||||
V(97_500_000, "wfm", 0.45, 180_000, "FM broadcast"),
|
||||
V(460_025_000, "fsk4", 0.30, 12_500, "P25-style digital voice",
|
||||
baud=4800, deviation=1_800, period_seconds=13, on_seconds=4),
|
||||
V(929_612_500, "fsk2", 0.28, 12_500, "POCSAG pager", baud=1200,
|
||||
deviation=2_400, period_seconds=11, on_seconds=2, phase_offset=5),
|
||||
V(446_000_000, "carrier", 0.25, 1_000, "unmodulated carrier"),
|
||||
V(433_920_000, "ook", 0.30, 40_000, "ISM remote", baud=2000,
|
||||
period_seconds=9, on_seconds=1.2),
|
||||
]
|
||||
|
||||
|
||||
class SimulatedDevice:
|
||||
"""Drop-in replacement for :class:`RtlSdrDevice` backed by synthetic IQ."""
|
||||
|
||||
def __init__(self, sample_rate: int = 2_048_000,
|
||||
transmitters: list[VirtualTransmitter] | None = None,
|
||||
noise_amplitude: float = 0.012,
|
||||
seed: int = 0, realtime: bool = False, **_ignored):
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.transmitters = (default_transmitters() if transmitters is None
|
||||
else list(transmitters))
|
||||
self.noise_amplitude = float(noise_amplitude)
|
||||
self.index = 0
|
||||
self.tuner = "simulated R820T2"
|
||||
self.gain = "auto"
|
||||
self.ppm = 0
|
||||
self.retunes = 0
|
||||
self.samples_read = 0
|
||||
self.realtime = realtime
|
||||
self._rng = np.random.default_rng(seed)
|
||||
self._center = 100_000_000
|
||||
self._t = 0.0
|
||||
self._start_wall = time.time()
|
||||
self._open = False
|
||||
|
||||
# -- lifecycle -------------------------------------------------------
|
||||
def open(self):
|
||||
self._open = True
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
self._open = False
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._open
|
||||
|
||||
# -- configuration ----------------------------------------------------
|
||||
@property
|
||||
def available_gains(self):
|
||||
return [0.0, 9.0, 14.4, 22.9, 28.0, 33.8, 40.2, 49.6]
|
||||
|
||||
@property
|
||||
def current_gain(self):
|
||||
return self.gain
|
||||
|
||||
def apply_gain(self, gain):
|
||||
self.gain = gain
|
||||
|
||||
def set_sample_rate(self, rate: int) -> int:
|
||||
self.sample_rate = int(rate)
|
||||
return self.sample_rate
|
||||
|
||||
@property
|
||||
def direct_sampling_mode(self) -> int:
|
||||
return 2 if self._center < 24_000_000 else 0
|
||||
|
||||
def can_tune(self, hz: float) -> bool:
|
||||
return 0 < hz <= 1_766_000_000
|
||||
|
||||
def tune(self, hz: float, settle: bool = True) -> int:
|
||||
self._center = int(round(hz))
|
||||
self.retunes += 1
|
||||
return self._center
|
||||
|
||||
@property
|
||||
def center_freq(self) -> int:
|
||||
return self._center
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
# -- streaming (the simulator has no USB to fall behind) -------------
|
||||
def start_stream(self, *a, **kw) -> None:
|
||||
self._streaming = True
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
self._streaming = False
|
||||
|
||||
@property
|
||||
def streaming(self) -> bool:
|
||||
return getattr(self, "_streaming", False)
|
||||
|
||||
@property
|
||||
def dropped_samples(self) -> int:
|
||||
return 0
|
||||
|
||||
def read_stream(self, count: int, timeout: float = 3.0) -> np.ndarray:
|
||||
return self.read_samples(count)
|
||||
|
||||
# -- capture -----------------------------------------------------------
|
||||
def read_samples(self, count: int, flush: bool = False) -> np.ndarray:
|
||||
count = int(count)
|
||||
fs = float(self.sample_rate)
|
||||
# The simulated clock advances with the samples handed out, so signal
|
||||
# schedules and keying line up with the recorded durations.
|
||||
t0 = self._t
|
||||
self._t += count / fs
|
||||
|
||||
out = (self._rng.standard_normal(count) +
|
||||
1j * self._rng.standard_normal(count)).astype(np.complex64)
|
||||
out *= np.complex64(self.noise_amplitude / math.sqrt(2.0))
|
||||
|
||||
half = fs / 2.0
|
||||
t = t0 + np.arange(count, dtype=np.float64) / fs
|
||||
for tx in self.transmitters:
|
||||
delta = tx.frequency - self._center
|
||||
if abs(delta) > half * 0.98:
|
||||
continue
|
||||
base = tx.generate(t0, count, fs)
|
||||
if not np.any(base):
|
||||
continue
|
||||
out += (base * np.exp(2j * np.pi * delta * t)).astype(np.complex64)
|
||||
|
||||
if self.realtime:
|
||||
target = self._start_wall + self._t
|
||||
gap = target - time.time()
|
||||
if gap > 0:
|
||||
time.sleep(min(gap, 0.5))
|
||||
|
||||
self.samples_read += count
|
||||
# Quantise to 8 bits, like the real RTL2832 ADC.
|
||||
q = np.clip(np.stack([out.real, out.imag]), -1.0, 0.996)
|
||||
q = np.round(q * 128.0) / 128.0
|
||||
return (q[0] + 1j * q[1]).astype(np.complex64)
|
||||
|
||||
def read_seconds(self, seconds: float, flush: bool = False) -> np.ndarray:
|
||||
return self.read_samples(int(self.sample_rate * seconds), flush=flush)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {"index": self.index, "tuner": self.tuner,
|
||||
"sample_rate": self.sample_rate, "center_freq": self._center,
|
||||
"gain": self.gain, "ppm": self.ppm,
|
||||
"direct_sampling": self.direct_sampling_mode,
|
||||
"retunes": self.retunes, "samples_read": self.samples_read,
|
||||
"simulated": True}
|
||||
411
bandsaunter/transcribe.py
Normal file
411
bandsaunter/transcribe.py
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"""Turning recorded speech into text.
|
||||
|
||||
Speech recognition needs a trained model, so unlike the spoken announcements
|
||||
there is no built-in fallback: an engine has to be installed. Several are
|
||||
supported and whichever is present is used. Recognition is slow enough that
|
||||
it runs on its own thread, so a scan never waits for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._quiet import suppress_stderr
|
||||
|
||||
__all__ = ["Transcript", "transcribe", "available_engine", "ENGINES",
|
||||
"TranscriptionWorker", "describe_engines"]
|
||||
|
||||
# Best first. Whisper handles the noise and clipping of radio audio far
|
||||
# better than the smaller recognisers, which were trained on clean speech.
|
||||
ENGINES = ("faster-whisper", "whisper", "whisper-cli", "vosk", "pocketsphinx")
|
||||
|
||||
# Vosk's bundled models are named by region.
|
||||
_VOSK_LANGS = {"en": "en-us", "en-gb": "en-us", "": "en-us",
|
||||
"pt": "pt", "zh": "cn", "cn": "cn"}
|
||||
|
||||
# Set BANDSAUNTER_ENGINE_OUTPUT=1 to watch a model download or debug an engine.
|
||||
_SHOW_ENGINE_OUTPUT = os.environ.get("BANDSAUNTER_ENGINE_OUTPUT", "") not in \
|
||||
("", "0", "no", "false")
|
||||
|
||||
_ENGINE_NOTES = {
|
||||
"faster-whisper": "pip install faster-whisper (best on radio audio)",
|
||||
"whisper": "pip install openai-whisper",
|
||||
"whisper-cli": "whisper.cpp, no Python dependencies",
|
||||
"vosk": "pip install vosk (small, weaker on noisy audio)",
|
||||
"pocketsphinx": "apt install pocketsphinx (tiny, poor on radio audio)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transcript:
|
||||
text: str = ""
|
||||
engine: str = ""
|
||||
language: str = ""
|
||||
duration: float = 0.0
|
||||
segments: list = field(default_factory=list) # (start, end, text)
|
||||
note: str = ""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.text.strip())
|
||||
|
||||
|
||||
def describe_engines() -> list[tuple[str, bool, str]]:
|
||||
"""``(name, installed, how to get it)`` for every supported engine."""
|
||||
return [(name, _is_present(name), _ENGINE_NOTES.get(name, ""))
|
||||
for name in ENGINES]
|
||||
|
||||
|
||||
def _is_present(name: str) -> bool:
|
||||
if name == "faster-whisper":
|
||||
return _importable("faster_whisper")
|
||||
if name == "whisper":
|
||||
return _importable("whisper")
|
||||
if name == "vosk":
|
||||
return _importable("vosk")
|
||||
if name == "pocketsphinx":
|
||||
return _importable("pocketsphinx")
|
||||
if name == "whisper-cli":
|
||||
return any(shutil.which(exe) for exe in ("whisper-cli", "whisper-cpp",
|
||||
"whisper.cpp"))
|
||||
return False
|
||||
|
||||
|
||||
def _importable(module: str) -> bool:
|
||||
import importlib.util
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def available_engine() -> str | None:
|
||||
for name in ENGINES:
|
||||
if _is_present(name):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engines. Models are expensive to load, so each is kept once it is built.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MODELS: dict[tuple, object] = {}
|
||||
_MODEL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _faster_whisper(audio, rate, model, language):
|
||||
from faster_whisper import WhisperModel
|
||||
key = ("faster-whisper", model or "base.en")
|
||||
with _MODEL_LOCK:
|
||||
engine = _MODELS.get(key)
|
||||
if engine is None:
|
||||
engine = WhisperModel(key[1], device="cpu", compute_type="int8")
|
||||
_MODELS[key] = engine
|
||||
segments, info = engine.transcribe(
|
||||
audio, language=language or None,
|
||||
vad_filter=True, beam_size=5)
|
||||
out, pieces = [], []
|
||||
for seg in segments:
|
||||
pieces.append((float(seg.start), float(seg.end), seg.text.strip()))
|
||||
out.append(seg.text.strip())
|
||||
return Transcript(text=" ".join(p for p in out if p).strip(),
|
||||
engine="faster-whisper",
|
||||
language=getattr(info, "language", "") or "",
|
||||
segments=pieces)
|
||||
|
||||
|
||||
def _openai_whisper(audio, rate, model, language):
|
||||
import whisper
|
||||
key = ("whisper", model or "base.en")
|
||||
with _MODEL_LOCK:
|
||||
engine = _MODELS.get(key)
|
||||
if engine is None:
|
||||
engine = whisper.load_model(key[1])
|
||||
_MODELS[key] = engine
|
||||
result = engine.transcribe(np.asarray(audio, dtype=np.float32),
|
||||
language=language or None, fp16=False)
|
||||
pieces = [(float(s["start"]), float(s["end"]), s["text"].strip())
|
||||
for s in result.get("segments", [])]
|
||||
return Transcript(text=str(result.get("text", "")).strip(),
|
||||
engine="whisper", language=result.get("language", ""),
|
||||
segments=pieces)
|
||||
|
||||
|
||||
def _whisper_cli(audio, rate, model, language):
|
||||
exe = next((shutil.which(e) for e in ("whisper-cli", "whisper-cpp",
|
||||
"whisper.cpp") if shutil.which(e)),
|
||||
None)
|
||||
if not exe:
|
||||
return None
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
wav = Path(tmp) / "clip.wav"
|
||||
_write_wav(wav, audio, 16000)
|
||||
cmd = [exe, "-f", str(wav), "-otxt", "-of", str(Path(tmp) / "out"),
|
||||
"-nt"]
|
||||
if model:
|
||||
cmd += ["-m", model]
|
||||
if language:
|
||||
cmd += ["-l", language]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
|
||||
text = (Path(tmp) / "out.txt").read_text().strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return Transcript(text=text, engine="whisper-cli", language=language)
|
||||
|
||||
|
||||
def _vosk(audio, rate, model, language):
|
||||
import json as _json
|
||||
|
||||
from vosk import KaldiRecognizer, Model
|
||||
# The model setting is shared with the whisper engines, where it holds a
|
||||
# size such as "base.en". For vosk it is a path, so anything that is not
|
||||
# an actual directory falls back to the language default rather than
|
||||
# failing with a confusing "folder does not exist".
|
||||
# Vosk names its bundled models by region, so the plain codes the rest of
|
||||
# the program uses have to be mapped or it reports "lang en does not exist".
|
||||
lang = _VOSK_LANGS.get((language or "en").lower(), language or "en-us")
|
||||
path = Path(model).expanduser() if model else None
|
||||
use_path = bool(path and path.is_dir())
|
||||
key = ("vosk", str(path) if use_path else f"lang:{lang}")
|
||||
with _MODEL_LOCK:
|
||||
engine = _MODELS.get(key)
|
||||
if engine is None:
|
||||
engine = Model(str(path)) if use_path else Model(lang=lang)
|
||||
_MODELS[key] = engine
|
||||
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
|
||||
rec = KaldiRecognizer(engine, 16000)
|
||||
rec.SetWords(False)
|
||||
out = []
|
||||
for i in range(0, len(pcm), 8000):
|
||||
if rec.AcceptWaveform(pcm[i:i + 8000]):
|
||||
out.append(_json.loads(rec.Result()).get("text", ""))
|
||||
out.append(_json.loads(rec.FinalResult()).get("text", ""))
|
||||
return Transcript(text=" ".join(p for p in out if p).strip(),
|
||||
engine="vosk", language=language)
|
||||
|
||||
|
||||
def _pocketsphinx(audio, rate, model, language):
|
||||
from pocketsphinx import Decoder
|
||||
decoder = Decoder(samprate=16000)
|
||||
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
|
||||
decoder.start_utt()
|
||||
decoder.process_raw(pcm, False, True)
|
||||
decoder.end_utt()
|
||||
hyp = decoder.hyp()
|
||||
return Transcript(text=(hyp.hypstr if hyp else "").strip(),
|
||||
engine="pocketsphinx", language=language)
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"faster-whisper": _faster_whisper,
|
||||
"whisper": _openai_whisper,
|
||||
"whisper-cli": _whisper_cli,
|
||||
"vosk": _vosk,
|
||||
"pocketsphinx": _pocketsphinx,
|
||||
}
|
||||
|
||||
|
||||
def _as_16k(audio: np.ndarray, rate: float) -> np.ndarray:
|
||||
"""Every one of these engines wants 16 kHz mono float.
|
||||
|
||||
Applied once in :func:`transcribe` rather than inside each engine, so an
|
||||
engine added later cannot forget to do it and be quietly handed audio at
|
||||
the wrong rate.
|
||||
"""
|
||||
audio = np.asarray(audio, dtype=np.float32).ravel()
|
||||
if int(rate) == 16000 or audio.size == 0:
|
||||
return audio
|
||||
from math import gcd
|
||||
|
||||
from scipy.signal import resample_poly
|
||||
g = gcd(int(rate), 16000)
|
||||
return resample_poly(audio, 16000 // g, int(rate) // g).astype(np.float32)
|
||||
|
||||
|
||||
def _write_wav(path: Path, audio: np.ndarray, rate: int) -> None:
|
||||
with wave.open(str(path), "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(rate)
|
||||
w.writeframes((np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes())
|
||||
|
||||
|
||||
def transcribe(audio: np.ndarray, rate: float, engine: str = "auto",
|
||||
model: str = "", language: str = "en") -> Transcript | None:
|
||||
"""Recognise speech in a block of audio.
|
||||
|
||||
Returns None when no engine is installed, so the caller can say so once
|
||||
rather than failing on every capture.
|
||||
"""
|
||||
chosen = available_engine() if engine in ("", "auto") else engine
|
||||
if not chosen:
|
||||
return None
|
||||
handler = _DISPATCH.get(chosen)
|
||||
if handler is None:
|
||||
return Transcript(note=f"unknown transcription engine {chosen!r}")
|
||||
if not _is_present(chosen):
|
||||
return Transcript(note=f"{chosen} is not installed")
|
||||
original_seconds = np.asarray(audio).size / float(rate)
|
||||
audio = _as_16k(audio, rate)
|
||||
try:
|
||||
# Engines announce model downloads and load progress on stderr, which
|
||||
# would be drawn straight over the live display.
|
||||
with suppress_stderr(not _SHOW_ENGINE_OUTPUT):
|
||||
result = handler(audio, 16000, model, language)
|
||||
except Exception as exc: # engines fail in many ways
|
||||
return Transcript(note=f"{chosen} failed: {exc}")
|
||||
if result is None:
|
||||
return Transcript(note=f"{chosen} produced nothing")
|
||||
result.duration = original_seconds
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running it off the scan loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
audio: np.ndarray
|
||||
rate: float
|
||||
path: Path
|
||||
when: datetime
|
||||
frequency: float
|
||||
append: bool
|
||||
meta_path: Path | None = None
|
||||
|
||||
|
||||
class TranscriptionWorker:
|
||||
"""Transcribes captures on its own thread.
|
||||
|
||||
Recognition takes seconds per clip -- far longer than a capture -- so
|
||||
doing it inline would mean missing whatever transmitted meanwhile.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: str = "auto", model: str = "",
|
||||
language: str = "en", max_queue: int = 32,
|
||||
on_done=None, on_error=None):
|
||||
self.engine = engine
|
||||
self.model = model
|
||||
self.language = language
|
||||
self.on_done = on_done
|
||||
self.on_error = on_error
|
||||
self._queue: queue.Queue = queue.Queue(maxsize=max_queue)
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self.written = 0
|
||||
self.dropped = 0
|
||||
self.failed = 0
|
||||
self.empty = 0
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, daemon=True,
|
||||
name="transcribe")
|
||||
self._thread.start()
|
||||
|
||||
def close(self, timeout: float = 300.0) -> None:
|
||||
"""Finish the queue, then stop. Called when the scan ends."""
|
||||
if self._thread is None:
|
||||
return
|
||||
self._queue.put(None)
|
||||
self._thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
return self._queue.qsize()
|
||||
|
||||
# -- work --------------------------------------------------------------
|
||||
def submit(self, audio: np.ndarray, rate: float, path: Path,
|
||||
when: datetime, frequency: float, append: bool = False,
|
||||
meta_path: Path | None = None) -> bool:
|
||||
if audio is None or audio.size == 0:
|
||||
return False
|
||||
try:
|
||||
self._queue.put_nowait(_Job(np.asarray(audio, dtype=np.float32),
|
||||
float(rate), Path(path), when,
|
||||
float(frequency), append,
|
||||
Path(meta_path) if meta_path else None))
|
||||
return True
|
||||
except queue.Full:
|
||||
self.dropped += 1
|
||||
return False
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
job = self._queue.get()
|
||||
if job is None:
|
||||
break
|
||||
try:
|
||||
self._handle(job)
|
||||
except Exception as exc:
|
||||
self.failed += 1
|
||||
if self.on_error:
|
||||
try:
|
||||
self.on_error(exc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle(self, job: _Job) -> None:
|
||||
result = transcribe(job.audio, job.rate, self.engine, self.model,
|
||||
self.language)
|
||||
if result is None:
|
||||
self.failed += 1
|
||||
return
|
||||
body = result.text.strip()
|
||||
if not body:
|
||||
# Nothing was said. Writing a file to announce that leaves a
|
||||
# directory full of placeholders, so write nothing at all; the
|
||||
# count is reported at the end of the scan instead.
|
||||
self.empty += 1
|
||||
return
|
||||
|
||||
if job.append:
|
||||
stamp = job.when.strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(job.path, "a") as fh:
|
||||
fh.write(f"[{stamp}] {body}\n")
|
||||
else:
|
||||
with open(job.path, "w") as fh:
|
||||
fh.write(f"{body}\n")
|
||||
self.written += 1
|
||||
self._record_in_metadata(job, body)
|
||||
if self.on_done:
|
||||
try:
|
||||
self.on_done(job.path, result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _record_in_metadata(job: _Job, text: str) -> None:
|
||||
"""Note the transcript in the capture's sidecar, once it exists.
|
||||
|
||||
Written here rather than when the capture was queued, so the metadata
|
||||
never points at a transcript file that was never created.
|
||||
"""
|
||||
if job.meta_path is None or not job.meta_path.exists():
|
||||
return
|
||||
try:
|
||||
import json
|
||||
data = json.loads(job.meta_path.read_text())
|
||||
hit = data.get("hit")
|
||||
if isinstance(hit, dict):
|
||||
hit["transcript_path"] = str(job.path)
|
||||
hit["transcript"] = text
|
||||
job.meta_path.write_text(json.dumps(data, indent=2, default=str))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
697
bandsaunter/tui.py
Normal file
697
bandsaunter/tui.py
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
"""The in-application interface: configure everything without command-line flags.
|
||||
|
||||
Every setting the command line accepts is reachable here, because both are
|
||||
generated from the same table in :mod:`bandsaunter.settings`. Each one carries
|
||||
its own help, so nothing has to be looked up elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from . import bandplan, settings as st
|
||||
from .bandplan import CATEGORIES, PRESETS, BandPreset, fmt_hz, in_category, search
|
||||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_OUTPUT_DIR, ScanConfig,
|
||||
delete_profile, list_profiles, load_config, save_config,
|
||||
save_default)
|
||||
from .ranges import RangeError, ScanRange, parse_frequency
|
||||
|
||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||
"first_run_setup", "TUIAbort"]
|
||||
|
||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||
|
||||
|
||||
def _rule(console: Console, text: str) -> None:
|
||||
console.print()
|
||||
console.rule(f"[bold]{text}[/bold]", style="blue")
|
||||
|
||||
|
||||
class TUIAbort(Exception):
|
||||
"""Input ended or the user interrupted; unwind out of the menus."""
|
||||
|
||||
|
||||
def _ask(console: Console, prompt: str, default: str = "") -> str:
|
||||
try:
|
||||
return Prompt.ask(prompt, default=default, show_default=bool(default))
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
# Returning an empty string here would send the menu round again and,
|
||||
# with no input left to read, round forever.
|
||||
raise TUIAbort() from None
|
||||
|
||||
|
||||
def _confirm(prompt: str, default: bool = False) -> bool:
|
||||
"""Yes/no that treats a closed input as "no" rather than crashing."""
|
||||
try:
|
||||
return Confirm.ask(prompt, default=default)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ranges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def show_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
if not cfg.ranges:
|
||||
console.print("[yellow]No frequency ranges selected yet.[/yellow] "
|
||||
"[grey62]Add some with 1 or 2.[/grey62]")
|
||||
return
|
||||
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("label")
|
||||
t.add_column("from", justify="right")
|
||||
t.add_column("to", justify="right")
|
||||
t.add_column("span", justify="right", style="grey62")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("on", justify="center")
|
||||
total = 0.0
|
||||
for i, r in enumerate(cfg.ranges, 1):
|
||||
if r.enabled:
|
||||
total += r.span
|
||||
t.add_row(str(i), r.label, fmt_hz(r.start), fmt_hz(r.stop),
|
||||
fmt_hz(r.span), r.mode,
|
||||
"[green]yes[/green]" if r.enabled else "[red]no[/red]")
|
||||
console.print(t)
|
||||
console.print(f"[grey62]{len(cfg.ranges)} range(s), {fmt_hz(total)} of "
|
||||
f"spectrum enabled[/grey62]")
|
||||
|
||||
|
||||
def add_manual_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
_rule(console, "add frequency ranges")
|
||||
console.print(
|
||||
"Enter a start and end frequency for each range. Units may be "
|
||||
"written [cyan]144M[/cyan], [cyan]144 MHz[/cyan], "
|
||||
"[cyan]144000k[/cyan] or plain Hz; a bare number under 10000 is read "
|
||||
"as MHz.\nLeave the start blank when you are done. There is no limit "
|
||||
"on how many ranges you add.\n")
|
||||
added = 0
|
||||
while True:
|
||||
start_s = _ask(console, f" [bold]start[/bold] of range "
|
||||
f"{len(cfg.ranges) + 1}")
|
||||
if not start_s.strip():
|
||||
break
|
||||
try:
|
||||
start = parse_frequency(start_s)
|
||||
except RangeError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
end_s = _ask(console, " [bold]end[/bold] of range")
|
||||
try:
|
||||
stop = parse_frequency(end_s) if end_s.strip() else start
|
||||
except RangeError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
|
||||
r = ScanRange(start, stop)
|
||||
covering = bandplan.presets_covering(0.5 * (r.start + r.stop))
|
||||
if covering:
|
||||
best = min(covering, key=lambda p: p.span)
|
||||
r.label = f"{fmt_hz(r.start)}-{fmt_hz(r.stop)} ({best.name})"
|
||||
console.print(f" [grey62]that falls in: {best.name} — default "
|
||||
f"mode {best.mode}[/grey62]")
|
||||
mode = _ask(console, " mode [grey62](auto/nfm/wfm/am/usb/lsb/cw/raw)"
|
||||
"[/grey62]", "auto").lower()
|
||||
r.mode = mode if mode in ("auto", "nfm", "wfm", "am", "usb", "lsb",
|
||||
"cw", "raw") else "auto"
|
||||
cfg.ranges.append(r)
|
||||
added += 1
|
||||
console.print(f" [green]added[/green] {r.describe()}\n")
|
||||
if added:
|
||||
console.print(f"[green]{added} range(s) added.[/green]")
|
||||
|
||||
|
||||
def choose_presets(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "US band plan")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("category")
|
||||
t.add_column("presets", justify="right", style="grey62")
|
||||
for i, cat in enumerate(CATEGORIES, 1):
|
||||
t.add_row(str(i), cat, str(len(in_category(cat))))
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a category number, a search term, or "
|
||||
"blank to go back.[/grey62]")
|
||||
answer = _ask(console, " category or search").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(CATEGORIES):
|
||||
presets = in_category(CATEGORIES[int(answer) - 1])
|
||||
heading = CATEGORIES[int(answer) - 1]
|
||||
else:
|
||||
presets = search(answer)
|
||||
heading = f"search: {answer!r}"
|
||||
if not presets:
|
||||
console.print(f" [yellow]nothing matched {answer!r}[/yellow]")
|
||||
continue
|
||||
_pick_from(console, cfg, presets, heading)
|
||||
|
||||
|
||||
def _pick_from(console: Console, cfg: ScanConfig,
|
||||
presets: list[BandPreset], heading: str) -> None:
|
||||
_rule(console, heading)
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("name")
|
||||
t.add_column("range", justify="right")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("notes", style="grey62", overflow="fold", max_width=42)
|
||||
for i, p in enumerate(presets, 1):
|
||||
extent = (f"{len(p.expand())} ranges" if p.is_group
|
||||
else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
|
||||
t.add_row(str(i), p.name, extent, p.mode, p.note)
|
||||
console.print(t)
|
||||
console.print("[grey62]Numbers to add ([cyan]1,3,5[/cyan] or "
|
||||
"[cyan]1-4[/cyan]), [cyan]all[/cyan], or blank to go "
|
||||
"back.[/grey62]")
|
||||
answer = _ask(console, " add").strip().lower()
|
||||
if not answer:
|
||||
return
|
||||
chosen = list(presets) if answer == "all" else _expand(answer, presets)
|
||||
existing = {r.preset_key for r in cfg.ranges if r.preset_key}
|
||||
added = 0
|
||||
hf = False
|
||||
for picked in chosen:
|
||||
# A preset may stand for a set of others; add what it actually scans.
|
||||
for p in picked.expand():
|
||||
if p.key in existing:
|
||||
continue
|
||||
cfg.ranges.append(ScanRange.from_preset(p))
|
||||
existing.add(p.key)
|
||||
added += 1
|
||||
hf |= p.needs_direct_sampling
|
||||
if hf:
|
||||
console.print(" [yellow]some of these are below 24 MHz — they need "
|
||||
"direct sampling and an HF antenna.[/yellow]")
|
||||
console.print(f" [green]added {added} range(s)[/green]")
|
||||
|
||||
|
||||
def _expand(answer: str, items: list) -> list:
|
||||
out = []
|
||||
for tok in answer.replace(" ", "").split(","):
|
||||
if not tok:
|
||||
continue
|
||||
if "-" in tok:
|
||||
a, _, b = tok.partition("-")
|
||||
if a.isdigit() and b.isdigit():
|
||||
out += [items[i - 1] for i in range(int(a), int(b) + 1)
|
||||
if 1 <= i <= len(items)]
|
||||
elif tok.isdigit() and 1 <= int(tok) <= len(items):
|
||||
out.append(items[int(tok) - 1])
|
||||
return out
|
||||
|
||||
|
||||
def edit_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "ranges")
|
||||
show_ranges(console, cfg)
|
||||
console.print(
|
||||
"\n [cyan]a[/cyan] add by hand "
|
||||
"[cyan]p[/cyan] add from the band plan\n"
|
||||
" [cyan]r[/cyan] remove "
|
||||
"[cyan]t[/cyan] toggle on/off\n"
|
||||
" [cyan]m[/cyan] change mode "
|
||||
"[cyan]c[/cyan] clear all\n"
|
||||
" [cyan]b[/cyan] back\n")
|
||||
choice = _ask(console, " choice", "b").strip().lower()
|
||||
if choice in _BACK:
|
||||
return
|
||||
if choice == "a":
|
||||
add_manual_ranges(console, cfg)
|
||||
elif choice == "p":
|
||||
choose_presets(console, cfg)
|
||||
elif choice == "c":
|
||||
if cfg.ranges and _confirm(" remove every range"):
|
||||
cfg.ranges = []
|
||||
elif choice in ("r", "t", "m") and cfg.ranges:
|
||||
which = _ask(console, " which numbers").strip()
|
||||
picked = _expand(which, list(range(1, len(cfg.ranges) + 1)))
|
||||
if not picked:
|
||||
console.print(" [yellow]nothing selected[/yellow]")
|
||||
continue
|
||||
if choice == "r":
|
||||
cfg.ranges = [r for i, r in enumerate(cfg.ranges, 1)
|
||||
if i not in picked]
|
||||
elif choice == "t":
|
||||
for i in picked:
|
||||
cfg.ranges[i - 1].enabled = not cfg.ranges[i - 1].enabled
|
||||
else:
|
||||
mode = _ask(console, " mode", "auto").strip().lower()
|
||||
for i in picked:
|
||||
cfg.ranges[i - 1].mode = mode or "auto"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _setting_row(setting: st.Setting, cfg: ScanConfig, default: ScanConfig):
|
||||
value = getattr(cfg, setting.key)
|
||||
shown = st.format_value(setting, value)
|
||||
changed = value != getattr(default, setting.key)
|
||||
return Text(shown, style="bold cyan" if changed else "white"), changed
|
||||
|
||||
|
||||
def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Setting]:
|
||||
items = st.in_group(group)
|
||||
default = ScanConfig()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("setting", width=22)
|
||||
t.add_column("value", width=18)
|
||||
t.add_column("what it does", style="grey62", overflow="fold")
|
||||
for i, s in enumerate(items, 1):
|
||||
value, changed = _setting_row(s, cfg, default)
|
||||
t.add_row(str(i), s.label + (" *" if changed else ""), value, s.help)
|
||||
console.print(t)
|
||||
console.print("[grey62]* differs from the built-in default[/grey62]")
|
||||
return items
|
||||
|
||||
|
||||
def setting_help(console: Console, setting: st.Setting,
|
||||
cfg: ScanConfig) -> None:
|
||||
default = ScanConfig()
|
||||
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
|
||||
"", setting.help.capitalize() + "."]
|
||||
if setting.detail:
|
||||
body += ["", setting.detail]
|
||||
body.append("")
|
||||
body.append(f"[grey62]now:[/grey62] "
|
||||
f"{st.format_value(setting, getattr(cfg, setting.key))}"
|
||||
f" [grey62]default:[/grey62] "
|
||||
f"{st.format_value(setting, getattr(default, setting.key))}")
|
||||
rng = setting.describe_range()
|
||||
if rng:
|
||||
body.append(f"[grey62]accepts:[/grey62] {rng}")
|
||||
if setting.flags:
|
||||
flags = " ".join(setting.flags)
|
||||
if setting.off_flags:
|
||||
flags += " / " + " ".join(setting.off_flags)
|
||||
body.append(f"[grey62]command line:[/grey62] {flags}")
|
||||
console.print(Panel(Text.from_markup("\n".join(body)),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
|
||||
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
|
||||
"""Prompt for one value. Returns True if it changed."""
|
||||
current = getattr(cfg, setting.key)
|
||||
setting_help(console, setting, cfg)
|
||||
hint = "yes/no" if setting.kind == "bool" else (
|
||||
"/".join(setting.choices) if setting.choices else
|
||||
(setting.example or setting.metavar or "value"))
|
||||
while True:
|
||||
raw = _ask(console, f" [bold]{setting.label}[/bold] [grey62]({hint})"
|
||||
f"[/grey62]", st.format_value(setting, current)
|
||||
if setting.kind not in ("freq_list", "accept_list")
|
||||
else "")
|
||||
if raw.strip() == "" or raw == st.format_value(setting, current):
|
||||
return False
|
||||
if raw.strip().lower() in ("d", "default"):
|
||||
value = getattr(ScanConfig(), setting.key)
|
||||
else:
|
||||
try:
|
||||
value = st.parse_value(setting, raw)
|
||||
except st.SettingError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
setattr(cfg, setting.key, value)
|
||||
errs = [e for e in cfg.validate() if "frequency ranges" not in e]
|
||||
if errs:
|
||||
console.print(f" [red]{errs[0]}[/red]")
|
||||
setattr(cfg, setting.key, current)
|
||||
continue
|
||||
console.print(f" [green]{setting.label} = "
|
||||
f"{st.format_value(setting, value)}[/green]")
|
||||
return True
|
||||
|
||||
|
||||
def settings_menu(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "settings")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("group")
|
||||
t.add_column("settings", justify="right", style="grey62")
|
||||
for i, g in enumerate(st.GROUPS, 1):
|
||||
t.add_row(str(i), g, str(len(st.in_group(g))))
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a group number, a search term "
|
||||
"(e.g. [cyan]hang[/cyan]), or blank to go back."
|
||||
"[/grey62]")
|
||||
answer = _ask(console, " group or search").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(st.GROUPS):
|
||||
_group_menu(console, cfg, st.GROUPS[int(answer) - 1])
|
||||
else:
|
||||
hits = st.search(answer)
|
||||
if not hits:
|
||||
console.print(f" [yellow]no setting matches "
|
||||
f"{answer!r}[/yellow]")
|
||||
continue
|
||||
if len(hits) == 1:
|
||||
edit_setting(console, hits[0], cfg)
|
||||
else:
|
||||
_list_menu(console, cfg, hits, f"matching {answer!r}")
|
||||
|
||||
|
||||
def _list_menu(console: Console, cfg: ScanConfig, items: list[st.Setting],
|
||||
heading: str) -> None:
|
||||
while True:
|
||||
_rule(console, heading)
|
||||
default = ScanConfig()
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("setting", width=22)
|
||||
t.add_column("value", width=18)
|
||||
t.add_column("group", style="grey62")
|
||||
for i, s in enumerate(items, 1):
|
||||
value, _ = _setting_row(s, cfg, default)
|
||||
t.add_row(str(i), s.label, value, s.group)
|
||||
console.print(t)
|
||||
answer = _ask(console, " number to edit, or blank to go back").strip()
|
||||
if not answer or not answer.isdigit():
|
||||
return
|
||||
idx = int(answer)
|
||||
if 1 <= idx <= len(items):
|
||||
edit_setting(console, items[idx - 1], cfg)
|
||||
|
||||
|
||||
def _group_menu(console: Console, cfg: ScanConfig, group: str) -> None:
|
||||
while True:
|
||||
_rule(console, group.lower())
|
||||
items = _settings_table(console, group, cfg)
|
||||
console.print("[grey62]Number to change it, [cyan]?N[/cyan] for help "
|
||||
"on one, [cyan]d[/cyan] to reset the group, blank to go "
|
||||
"back.[/grey62]")
|
||||
answer = _ask(console, " choice").strip().lower()
|
||||
if answer in _BACK:
|
||||
return
|
||||
if answer == "d":
|
||||
if _confirm(f" reset every setting in {group}"):
|
||||
default = ScanConfig()
|
||||
for s in items:
|
||||
setattr(cfg, s.key, getattr(default, s.key))
|
||||
console.print(" [green]reset[/green]")
|
||||
continue
|
||||
want_help = answer.startswith("?")
|
||||
token = answer.lstrip("?").strip()
|
||||
if not token.isdigit():
|
||||
console.print(" [yellow]enter a number from the list[/yellow]")
|
||||
continue
|
||||
idx = int(token)
|
||||
if not (1 <= idx <= len(items)):
|
||||
console.print(" [yellow]no such number[/yellow]")
|
||||
continue
|
||||
if want_help:
|
||||
setting_help(console, items[idx - 1], cfg)
|
||||
else:
|
||||
edit_setting(console, items[idx - 1], cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiles and saved settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def profiles_menu(console: Console, cfg: ScanConfig) -> ScanConfig:
|
||||
while True:
|
||||
_rule(console, "saved settings")
|
||||
console.print(f"[grey62]Settings live in {DEFAULT_CONFIG_DIR}[/grey62]")
|
||||
exists = DEFAULT_CONFIG_PATH.exists()
|
||||
console.print(" default settings file: "
|
||||
+ (f"[green]{DEFAULT_CONFIG_PATH}[/green]" if exists
|
||||
else "[yellow]not saved yet[/yellow]"))
|
||||
profiles = list_profiles()
|
||||
if profiles:
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("profile")
|
||||
t.add_column("ranges", justify="right")
|
||||
t.add_column("record", justify="right")
|
||||
t.add_column("hang", justify="right")
|
||||
for i, p in enumerate(profiles, 1):
|
||||
try:
|
||||
other = load_config(str(p))
|
||||
t.add_row(str(i), p.stem, str(len(other.ranges)),
|
||||
f"{other.record_seconds:g}s",
|
||||
f"{other.hang_seconds:g}s")
|
||||
except Exception:
|
||||
t.add_row(str(i), p.stem, "[red]unreadable[/red]", "", "")
|
||||
console.print(t)
|
||||
else:
|
||||
console.print(" [grey62]no named profiles yet[/grey62]")
|
||||
|
||||
console.print(
|
||||
"\n [cyan]s[/cyan] save as the default settings\n"
|
||||
" [cyan]n[/cyan] save as a named profile\n"
|
||||
" [cyan]l[/cyan] load a profile\n"
|
||||
" [cyan]d[/cyan] delete a profile\n"
|
||||
" [cyan]b[/cyan] back\n")
|
||||
choice = _ask(console, " choice", "b").strip().lower()
|
||||
if choice in _BACK:
|
||||
return cfg
|
||||
try:
|
||||
if choice == "s":
|
||||
path = save_default(cfg)
|
||||
console.print(f" [green]saved — every run will start from "
|
||||
f"{path}[/green]")
|
||||
elif choice == "n":
|
||||
name = _ask(console, " profile name", "myscan").strip()
|
||||
if name:
|
||||
console.print(f" [green]saved to "
|
||||
f"{save_config(cfg, name)}[/green]")
|
||||
elif choice == "l" and profiles:
|
||||
name = _ask(console, " profile name or number").strip()
|
||||
if name.isdigit() and 1 <= int(name) <= len(profiles):
|
||||
name = profiles[int(name) - 1].stem
|
||||
cfg = load_config(name)
|
||||
console.print(f" [green]loaded {name}[/green]")
|
||||
elif choice == "d" and profiles:
|
||||
name = _ask(console, " profile name or number").strip()
|
||||
if name.isdigit() and 1 <= int(name) <= len(profiles):
|
||||
name = profiles[int(name) - 1].stem
|
||||
if name and _confirm(f" delete {name}"):
|
||||
console.print(" [green]deleted[/green]"
|
||||
if delete_profile(name)
|
||||
else " [yellow]no such profile[/yellow]")
|
||||
except (OSError, FileNotFoundError, ValueError) as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TOPICS: dict[str, tuple[str, str]] = {
|
||||
"1": ("Getting started", """
|
||||
Pick what to scan, then start. Ranges come from two places: type start and
|
||||
end frequencies by hand, or choose from the built-in US band plan, which
|
||||
carries each band's usual channel spacing and demodulator so you do not have
|
||||
to set them.
|
||||
|
||||
Everything on this menu can also be given on the command line, and every
|
||||
command-line option can be set here. Settings you save become the starting
|
||||
point for every later run."""),
|
||||
"2": ("How the scan works", """
|
||||
The scanner sweeps each range in steps, listening at each tuner position for
|
||||
the dwell time. Anything standing far enough above the noise counts as a
|
||||
detection.
|
||||
|
||||
It then drops onto that frequency, looks at the signal briefly to choose the
|
||||
right demodulator, and records until either the record limit is reached or the
|
||||
channel goes quiet for the hang time. Gaps shorter than the hang time are
|
||||
recorded straight through, so a two-way exchange stays in one file."""),
|
||||
"3": ("Why nothing is being recorded", """
|
||||
Most often the squelch threshold is too high, or the content check is
|
||||
rejecting what it hears.
|
||||
|
||||
Try 'Squelch threshold' lower (8 dB is sensitive, 15 dB is conservative), and
|
||||
check the scan summary: it reports how many detections were discarded and
|
||||
why. To see everything the squelch opens on, set 'Check for content' to no —
|
||||
but expect static and interference to be recorded too."""),
|
||||
"4": ("Why static is being recorded", """
|
||||
Turn 'Check for content' back on. With it on, a capture is kept only if it
|
||||
carries speech, decodable Morse, or an identified digital keying scheme;
|
||||
static, hum, bare carriers and interference are deleted.
|
||||
|
||||
If real signals are being rejected, lower 'Minimum speech score'. Speech
|
||||
detection needs roughly a second of audio, so very short overs may be
|
||||
missed."""),
|
||||
"5": ("Recording conversations", """
|
||||
Set 'Record for' to 0 so a long exchange is not cut off, and 'Wait for quiet'
|
||||
longer than the pause between overs — five or six seconds suits most two-way
|
||||
traffic. 'Absolute limit' still stops a capture running away.
|
||||
|
||||
Silence, static and interference all count as quiet, so a burst of noise
|
||||
during a pause will not park the receiver on a finished conversation."""),
|
||||
"6": ("Files and where they go", """
|
||||
Recordings are written to the output directory, all in one flat folder, named
|
||||
yyyy-mm-dd_hh.mm.ss_frequency_modulation.wav. Beside each one is a .json with
|
||||
the identification and measurements, and with 'Save raw IQ' on, the raw
|
||||
samples plus a SigMF sidecar.
|
||||
|
||||
scan_log.csv lists every hit and opens in a spreadsheet."""),
|
||||
"7": ("HF and direct sampling", """
|
||||
Below about 24 MHz the tuner cannot reach, so the signal is fed straight into
|
||||
the digitiser. 'Direct sampling' set to auto switches this on and off as
|
||||
needed; most dongles use the Q branch.
|
||||
|
||||
There is no filtering or gain in front of the digitiser in this mode, so an HF
|
||||
antenna and a quiet location matter more than usual."""),
|
||||
"8": ("Keys during a scan", """
|
||||
q stop the scan
|
||||
p pause and resume
|
||||
s skip the signal being recorded and carry on sweeping
|
||||
l lock out this frequency for the rest of the run
|
||||
+/- raise or lower the squelch threshold on the fly"""),
|
||||
}
|
||||
|
||||
|
||||
def help_screen(console: Console) -> None:
|
||||
while True:
|
||||
_rule(console, "help")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("topic")
|
||||
for k, (title, _) in _TOPICS.items():
|
||||
t.add_row(k, title)
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a topic number, a setting name to look "
|
||||
"up, or blank to go back.[/grey62]")
|
||||
answer = _ask(console, " topic").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer in _TOPICS:
|
||||
title, body = _TOPICS[answer]
|
||||
console.print(Panel(Text(body.strip()), title=title,
|
||||
border_style="blue", padding=(0, 1)))
|
||||
else:
|
||||
hits = st.search(answer)
|
||||
if not hits:
|
||||
console.print(f" [yellow]nothing matches {answer!r}[/yellow]")
|
||||
for s in hits[:4]:
|
||||
setting_help(console, s, ScanConfig())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def first_run_setup(console: Console, cfg: ScanConfig) -> bool:
|
||||
"""Ask where recordings should go, the first time the program is run.
|
||||
|
||||
Returns True if settings were saved. Only the one question: everything
|
||||
else has a working default and can be changed from the settings menu.
|
||||
"""
|
||||
console.print(Panel(Text.from_markup(
|
||||
"[bold]Welcome to bandsaunter[/bold]\n\n"
|
||||
"Recordings, transcripts and the scan log are all written to one "
|
||||
"directory. Where would you like them?\n\n"
|
||||
"[grey62]This is saved, so you are only asked once. Everything else "
|
||||
"can be changed later from Settings, or with "
|
||||
"[cyan]bandsaunter config[/cyan].[/grey62]"),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
while True:
|
||||
answer = _ask(console, " recordings directory",
|
||||
cfg.output_dir or DEFAULT_OUTPUT_DIR).strip()
|
||||
if not answer:
|
||||
answer = DEFAULT_OUTPUT_DIR
|
||||
path = Path(answer).expanduser()
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".bandsaunter-write-test"
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
console.print(f" [red]cannot use that directory: {exc}[/red]")
|
||||
continue
|
||||
cfg.output_dir = answer
|
||||
break
|
||||
|
||||
try:
|
||||
saved = save_default(cfg)
|
||||
console.print(f" [green]recordings will go to {path}[/green]")
|
||||
console.print(f" [grey62]settings saved to {saved}[/grey62]\n")
|
||||
return True
|
||||
except OSError as exc:
|
||||
console.print(f" [yellow]could not save settings: {exc}[/yellow]")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _summary(cfg: ScanConfig) -> str:
|
||||
record = ("no limit" if not cfg.record_seconds
|
||||
else f"{cfg.record_seconds:g}s")
|
||||
gate = ", ".join(cfg.accept) if cfg.require_signal else "everything"
|
||||
return (f"record {record}, hang {cfg.hang_seconds:g}s, "
|
||||
f"squelch +{cfg.threshold_db:g} dB, keep {gate}")
|
||||
|
||||
|
||||
def run_tui(console: Console, cfg: ScanConfig | None = None,
|
||||
source: Path | None = None) -> ScanConfig | None:
|
||||
"""The interactive front end. Returns a config to scan with, or None."""
|
||||
cfg = cfg or ScanConfig()
|
||||
console.print(Panel(Text.from_markup(
|
||||
"[bold]bandsaunter[/bold] — RTL-SDR signal scanner\n"
|
||||
"[grey62]Scan any set of frequencies, record what turns up, and "
|
||||
"identify it. Everything is configurable here; press "
|
||||
"[cyan]h[/cyan] for help at any point.[/grey62]"),
|
||||
border_style="blue"))
|
||||
if source:
|
||||
console.print(f"[grey62]settings loaded from {source}[/grey62]")
|
||||
|
||||
try:
|
||||
return _main_loop(console, cfg)
|
||||
except TUIAbort:
|
||||
console.print()
|
||||
return None
|
||||
|
||||
|
||||
def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
||||
while True:
|
||||
_rule(console, "main menu")
|
||||
show_ranges(console, cfg)
|
||||
console.print(
|
||||
f"\n [cyan]1[/cyan] Frequency ranges "
|
||||
f"[grey62]{len(cfg.ranges)} configured[/grey62]\n"
|
||||
f" [cyan]2[/cyan] Band plan "
|
||||
f"[grey62]{len(PRESETS)} US presets[/grey62]\n"
|
||||
f" [cyan]3[/cyan] Settings "
|
||||
f"[grey62]{_summary(cfg)}[/grey62]\n"
|
||||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||
f" [cyan]h[/cyan] Help\n"
|
||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||
f" [cyan]q[/cyan] Quit\n")
|
||||
choice = _ask(console, " choice", "s").strip().lower()
|
||||
|
||||
if choice == "1":
|
||||
edit_ranges(console, cfg)
|
||||
elif choice == "2":
|
||||
choose_presets(console, cfg)
|
||||
elif choice == "3":
|
||||
settings_menu(console, cfg)
|
||||
elif choice == "4":
|
||||
cfg = profiles_menu(console, cfg)
|
||||
elif choice in ("h", "?", "help"):
|
||||
help_screen(console)
|
||||
elif choice in ("s", "start", "go"):
|
||||
errs = cfg.validate()
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f" [red]{e}[/red]")
|
||||
continue
|
||||
return cfg
|
||||
elif choice in ("q", "quit", "exit"):
|
||||
return None
|
||||
373
bandsaunter/ui.py
Executable file
373
bandsaunter/ui.py
Executable file
|
|
@ -0,0 +1,373 @@
|
|||
"""Live terminal display for a running scan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import select
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .bandplan import fmt_hz
|
||||
from .recorder import HitRecord
|
||||
from .scanner import Detection, Scanner
|
||||
|
||||
__all__ = ["ScanDisplay", "KeyReader", "print_hit", "print_band_table"]
|
||||
|
||||
_SPARK = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
def _sparkline(values: np.ndarray, width: int = 60,
|
||||
lo: float | None = None, hi: float | None = None) -> str:
|
||||
"""Compress a spectrum into one row of block characters."""
|
||||
v = np.asarray(values, dtype=np.float64)
|
||||
if v.size == 0:
|
||||
return " " * width
|
||||
if v.size > width:
|
||||
# Max-reduce rather than average: a narrow carrier must stay visible.
|
||||
edges = np.linspace(0, v.size, width + 1).astype(int)
|
||||
v = np.array([v[edges[i]:edges[i + 1]].max() if edges[i + 1] > edges[i]
|
||||
else v[min(edges[i], v.size - 1)] for i in range(width)])
|
||||
lo = float(np.percentile(v, 5)) if lo is None else lo
|
||||
hi = float(np.percentile(v, 99.5)) if hi is None else hi
|
||||
if hi <= lo:
|
||||
hi = lo + 1.0
|
||||
idx = np.clip((v - lo) / (hi - lo) * (len(_SPARK) - 1), 0,
|
||||
len(_SPARK) - 1).astype(int)
|
||||
return "".join(_SPARK[i] for i in idx)
|
||||
|
||||
|
||||
class KeyReader:
|
||||
"""Non-blocking single-key input, restoring the terminal on exit."""
|
||||
|
||||
def __init__(self, enabled: bool = True):
|
||||
self.enabled = enabled and sys.stdin.isatty()
|
||||
self._old = None
|
||||
|
||||
def __enter__(self):
|
||||
if self.enabled:
|
||||
try:
|
||||
self._old = termios.tcgetattr(sys.stdin)
|
||||
tty.setcbreak(sys.stdin.fileno())
|
||||
except (termios.error, ValueError):
|
||||
self.enabled = False
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
if self._old is not None:
|
||||
try:
|
||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old)
|
||||
except (termios.error, ValueError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def get(self) -> str | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
try:
|
||||
r, _, _ = select.select([sys.stdin], [], [], 0)
|
||||
if r:
|
||||
return sys.stdin.read(1)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecState:
|
||||
frequency: float = 0.0
|
||||
mode: str = ""
|
||||
elapsed: float = 0.0
|
||||
snr: float = 0.0
|
||||
present: bool = False
|
||||
quiet_for: float = 0.0
|
||||
active: bool = False
|
||||
|
||||
|
||||
class ScanDisplay:
|
||||
"""Renders scanner callbacks into a live dashboard.
|
||||
|
||||
Attach with ``display.attach(scanner)``; the scanner then drives it.
|
||||
"""
|
||||
|
||||
def __init__(self, scanner: Scanner, console: Console | None = None,
|
||||
max_hits: int = 12, show_spectrum: bool = True):
|
||||
self.scanner = scanner
|
||||
self.console = console or Console()
|
||||
self.hits: deque[HitRecord] = deque(maxlen=max_hits)
|
||||
self.messages: deque[str] = deque(maxlen=4)
|
||||
self.show_spectrum = show_spectrum
|
||||
|
||||
self._spark = ""
|
||||
self._span = ""
|
||||
self._peak = 0.0
|
||||
self._step_i = 0
|
||||
self._n_steps = 1
|
||||
self._rec = _RecState()
|
||||
self._last_detection: Detection | None = None
|
||||
self._dirty = True
|
||||
|
||||
# -- callbacks --------------------------------------------------------
|
||||
def attach(self, scanner: Scanner | None = None) -> None:
|
||||
s = scanner or self.scanner
|
||||
cb = s.cb
|
||||
cb.on_step = self.on_step
|
||||
cb.on_detection = self.on_detection
|
||||
cb.on_record_start = self.on_record_start
|
||||
cb.on_record_tick = self.on_record_tick
|
||||
cb.on_record_end = self.on_record_end
|
||||
cb.on_status = self.on_status
|
||||
cb.on_error = self.on_error
|
||||
|
||||
def on_step(self, i, n, step, psd_db, freqs):
|
||||
self._step_i, self._n_steps = i, n
|
||||
if self.show_spectrum:
|
||||
inband = (freqs >= step.low) & (freqs <= step.high)
|
||||
if np.any(inband):
|
||||
self._spark = _sparkline(psd_db[inband], width=self._spark_width())
|
||||
self._peak = float(psd_db[inband].max())
|
||||
self._span = f"{fmt_hz(step.low)} - {fmt_hz(step.high)}"
|
||||
self._dirty = True
|
||||
|
||||
def on_detection(self, det: Detection):
|
||||
self._last_detection = det
|
||||
self._dirty = True
|
||||
|
||||
def on_record_start(self, det, rec):
|
||||
self._rec = _RecState(frequency=rec.frequency, mode=rec.mode,
|
||||
active=True)
|
||||
self._dirty = True
|
||||
|
||||
def on_record_tick(self, rec, elapsed, present, snr, quiet_for=0.0):
|
||||
self._rec.elapsed = elapsed
|
||||
self._rec.present = present
|
||||
self._rec.snr = snr
|
||||
self._rec.quiet_for = quiet_for
|
||||
self._rec.mode = rec.mode
|
||||
self._dirty = True
|
||||
|
||||
def on_record_end(self, hit: HitRecord):
|
||||
self._rec.active = False
|
||||
if hit.kept:
|
||||
self.hits.appendleft(hit)
|
||||
self._dirty = True
|
||||
|
||||
def on_status(self, msg: str):
|
||||
self.messages.appendleft(msg)
|
||||
self._dirty = True
|
||||
|
||||
def on_error(self, exc: Exception):
|
||||
self.messages.appendleft(f"[red]{type(exc).__name__}: {exc}[/red]")
|
||||
self._dirty = True
|
||||
|
||||
# -- rendering ---------------------------------------------------------
|
||||
def _spark_width(self) -> int:
|
||||
return max(20, min(120, self.console.width - 24))
|
||||
|
||||
def _header(self) -> Panel:
|
||||
d = self.scanner.device
|
||||
st = d.status() if d else {}
|
||||
gain = st.get("gain", "?")
|
||||
gain = f"{gain:.1f} dB" if isinstance(gain, (int, float)) else str(gain)
|
||||
ds = st.get("direct_sampling", 0)
|
||||
bits = [
|
||||
f"[bold]{st.get('tuner', '?')}[/bold]",
|
||||
f"{st.get('sample_rate', 0)/1e6:.3f} MS/s",
|
||||
f"gain {gain}",
|
||||
f"{st.get('ppm', 0):+d} ppm",
|
||||
]
|
||||
if ds:
|
||||
bits.append("[yellow]direct sampling[/yellow]")
|
||||
if st.get("simulated"):
|
||||
bits.append("[magenta]SIMULATED[/magenta]")
|
||||
return Panel(Text.from_markup(" ".join(bits)),
|
||||
title="receiver", border_style="blue", padding=(0, 1))
|
||||
|
||||
def _sweep_panel(self, show_spectrum: bool = True) -> Panel:
|
||||
s = self.scanner.stats
|
||||
frac = (self._step_i + 1) / max(1, self._n_steps)
|
||||
bar_w = max(10, min(40, self.console.width - 60))
|
||||
filled = int(frac * bar_w)
|
||||
bar = "[green]" + "━" * filled + "[/green]" + \
|
||||
"[grey37]" + "━" * (bar_w - filled) + "[/grey37]"
|
||||
lines = [
|
||||
Text.from_markup(
|
||||
f"{bar} step {self._step_i + 1}/{self._n_steps} "
|
||||
f"[bold]{self._span}[/bold]"),
|
||||
]
|
||||
if show_spectrum and self.show_spectrum and self._spark:
|
||||
lines.append(Text.from_markup(
|
||||
f"[cyan]{self._spark}[/cyan] peak {self._peak:6.1f} dBFS"))
|
||||
state = s.state
|
||||
colour = {"recording": "red", "sweeping": "green",
|
||||
"paused": "yellow"}.get(state, "white")
|
||||
lines.append(Text.from_markup(
|
||||
f"[{colour}]{state}[/{colour}] cycle {s.cycles + 1} "
|
||||
f"hits {s.recordings} dropped {s.discarded} "
|
||||
f"detections {s.detections} up {_dur(s.elapsed)}"))
|
||||
return Panel(Group(*lines), title="sweep", border_style="blue",
|
||||
padding=(0, 1))
|
||||
|
||||
def _record_panel(self) -> Panel | None:
|
||||
r = self._rec
|
||||
if not r.active:
|
||||
return None
|
||||
cfg = self.scanner.cfg
|
||||
limit = cfg.record_seconds
|
||||
bar_w = max(10, min(30, self.console.width - 70))
|
||||
frac = min(1.0, r.elapsed / limit) if limit else 0.0
|
||||
filled = int(frac * bar_w)
|
||||
bar = ("[red]" + "█" * filled + "[/red]" +
|
||||
"[grey37]" + "░" * (bar_w - filled) + "[/grey37]") if limit \
|
||||
else "[red]recording[/red]"
|
||||
hang = cfg.hang_seconds
|
||||
if r.present:
|
||||
sq = "[green]SIGNAL[/green]"
|
||||
else:
|
||||
# Show the gap counting down, so it is obvious the recording is
|
||||
# being held open across a pause rather than stuck.
|
||||
sq = f"[yellow]gap {min(r.quiet_for, hang):4.1f}/{hang:g}s[/yellow]"
|
||||
limit_s = f"/{limit:g}s" if limit else ""
|
||||
return Panel(
|
||||
Text.from_markup(
|
||||
f"[bold red]REC[/bold red] {fmt_hz(r.frequency)} "
|
||||
f"[{r.mode}] {bar} {r.elapsed:5.1f}{limit_s} "
|
||||
f"{sq} SNR {r.snr:5.1f} dB"),
|
||||
border_style="red", padding=(0, 1))
|
||||
|
||||
def _layout(self) -> tuple[bool, int, bool]:
|
||||
"""Decide what fits: ``(spectrum row, hit rows, footer)``.
|
||||
|
||||
On a short terminal the optional parts are given up in order --
|
||||
spectrum, then the hit list, then the key hints -- so the receiver and
|
||||
sweep panels always fit. A frame taller than the terminal cannot be
|
||||
redrawn in place, and every refresh would leave another copy of it
|
||||
behind, which is why the header ends up on screen several times over.
|
||||
"""
|
||||
height = self.console.size.height or 24
|
||||
budget = max(6, height - 1)
|
||||
rec = 3 if self._rec.active else 0
|
||||
spectrum = bool(self.show_spectrum and self._spark)
|
||||
|
||||
for want_spectrum in ((True, False) if spectrum else (False,)):
|
||||
for want_footer in (True, False):
|
||||
base = 3 + (4 if want_spectrum else 3) + rec + \
|
||||
(3 if want_footer else 0)
|
||||
rows = budget - base - 4 # 4 = hits panel chrome
|
||||
if rows >= 1:
|
||||
return want_spectrum, rows, want_footer
|
||||
if budget - base >= 0:
|
||||
return want_spectrum, 0, want_footer
|
||||
return False, 0, False
|
||||
|
||||
def _hit_capacity(self) -> int:
|
||||
"""How many hit rows fit without pushing the display off the screen.
|
||||
|
||||
A display taller than the terminal cannot be redrawn in place, so each
|
||||
refresh scrolls another copy of it into the scrollback and the header
|
||||
appears over and over. The number of hits grows as the scan runs,
|
||||
which is why it starts fine and degrades.
|
||||
"""
|
||||
return self._layout()[1]
|
||||
|
||||
def _hits_table(self) -> Panel:
|
||||
t = Table(box=None, expand=True, pad_edge=False, show_edge=False)
|
||||
t.add_column("time", style="grey62", width=8)
|
||||
t.add_column("frequency", style="bold cyan", width=15, justify="right")
|
||||
t.add_column("dur", width=6, justify="right")
|
||||
t.add_column("SNR", width=7, justify="right")
|
||||
t.add_column("identified as", ratio=1, overflow="ellipsis")
|
||||
capacity = self._hit_capacity()
|
||||
shown = list(self.hits)[:capacity]
|
||||
for h in shown:
|
||||
extra = ""
|
||||
if h.morse_text:
|
||||
extra = f' [yellow]"{h.morse_text.strip()[:32]}"[/yellow]'
|
||||
elif h.ctcss_hz:
|
||||
extra = f" [grey62]CTCSS {h.ctcss_hz:.1f}[/grey62]"
|
||||
elif h.baud:
|
||||
extra = f" [grey62]{h.baud:.0f} baud[/grey62]"
|
||||
conf = h.confidence
|
||||
colour = "green" if conf >= 0.7 else "yellow" if conf >= 0.45 else "grey62"
|
||||
t.add_row(
|
||||
time.strftime("%H:%M:%S", time.localtime(h.started_at)),
|
||||
fmt_hz(h.frequency),
|
||||
f"{h.duration:.1f}s",
|
||||
f"{h.snr_db:.1f}",
|
||||
Text.from_markup(
|
||||
f"[{colour}]{h.classification or 'unclassified'}[/{colour}]{extra}"),
|
||||
)
|
||||
if not self.hits:
|
||||
t.add_row("", "", "", "", Text("no signals recorded yet",
|
||||
style="grey42"))
|
||||
hidden = len(self.hits) - len(shown)
|
||||
title = "recorded signals"
|
||||
if hidden > 0:
|
||||
title += f" [grey62]({hidden} more above)[/grey62]"
|
||||
return Panel(t, title=title, border_style="blue", padding=(0, 1))
|
||||
|
||||
def _footer(self) -> Panel:
|
||||
keys = ("[bold]q[/bold] quit [bold]p[/bold] pause "
|
||||
"[bold]s[/bold] skip [bold]l[/bold] lock out "
|
||||
"[bold]+/-[/bold] threshold")
|
||||
msg = self.messages[0] if self.messages else ""
|
||||
return Panel(Text.from_markup(f"{keys} {msg}"),
|
||||
border_style="grey37", padding=(0, 1))
|
||||
|
||||
def render(self):
|
||||
spectrum, rows, footer = self._layout()
|
||||
parts = [self._header(), self._sweep_panel(show_spectrum=spectrum)]
|
||||
rec = self._record_panel()
|
||||
if rec is not None:
|
||||
parts.append(rec)
|
||||
if rows > 0:
|
||||
parts.append(self._hits_table())
|
||||
if footer:
|
||||
parts.append(self._footer())
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _dur(seconds: float) -> str:
|
||||
seconds = int(seconds)
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def print_hit(console: Console, hit: HitRecord) -> None:
|
||||
"""One-line-per-hit output for non-interactive runs."""
|
||||
if not hit.kept:
|
||||
return
|
||||
stamp = time.strftime("%H:%M:%S", time.localtime(hit.started_at))
|
||||
conf = f"{hit.confidence*100:.0f}%"
|
||||
line = (f"[grey62]{stamp}[/grey62] [bold cyan]{fmt_hz(hit.frequency):>14}[/bold cyan]"
|
||||
f" {hit.duration:5.1f}s SNR {hit.snr_db:5.1f} dB "
|
||||
f"[green]{hit.classification or 'unclassified'}[/green] ({conf})")
|
||||
console.print(line, highlight=False)
|
||||
if hit.morse_text:
|
||||
console.print(f'{"":>26}[yellow]Morse @ {hit.morse_wpm:.0f} WPM: '
|
||||
f'"{hit.morse_text.strip()}"[/yellow]', highlight=False)
|
||||
if hit.reasons:
|
||||
console.print(f'{"":>26}[grey54]{hit.reasons[0]}[/grey54]', highlight=False)
|
||||
|
||||
|
||||
def print_band_table(console: Console, presets, title: str = "band plan") -> None:
|
||||
t = Table(title=title, box=None, header_style="bold")
|
||||
t.add_column("key", style="cyan")
|
||||
t.add_column("name")
|
||||
t.add_column("range", justify="right")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("notes", style="grey62", overflow="fold")
|
||||
for p in presets:
|
||||
extent = (f"{len(p.expand())} ranges, {fmt_hz(p.start)}-{fmt_hz(p.stop)}"
|
||||
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
|
||||
t.add_row(p.key, p.name, extent, p.mode, p.note)
|
||||
console.print(t)
|
||||
41
pyproject.toml
Normal file
41
pyproject.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bandsaunter"
|
||||
version = "1.0.0"
|
||||
description = "Signal scanner, recorder and identifier for RTL-SDR receivers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
dependencies = [
|
||||
"numpy>=1.22",
|
||||
"scipy>=1.8",
|
||||
"rich>=12.0",
|
||||
"PyYAML>=5.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
plots = ["matplotlib>=3.5"]
|
||||
dev = ["pytest>=7.0"]
|
||||
# Speech recognition for --transcribe. Optional: transcription is off by
|
||||
# default, and the scan says so plainly when no recogniser is installed.
|
||||
transcribe = ["faster-whisper>=1.0"]
|
||||
transcribe-small = ["vosk>=0.3"]
|
||||
|
||||
# System packages, which pip cannot install. Recorded here so packaging does
|
||||
# not have to rediscover them:
|
||||
# Depends: librtlsdr0 -- the dongle is unreachable without it
|
||||
# Recommends: espeak-ng -- clearer spoken timestamps; a built-in
|
||||
# synthesiser is used when absent
|
||||
|
||||
[project.scripts]
|
||||
bandsaunter = "bandsaunter.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["bandsaunter*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
37
tests/morse_gen.py
Normal file
37
tests/morse_gen.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Generate correctly-timed Morse audio for testing (1/3/7 unit spacing)."""
|
||||
import numpy as np
|
||||
from bandsaunter.morse import encode_morse
|
||||
|
||||
|
||||
def morse_keying(msg: str, wpm: float, fs: float) -> np.ndarray:
|
||||
"""Key-down/key-up waveform with ITU timing: element 1, letter 3, word 7."""
|
||||
dot = 1.2 / wpm
|
||||
segs = [(0, 8)] # lead-in silence
|
||||
letters = encode_morse(msg).split(" ")
|
||||
for i, tok in enumerate(letters):
|
||||
if tok == "/":
|
||||
segs.append((0, 7)) # word gap
|
||||
continue
|
||||
if i > 0 and letters[i - 1] != "/":
|
||||
segs.append((0, 3)) # letter gap
|
||||
for j, el in enumerate(tok):
|
||||
if j > 0:
|
||||
segs.append((0, 1)) # element gap
|
||||
segs.append((1, 1 if el == "." else 3))
|
||||
segs.append((0, 8))
|
||||
return np.concatenate([np.full(max(1, int(u * dot * fs)), float(s))
|
||||
for s, u in segs])
|
||||
|
||||
|
||||
def morse_audio(msg: str, wpm: float, fs: float = 16000.0, snr_db: float = 20.0,
|
||||
tone: float = 700.0, seed: int = 0) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
env = morse_keying(msg, wpm, fs)
|
||||
# Soften the keying edges the way a real transmitter's shaping does.
|
||||
k = max(3, int(0.006 * fs))
|
||||
env = np.convolve(env, np.hanning(k), "same")
|
||||
env /= max(env.max(), 1e-9)
|
||||
t = np.arange(env.size) / fs
|
||||
x = env * np.sin(2 * np.pi * tone * t)
|
||||
n = np.sqrt(np.mean(x ** 2) / (10 ** (snr_db / 10.0)))
|
||||
return x + n * rng.standard_normal(x.size)
|
||||
76
tests/signals.py
Normal file
76
tests/signals.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Synthetic test signals shared by the test modules."""
|
||||
import numpy as np
|
||||
from scipy.signal import butter, hilbert, lfilter
|
||||
|
||||
FS = 32000.0
|
||||
|
||||
|
||||
def _noise(x, snr_db, rng):
|
||||
p = float(np.mean(np.abs(x) ** 2))
|
||||
n = np.sqrt(p / (2 * 10 ** (snr_db / 10.0)))
|
||||
return (x + n * (rng.standard_normal(x.size)
|
||||
+ 1j * rng.standard_normal(x.size))).astype(np.complex64)
|
||||
|
||||
|
||||
def voice(n, fs=FS, seed=0):
|
||||
"""Band-limited noise with a syllabic envelope -- a good speech stand-in."""
|
||||
rng = np.random.default_rng(seed)
|
||||
b, a = butter(4, [300 / (fs / 2), 2700 / (fs / 2)], btype="band")
|
||||
v = lfilter(b, a, rng.standard_normal(n))
|
||||
v /= max(np.abs(v).max(), 1e-9)
|
||||
t = np.arange(n) / fs
|
||||
return v * (0.4 + 0.6 * np.abs(np.sin(2 * np.pi * 1.7 * t)))
|
||||
|
||||
|
||||
def make(kind, n=64000, fs=FS, snr_db=30.0, seed=3):
|
||||
rng = np.random.default_rng(seed)
|
||||
t = np.arange(n) / fs
|
||||
v = voice(n, fs, seed)
|
||||
|
||||
if kind == "nfm":
|
||||
msg = v + 0.15 * np.sin(2 * np.pi * 100.0 * t)
|
||||
x = np.exp(1j * np.cumsum(2 * np.pi * 2500 * msg / fs))
|
||||
elif kind == "wfm":
|
||||
x = np.exp(1j * np.cumsum(2 * np.pi * 3000 * v / fs))
|
||||
elif kind == "am":
|
||||
x = ((1 + 0.6 * v) * np.exp(2j * np.pi * 30 * t))
|
||||
elif kind == "usb":
|
||||
x = 0.5 * hilbert(v)
|
||||
elif kind == "lsb":
|
||||
x = 0.5 * np.conj(hilbert(v))
|
||||
elif kind == "carrier":
|
||||
x = np.exp(2j * np.pi * 137 * t)
|
||||
elif kind == "cw":
|
||||
dot = 0.08
|
||||
pat = [1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0]
|
||||
key = np.zeros(n)
|
||||
i = 0
|
||||
while i < n:
|
||||
for b in pat:
|
||||
m = int(dot * fs)
|
||||
if i + m > n:
|
||||
break
|
||||
key[i:i + m] = b
|
||||
i += m
|
||||
env = np.convolve(key, np.hanning(int(0.005 * fs)), "same")
|
||||
env /= max(env.max(), 1e-9)
|
||||
x = env * np.exp(2j * np.pi * 300 * t)
|
||||
elif kind.startswith("fsk"):
|
||||
levels = int(kind[3])
|
||||
baud, dev = (1200.0, 2400.0) if levels == 2 else (4800.0, 1800.0)
|
||||
sp = int(fs / baud)
|
||||
sym = rng.integers(0, levels, n // sp + 1)
|
||||
lv = (sym - (levels - 1) / 2) / max(1, (levels - 1) / 2)
|
||||
f = np.resize(np.repeat(lv, sp) * dev, n)
|
||||
x = np.exp(1j * np.cumsum(2 * np.pi * f / fs))
|
||||
elif kind.startswith("psk"):
|
||||
m = int(kind[3])
|
||||
sp = int(fs / 4800.0)
|
||||
sym = rng.integers(0, m, n // sp + 1)
|
||||
x = np.exp(1j * np.resize(np.repeat(2 * np.pi * sym / m, sp), n))
|
||||
elif kind == "noise":
|
||||
return (0.01 * (rng.standard_normal(n)
|
||||
+ 1j * rng.standard_normal(n))).astype(np.complex64)
|
||||
else:
|
||||
raise ValueError(kind)
|
||||
return _noise(np.asarray(x, dtype=np.complex128), snr_db, rng)
|
||||
52
tests/speech.py
Normal file
52
tests/speech.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Synthetic speech for testing the voice detector.
|
||||
|
||||
A glottal pulse train with jitter, shaped by three formant resonators and
|
||||
gated into syllables with pauses between phrases -- the structure a speech
|
||||
detector is supposed to key on, without needing a recorded voice sample.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy import signal as sps
|
||||
|
||||
|
||||
def synth_speech(seconds=4.0, fs=16000.0, pitch=120.0, seed=0,
|
||||
syllable_rate=4.0, pause_fraction=0.25):
|
||||
rng = np.random.default_rng(seed)
|
||||
n = int(seconds * fs)
|
||||
t = np.arange(n) / fs
|
||||
|
||||
# Glottal excitation: pulse train with natural pitch drift and jitter.
|
||||
f0 = pitch * (1.0 + 0.06 * np.sin(2 * np.pi * 0.7 * t)
|
||||
+ 0.02 * rng.standard_normal(n).cumsum() / np.sqrt(n))
|
||||
phase = np.cumsum(2 * np.pi * f0 / fs)
|
||||
exc = np.zeros(n)
|
||||
pulses = np.flatnonzero(np.diff(np.floor(phase / (2 * np.pi))) > 0)
|
||||
exc[pulses] = 1.0
|
||||
exc -= exc.mean()
|
||||
|
||||
# Three formants, swept so the spectrum moves the way articulation does.
|
||||
out = np.zeros(n)
|
||||
for base, bw, amp in ((650.0, 90.0, 1.0), (1200.0, 110.0, 0.55),
|
||||
(2600.0, 160.0, 0.30)):
|
||||
sweep = base * (1.0 + 0.22 * np.sin(2 * np.pi * 1.9 * t
|
||||
+ rng.uniform(0, 6.283)))
|
||||
# Piecewise-constant formant, cheap but spectrally honest.
|
||||
seg = int(fs * 0.04)
|
||||
y = np.zeros(n)
|
||||
for s in range(0, n - seg, seg):
|
||||
fc = float(np.mean(sweep[s:s + seg]))
|
||||
b, a = sps.iirpeak(min(fc, fs / 2 * 0.95) / (fs / 2),
|
||||
max(2.0, fc / bw))
|
||||
y[s:s + seg] = sps.lfilter(b, a, exc[s:s + seg])
|
||||
out += amp * y
|
||||
|
||||
# Voiced/unvoiced: add fricative noise on some syllables.
|
||||
out += 0.05 * rng.standard_normal(n) * (np.abs(np.sin(2 * np.pi * 2.3 * t)) > 0.8)
|
||||
|
||||
# Syllabic gating plus phrase pauses.
|
||||
syl = 0.5 + 0.5 * np.sin(2 * np.pi * syllable_rate * t)
|
||||
phrase = (np.sin(2 * np.pi * 0.35 * t) > (-1 + 2 * pause_fraction)).astype(float)
|
||||
phrase = sps.lfilter(np.ones(int(0.02 * fs)) / int(0.02 * fs), [1.0], phrase)
|
||||
out *= syl * phrase
|
||||
|
||||
out /= max(np.abs(out).max(), 1e-9)
|
||||
return out * 0.7
|
||||
321
tests/test_announce.py
Normal file
321
tests/test_announce.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Spoken announcements and the files they are appended to."""
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import announce
|
||||
from bandsaunter.recorder import FrequencyLog, append_wav, read_wav
|
||||
from bandsaunter.quality import voice_metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Words
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("n,words", [
|
||||
(0, "zero"), (7, "seven"), (10, "ten"), (12, "twelve"), (19, "nineteen"),
|
||||
(20, "twenty"), (21, "twenty one"), (45, "forty five"), (59, "fifty nine"),
|
||||
(100, "one hundred"), (146, "one hundred forty six"),
|
||||
(2026, "two thousand twenty six"),
|
||||
])
|
||||
def test_number_words(n, words):
|
||||
assert " ".join(announce.number_words(n)) == words
|
||||
|
||||
|
||||
def test_timestamp_phrase_reads_naturally():
|
||||
phrase = announce.timestamp_phrase(datetime(2026, 8, 21, 14, 38, 5))
|
||||
assert "august" in phrase
|
||||
assert "twenty one" in phrase
|
||||
assert "twenty twenty six" in phrase # years are said in pairs
|
||||
assert "fourteen thirty eight" in phrase
|
||||
assert "oh five" in phrase # 05 seconds, not "five"
|
||||
|
||||
|
||||
def test_midnight_and_single_digits():
|
||||
phrase = announce.timestamp_phrase(datetime(2026, 1, 3, 0, 5, 0))
|
||||
assert "january three" in phrase
|
||||
assert "oh oh" in phrase # hour 00 and second 00
|
||||
|
||||
|
||||
def test_frequency_is_spoken_digit_by_digit():
|
||||
phrase = announce.timestamp_phrase(datetime(2026, 1, 1, 0, 0, 0),
|
||||
frequency=146_520_000.0)
|
||||
assert "one hundred forty six point one four six" not in phrase
|
||||
assert "point five two zero" in phrase
|
||||
assert "megahertz" in phrase
|
||||
|
||||
|
||||
def test_every_word_in_the_vocabulary_can_be_spoken():
|
||||
"""A word with no pronunciation would be silently dropped."""
|
||||
for word in announce.WORDS:
|
||||
for phone in announce.WORDS[word].split():
|
||||
assert phone in announce.PHONEMES, f"{word} uses unknown {phone}"
|
||||
|
||||
|
||||
def test_all_the_words_a_timestamp_needs_are_covered():
|
||||
for month in range(1, 13):
|
||||
for day in (1, 9, 15, 21, 28): # 28 is safe in every month
|
||||
for hour in (0, 5, 12, 23):
|
||||
phrase = announce.timestamp_phrase(
|
||||
datetime(2026, month, day, hour, 45, 9))
|
||||
for word in phrase.split():
|
||||
assert word in ("_", "__") or word in announce.WORDS, word
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthesis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _lpc_formants(x, fs, order=12):
|
||||
x = x - x.mean()
|
||||
x = np.append(x[0], x[1:] - 0.97 * x[:-1]) * np.hamming(x.size)
|
||||
r = np.correlate(x, x, "full")[x.size - 1:x.size - 1 + order + 1]
|
||||
if r[0] == 0:
|
||||
return []
|
||||
a = np.zeros(order + 1)
|
||||
a[0], e = 1.0, r[0]
|
||||
for i in range(1, order + 1):
|
||||
k = -(a[:i] @ r[i:0:-1]) / e
|
||||
a[:i + 1] = (np.concatenate([a[:i], [0]])
|
||||
+ k * np.concatenate([[0], a[i - 1::-1]]))
|
||||
e *= (1 - k * k)
|
||||
if e <= 0:
|
||||
break
|
||||
roots = [z for z in np.roots(a) if np.imag(z) > 0.01]
|
||||
return [f for f in sorted(float(np.angle(z) * fs / (2 * np.pi))
|
||||
for z in roots) if 120 < f < 5000]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vowel", ["iy", "ih", "eh", "ae", "aa", "ao",
|
||||
"uw", "uh", "ah", "er"])
|
||||
def test_vowels_land_on_their_formant_targets(vowel):
|
||||
"""Intelligibility rests on the formants being where they are meant to be.
|
||||
|
||||
Summing resonators in parallel instead of cascading them loses the first
|
||||
formant entirely, and every vowel then sounds the same.
|
||||
"""
|
||||
spec = announce.PHONEMES[vowel]
|
||||
announce.WORDS["_probe_"] = vowel
|
||||
audio = announce.synthesize("_probe_", 16000)
|
||||
seg = audio[int(audio.size * 0.35):][:800]
|
||||
formants = _lpc_formants(seg, 16000)
|
||||
assert len(formants) >= 2, f"{vowel}: found {formants}"
|
||||
assert abs(formants[0] - spec.f1) < 130, f"{vowel} F1: {formants}"
|
||||
assert abs(formants[1] - spec.f2) < 250, f"{vowel} F2: {formants}"
|
||||
|
||||
|
||||
def test_speech_is_in_the_voice_band():
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
|
||||
n = 1 << 14
|
||||
spec = np.abs(np.fft.rfft(audio[:n] * np.hanning(n))) ** 2
|
||||
freqs = np.fft.rfftfreq(n, 1 / 16000)
|
||||
band = (freqs >= 250) & (freqs < 3400)
|
||||
assert spec[band].sum() / spec.sum() > 0.6
|
||||
|
||||
|
||||
def test_the_announcement_reads_as_speech():
|
||||
"""The voice detector should hear the announcement as a voice."""
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
|
||||
m = voice_metrics(audio, 16000)
|
||||
assert m.score > 0.6, m.describe()
|
||||
assert 80 < m.pitch_hz < 200
|
||||
|
||||
|
||||
def test_synthesis_is_fast_enough_to_run_inline():
|
||||
import time
|
||||
t0 = time.perf_counter()
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
|
||||
elapsed = time.perf_counter() - t0
|
||||
assert elapsed < 0.25 * audio.size / 16000, f"{elapsed:.2f}s"
|
||||
|
||||
|
||||
def test_unknown_words_do_not_crash():
|
||||
assert announce.synthesize("gibberishwordnotinvocabulary", 16000).size == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Appending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_wav_stays_valid_after_every_append(tmp_path):
|
||||
"""A scan can be stopped at any moment; the file so far must still play."""
|
||||
path = tmp_path / "acc.wav"
|
||||
for i in range(1, 5):
|
||||
append_wav(path, np.full(1600, 0.1 * i, dtype=np.float32), 16000)
|
||||
audio, rate = read_wav(path)
|
||||
assert rate == 16000
|
||||
assert audio.size == 1600 * i
|
||||
import wave
|
||||
with wave.open(str(path)) as w: # the standard reader must agree
|
||||
assert w.getnframes() == 1600 * i
|
||||
|
||||
|
||||
def test_appending_a_different_rate_is_refused(tmp_path):
|
||||
path = tmp_path / "acc.wav"
|
||||
append_wav(path, np.zeros(160, dtype=np.float32), 16000)
|
||||
with pytest.raises(ValueError, match="16000"):
|
||||
append_wav(path, np.zeros(160, dtype=np.float32), 32000)
|
||||
|
||||
|
||||
def test_frequency_log_groups_nearby_receptions(tmp_path):
|
||||
log = FrequencyLog(tmp_path, tolerance_hz=6250.0, announce=False)
|
||||
audio = np.full(1600, 0.2, dtype=np.float32)
|
||||
log.add(146_520_040.0, audio, 16000)
|
||||
log.add(146_519_800.0, audio, 16000) # 240 Hz away: same channel
|
||||
log.add(147_100_000.0, audio, 16000) # far away: its own file
|
||||
files = sorted(p.name for p in tmp_path.glob("*.wav"))
|
||||
assert len(files) == 2, files
|
||||
assert log.appended == 3
|
||||
|
||||
|
||||
def test_frequency_log_resamples_a_mismatched_capture(tmp_path):
|
||||
log = FrequencyLog(tmp_path, announce=False)
|
||||
log.add(146_520_000.0, np.full(16000, 0.2, dtype=np.float32), 16000)
|
||||
log.add(146_520_000.0, np.full(32000, 0.2, dtype=np.float32), 32000)
|
||||
audio, rate = read_wav(log.files[0])
|
||||
assert rate == 16000
|
||||
# one second at each rate, plus the gaps between them
|
||||
assert 2.0 <= audio.size / rate <= 3.0
|
||||
|
||||
|
||||
def test_announcement_is_placed_before_each_recording(tmp_path):
|
||||
quiet = FrequencyLog(tmp_path / "a", announce=False)
|
||||
spoken = FrequencyLog(tmp_path / "b", announce=True)
|
||||
body = np.full(16000, 0.2, dtype=np.float32)
|
||||
quiet.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5))
|
||||
spoken.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5))
|
||||
short, _ = read_wav(quiet.files[0])
|
||||
long_, rate = read_wav(spoken.files[0])
|
||||
assert long_.size > short.size + 2 * rate, "no announcement was inserted"
|
||||
# the recording is a steady 0.2; the announcement is not, and comes first
|
||||
assert voice_metrics(long_[:int(3 * rate)], rate).score > 0.5
|
||||
|
||||
|
||||
def test_an_existing_file_from_an_earlier_run_is_continued(tmp_path):
|
||||
first = FrequencyLog(tmp_path, announce=False)
|
||||
first.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||
before = read_wav(first.files[0])[0].size
|
||||
|
||||
second = FrequencyLog(tmp_path, announce=False)
|
||||
second.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||
after = read_wav(second.files[0])[0].size
|
||||
assert after > before, "a later run started a new file instead of appending"
|
||||
assert len(list(tmp_path.glob("*.wav"))) == 1
|
||||
|
||||
|
||||
def test_the_date_is_only_spoken_when_it_changes(tmp_path):
|
||||
"""Repeating the date before every over takes longer than most overs last."""
|
||||
log = FrequencyLog(tmp_path, announce=True)
|
||||
body = np.full(16000, 0.2, dtype=np.float32)
|
||||
lengths = []
|
||||
previous = 0
|
||||
for when in (datetime(2026, 8, 21, 14, 38, 5),
|
||||
datetime(2026, 8, 21, 14, 41, 0),
|
||||
datetime(2026, 8, 22, 9, 2, 0)):
|
||||
log.add(146_520_000.0, body, 16000, when=when)
|
||||
size = read_wav(log.files[0])[0].size
|
||||
lengths.append(size - previous)
|
||||
previous = size
|
||||
first, same_day, next_day = lengths
|
||||
assert same_day < first * 0.7, "the date was repeated needlessly"
|
||||
assert next_day > same_day * 1.5, "a new day should get the full date"
|
||||
|
||||
|
||||
def test_time_only_announcement_is_shorter():
|
||||
when = datetime(2026, 8, 21, 14, 38, 5)
|
||||
full = announce.speak_timestamp(when, 16000, with_date=True)
|
||||
brief = announce.speak_timestamp(when, 16000, with_date=False)
|
||||
assert brief.size < full.size
|
||||
assert brief.size > 8000, "the time itself must still be spoken"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installed engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HAVE_ENGINE = announce.available_engine() is not None
|
||||
needs_engine = pytest.mark.skipif(not HAVE_ENGINE,
|
||||
reason="no text-to-speech program installed")
|
||||
|
||||
|
||||
def test_engine_detection_does_not_throw():
|
||||
engine = announce.available_engine()
|
||||
assert engine is None or engine in announce.ENGINES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("when", [
|
||||
datetime(2026, 8, 21, 14, 38, 5),
|
||||
datetime(2026, 1, 3, 9, 5, 0),
|
||||
datetime(2026, 12, 31, 23, 59, 59),
|
||||
])
|
||||
def test_engine_text_avoids_the_forms_engines_misread(when):
|
||||
"""Punctuation is what gives an engine its phrasing, and the obvious
|
||||
spellings are traps: a colon makes espeak read 14:38:05 as "fourteen
|
||||
thirty, eight zero five", and an ISO date has its dashes read aloud."""
|
||||
text = announce.timestamp_text(when)
|
||||
assert ":" not in text
|
||||
assert "-" not in text
|
||||
assert text.count(",") >= 2, text # date, year and time separated
|
||||
assert "twenty twenty six" in text # not "two thousand and ..."
|
||||
|
||||
|
||||
def test_engine_text_covers_the_time_only_case():
|
||||
text = announce.timestamp_text(datetime(2026, 8, 21, 9, 5, 0),
|
||||
with_date=False)
|
||||
assert "August" not in text
|
||||
assert "09 05" in text
|
||||
|
||||
|
||||
@needs_engine
|
||||
def test_installed_engine_renders_speech():
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
|
||||
engine="auto")
|
||||
assert audio.size > 16000, "suspiciously short"
|
||||
assert audio.dtype == np.float32
|
||||
m = voice_metrics(audio, 16000)
|
||||
assert m.score > 0.6, m.describe()
|
||||
|
||||
|
||||
@needs_engine
|
||||
@pytest.mark.parametrize("rate", [8000, 16000, 32000, 48000])
|
||||
def test_installed_engine_is_resampled_to_the_asked_for_rate(rate):
|
||||
"""Engines render at their own rate; espeak-ng uses 22050 Hz."""
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), rate,
|
||||
engine="auto")
|
||||
seconds = audio.size / rate
|
||||
assert 2.0 < seconds < 12.0, f"{seconds:.2f}s at {rate} Hz"
|
||||
|
||||
|
||||
@needs_engine
|
||||
def test_both_engines_produce_the_same_level():
|
||||
"""Switching engines must not change how loud announcements are."""
|
||||
when = datetime(2026, 8, 21, 14, 38, 5)
|
||||
external = announce.speak_timestamp(when, 16000, engine="auto")
|
||||
builtin = announce.speak_timestamp(when, 16000, engine="builtin")
|
||||
assert abs(float(np.abs(external).max())
|
||||
- float(np.abs(builtin).max())) < 0.05
|
||||
|
||||
|
||||
@needs_engine
|
||||
def test_dropping_the_date_shortens_the_announcement():
|
||||
when = datetime(2026, 8, 21, 14, 38, 5)
|
||||
full = announce.speak_timestamp(when, 16000, engine="auto")
|
||||
brief = announce.speak_timestamp(when, 16000, engine="auto",
|
||||
with_date=False)
|
||||
assert brief.size < full.size * 0.75
|
||||
|
||||
|
||||
def test_an_engine_that_is_not_installed_falls_back_to_the_builtin():
|
||||
"""The feature must keep working with nothing else on the machine."""
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
|
||||
engine="definitely-not-installed")
|
||||
assert audio.size > 0
|
||||
assert voice_metrics(audio, 16000).score > 0.6
|
||||
|
||||
|
||||
def test_a_failing_engine_falls_back_rather_than_crashing(monkeypatch):
|
||||
monkeypatch.setattr(announce, "_external", lambda *a, **k: None)
|
||||
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
|
||||
engine="auto")
|
||||
assert audio.size > 0
|
||||
50
tests/test_classify.py
Normal file
50
tests/test_classify.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import pytest
|
||||
|
||||
from bandsaunter.classify import classify, extract_features
|
||||
from signals import FS, make
|
||||
|
||||
CASES = [
|
||||
("cw", "cw", 14.05e6), ("nfm", "nfm", 146.52e6), ("am", "am", 121.5e6),
|
||||
("usb", "ssb", 14.2e6), ("lsb", "ssb", 7.2e6),
|
||||
("fsk2", "fsk", 929.5e6), ("fsk4", "fsk", 460.0e6),
|
||||
("psk4", "psk", 450.0e6), ("psk2", "psk", 437.0e6),
|
||||
("carrier", "carrier", 446.0e6), ("noise", "unknown", 300e6),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,family,freq", CASES)
|
||||
@pytest.mark.parametrize("seed", [3, 11, 42])
|
||||
def test_modulation_family(kind, family, freq, seed):
|
||||
c = classify(make(kind, seed=seed), FS, freq_hz=freq, snr_db=28)
|
||||
assert c.family == family, f"{kind} -> {c.label!r} (family {c.family})"
|
||||
|
||||
|
||||
def test_known_systems_are_named_from_frequency():
|
||||
assert "airband" in classify(make("am"), FS, freq_hz=121.5e6,
|
||||
snr_db=28).label.lower()
|
||||
assert "broadcast" in classify(make("wfm"), FS, freq_hz=97.5e6,
|
||||
snr_db=28).label.lower()
|
||||
assert "weather" in classify(make("nfm"), FS, freq_hz=162.475e6,
|
||||
snr_db=28).label.lower()
|
||||
|
||||
|
||||
def test_ctcss_tone_recovered():
|
||||
c = classify(make("nfm"), FS, freq_hz=146.52e6, snr_db=28)
|
||||
assert c.features.ctcss_hz == pytest.approx(100.0, abs=0.5)
|
||||
|
||||
|
||||
def test_low_snr_reduces_confidence():
|
||||
strong = classify(make("nfm", snr_db=30), FS, freq_hz=146.52e6, snr_db=30)
|
||||
weak = classify(make("nfm", snr_db=5), FS, freq_hz=146.52e6, snr_db=5)
|
||||
assert weak.confidence < strong.confidence
|
||||
|
||||
|
||||
def test_fsk_level_count():
|
||||
assert extract_features(make("fsk2"), FS, snr_db=28).freq_modes == 2
|
||||
assert extract_features(make("fsk4"), FS, snr_db=28).freq_modes == 4
|
||||
|
||||
|
||||
def test_symbol_rate_estimate():
|
||||
f = extract_features(make("fsk4"), FS, snr_db=28)
|
||||
# The cyclostationary line lands on the baud rate or a low harmonic of it.
|
||||
assert f.baud > 1000
|
||||
114
tests/test_dsp.py
Normal file
114
tests/test_dsp.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import dsp
|
||||
|
||||
|
||||
def test_decimator_is_stateful_across_blocks():
|
||||
"""Block-by-block filtering must equal one-shot filtering exactly."""
|
||||
rng = np.random.default_rng(0)
|
||||
x = (rng.standard_normal(20000) + 1j * rng.standard_normal(20000)).astype(np.complex64)
|
||||
d1 = dsp.FIRDecimator(8)
|
||||
blocked = np.concatenate([d1(x[:7000]), d1(x[7000:13000]), d1(x[13000:])])
|
||||
d2 = dsp.FIRDecimator(8)
|
||||
assert np.allclose(blocked, d2(x), atol=1e-9)
|
||||
|
||||
|
||||
def test_decimation_chain_factors():
|
||||
assert dsp.design_decimation(128) == [8, 8, 2]
|
||||
assert dsp.design_decimation(1) == []
|
||||
chain = dsp.DecimationChain(64)
|
||||
out = chain(np.ones(6400, dtype=np.complex64))
|
||||
assert out.size == 100
|
||||
|
||||
|
||||
def test_mixer_keeps_phase_continuous():
|
||||
fs = 48000.0
|
||||
m = dsp.Mixer(1000.0, fs)
|
||||
a = m(np.ones(1000, dtype=np.complex64))
|
||||
b = m(np.ones(1000, dtype=np.complex64))
|
||||
joined = np.concatenate([a, b])
|
||||
one_shot = dsp.frequency_shift(np.ones(2000, dtype=np.complex64), 1000.0, fs)
|
||||
assert np.allclose(joined, one_shot, atol=1e-4)
|
||||
|
||||
|
||||
def test_noise_floor_curve_ignores_signals():
|
||||
"""A carrier must not raise the floor it is being measured against."""
|
||||
psd = np.full(1024, -80.0)
|
||||
psd[500:504] = -20.0
|
||||
floor = dsp.noise_floor_curve(psd)
|
||||
assert floor[502] == pytest.approx(-80.0, abs=1.0)
|
||||
assert (psd - floor)[502] > 55.0
|
||||
|
||||
|
||||
def test_peak_hold_beats_averaging_on_bursts():
|
||||
fs = 2_048_000
|
||||
x = np.zeros(102400, dtype=np.complex64)
|
||||
t = np.arange(102400) / fs
|
||||
x[:8000] = 0.5 * np.exp(2j * np.pi * 100_000 * t[:8000])
|
||||
rng = np.random.default_rng(1)
|
||||
x += 0.01 * (rng.standard_normal(102400) + 1j * rng.standard_normal(102400))
|
||||
_, p_avg = dsp.welch_psd(x, 1024, combine="mean")
|
||||
_, p_max = dsp.welch_psd(x, 1024, combine="max")
|
||||
avg = (dsp.db(p_avg) - dsp.noise_floor_curve(dsp.db(p_avg))).max()
|
||||
peak = (dsp.db(p_max) - dsp.noise_floor_curve(dsp.db(p_max))).max()
|
||||
assert peak > avg
|
||||
|
||||
|
||||
def test_occupied_bandwidth_and_flatness():
|
||||
psd = np.zeros(1024)
|
||||
psd[500:524] = 1.0
|
||||
bw, offset = dsp.occupied_bandwidth(psd, 100.0)
|
||||
assert bw == pytest.approx(2400.0, rel=0.15)
|
||||
assert dsp.spectral_flatness(np.ones(256)) == pytest.approx(1.0)
|
||||
tone = np.full(256, 1e-9)
|
||||
tone[128] = 1.0
|
||||
assert dsp.spectral_flatness(tone) < 0.01
|
||||
|
||||
|
||||
def test_decimator_matches_a_direct_filter_reference():
|
||||
"""The polyphase form must equal filtering then discarding samples.
|
||||
|
||||
It replaced an lfilter that computed every output and threw most away;
|
||||
that was slow enough to stop captures keeping up with real time, which
|
||||
shows up as recordings that play too fast.
|
||||
"""
|
||||
from scipy import signal as sps
|
||||
rng = np.random.default_rng(4)
|
||||
x = (rng.standard_normal(30000) + 1j * rng.standard_normal(30000)).astype(np.complex64)
|
||||
for factor in (2, 4, 8):
|
||||
d = dsp.FIRDecimator(factor)
|
||||
got = d(x)
|
||||
ref = sps.lfilter(d.taps.astype(np.float64), [1.0],
|
||||
x.astype(np.complex128))[::factor]
|
||||
assert np.allclose(got, ref[:got.size], atol=1e-4), f"factor {factor}"
|
||||
|
||||
|
||||
def test_quarter_rate_mixer_matches_an_explicit_reference():
|
||||
"""The trig-free shortcut must be exact, and stay phase-continuous."""
|
||||
rng = np.random.default_rng(5)
|
||||
n = 9000
|
||||
x = (rng.standard_normal(n) + 1j * rng.standard_normal(n)).astype(np.complex64)
|
||||
fs = 2_048_000.0
|
||||
mixer = dsp.Mixer(fs / 4, fs)
|
||||
assert mixer._is_quarter_rate
|
||||
got = np.concatenate([mixer(x[:3000]), mixer(x[3000:5000]), mixer(x[5000:])])
|
||||
ref = x * np.exp(-2j * np.pi * (fs / 4) * np.arange(n) / fs)
|
||||
assert np.allclose(got, ref, atol=1e-5)
|
||||
|
||||
|
||||
def test_decimation_is_fast_enough_for_realtime():
|
||||
"""A 50 ms block must decimate in well under 50 ms of CPU."""
|
||||
import time
|
||||
sr, n = 2_048_000, 102_400
|
||||
x = (np.random.default_rng(6).standard_normal(n)
|
||||
+ 1j * np.random.default_rng(7).standard_normal(n)).astype(np.complex64)
|
||||
chain = dsp.DecimationChain(64)
|
||||
chain(x)
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(5):
|
||||
chain(x)
|
||||
per_block = (time.perf_counter() - t0) / 5
|
||||
budget = n / sr
|
||||
assert per_block < 0.35 * budget, \
|
||||
f"{per_block*1000:.1f} ms per {budget*1000:.0f} ms block"
|
||||
50
tests/test_morse.py
Normal file
50
tests/test_morse.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from morse_gen import morse_audio
|
||||
from bandsaunter.morse import decode_morse, encode_morse
|
||||
|
||||
FS = 16000
|
||||
|
||||
MESSAGES = [
|
||||
("CQ CQ DE W1AW K", 18, 25), ("SOS SOS", 12, 20),
|
||||
("TEST DE N0CALL 599 TU", 25, 18), ("HELLO WORLD 73", 35, 15),
|
||||
("PARIS PARIS", 8, 25), ("VVV DE K1ABC", 22, 12),
|
||||
("QRZ? DE VE3XYZ/M", 20, 20), ("R 5NN TU 73 GL", 30, 22),
|
||||
("MAYDAY MAYDAY", 15, 8), ("THE QUICK BROWN FOX 1234567890", 28, 20),
|
||||
("CQ DX DE W1AW", 18, 6),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg,wpm,snr", MESSAGES)
|
||||
def test_decodes_exactly(msg, wpm, snr):
|
||||
r = decode_morse(morse_audio(msg, wpm, FS, snr), FS)
|
||||
assert r.text.strip() == msg
|
||||
assert r.is_morse
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wpm", [8, 15, 22, 30, 40])
|
||||
def test_speed_estimate_is_accurate(wpm):
|
||||
r = decode_morse(morse_audio("CQ DE W1AW TEST", wpm, FS, 20), FS)
|
||||
assert r.wpm == pytest.approx(wpm, rel=0.10)
|
||||
|
||||
|
||||
def test_tone_frequency_found():
|
||||
r = decode_morse(morse_audio("CQ TEST", 20, FS, 20, tone=1100.0), FS)
|
||||
assert r.tone_hz == pytest.approx(1100.0, abs=25)
|
||||
|
||||
|
||||
def test_noise_is_not_morse():
|
||||
rng = np.random.default_rng(0)
|
||||
r = decode_morse(0.1 * rng.standard_normal(FS * 3), FS)
|
||||
assert not r.is_morse
|
||||
assert r.text == ""
|
||||
|
||||
|
||||
def test_silence_is_rejected():
|
||||
assert not decode_morse(np.zeros(FS * 2), FS).is_morse
|
||||
|
||||
|
||||
def test_encode_round_trip():
|
||||
assert encode_morse("SOS") == "... --- ..."
|
||||
assert encode_morse("A B") == ".- / -..."
|
||||
217
tests/test_quality.py
Normal file
217
tests/test_quality.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Tests for the content gate: what counts as a signal worth recording."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.signal import butter, lfilter
|
||||
|
||||
from bandsaunter.classify import classify
|
||||
from bandsaunter.demod import make_demodulator
|
||||
from bandsaunter.morse import decode_morse
|
||||
from bandsaunter.quality import (RAYLEIGH_CV, assess, digital_structure,
|
||||
noise_likeness, voice_metrics)
|
||||
from bandsaunter import dsp
|
||||
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
|
||||
from speech import synth_speech
|
||||
from signals import FS, make
|
||||
|
||||
AUDIO_FS = 16000
|
||||
|
||||
|
||||
def _tone(hz, seconds=5.0, fs=AUDIO_FS):
|
||||
t = np.arange(int(seconds * fs)) / fs
|
||||
return np.sin(2 * np.pi * hz * t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Voice detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("pitch,seed", [(110, 0), (150, 1), (210, 2)])
|
||||
def test_speech_scores_high(pitch, seed):
|
||||
m = voice_metrics(synth_speech(5.0, AUDIO_FS, pitch, seed), AUDIO_FS)
|
||||
assert m.score > 0.6, m.describe()
|
||||
assert m.pitch_hz == pytest.approx(pitch, rel=0.2)
|
||||
assert m.voiced_fraction > 0.2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,signal", [
|
||||
("white noise", np.random.default_rng(0).standard_normal(AUDIO_FS * 5)),
|
||||
("steady tone", _tone(1000.0)),
|
||||
("mains-style hum", sum(_tone(120.0 * k) / k for k in range(1, 6))),
|
||||
("silence", np.zeros(AUDIO_FS * 3)),
|
||||
])
|
||||
def test_non_speech_scores_low(name, signal):
|
||||
assert voice_metrics(signal, AUDIO_FS).score < 0.42, name
|
||||
|
||||
|
||||
def test_static_scores_below_speech():
|
||||
b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band")
|
||||
static = lfilter(b, a, np.random.default_rng(1).standard_normal(AUDIO_FS * 5))
|
||||
speech = synth_speech(5.0, AUDIO_FS, 120, 0)
|
||||
assert voice_metrics(static, AUDIO_FS).score < \
|
||||
voice_metrics(speech, AUDIO_FS).score - 0.3
|
||||
|
||||
|
||||
def test_pitch_variation_separates_voice_from_a_buzz():
|
||||
"""A talker's pitch wanders; a hum sits on one frequency."""
|
||||
assert voice_metrics(synth_speech(5.0, AUDIO_FS, 120, 0),
|
||||
AUDIO_FS).pitch_variation > 0.02
|
||||
hum = sum(_tone(120.0 * k) / k for k in range(1, 6))
|
||||
assert voice_metrics(hum, AUDIO_FS).pitch_variation < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Noise and structure scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_noise_likeness_high_for_noise_low_for_signals():
|
||||
from bandsaunter.classify import extract_features
|
||||
noise = noise_likeness(extract_features(make("noise"), FS, snr_db=0))
|
||||
assert noise > 0.7
|
||||
for kind in ("nfm", "fsk4", "cw", "am"):
|
||||
assert noise_likeness(extract_features(make(kind), FS, snr_db=28)) < noise
|
||||
|
||||
|
||||
def test_rayleigh_constant():
|
||||
"""Complex Gaussian noise has this exact envelope coefficient of variation."""
|
||||
rng = np.random.default_rng(0)
|
||||
x = (rng.standard_normal(200000) + 1j * rng.standard_normal(200000))
|
||||
env = np.abs(x)
|
||||
assert env.std() / env.mean() == pytest.approx(RAYLEIGH_CV, rel=0.02)
|
||||
|
||||
|
||||
def test_symbol_rate_alone_is_not_digital():
|
||||
"""A baud estimate with no identified keying proves nothing."""
|
||||
from bandsaunter.classify import extract_features
|
||||
f = extract_features(make("noise"), FS, snr_db=0)
|
||||
score, bits = digital_structure(f)
|
||||
assert score == 0.0 and bits == []
|
||||
|
||||
|
||||
def test_fsk_and_ook_are_digital():
|
||||
from bandsaunter.classify import extract_features
|
||||
for kind in ("fsk2", "fsk4"):
|
||||
score, bits = digital_structure(extract_features(make(kind), FS, snr_db=28))
|
||||
assert score > 0.3, f"{kind}: {bits}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end verdicts through the real RF chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _through_radio(mode, freq_hz, demod_mode, bw=12_500, seconds=3.0, **kw):
|
||||
"""Run a simulated transmitter through tune, mix, decimate and demodulate."""
|
||||
sr = 2_048_000
|
||||
tx = [] if mode is None else [V(146_520_000, mode, 0.4, bw, "tx", **kw)]
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
dev.tune(146_520_000 - sr * 0.25)
|
||||
mixer = dsp.Mixer(sr * 0.25, sr)
|
||||
demod = make_demodulator(demod_mode, sr, bw, 16_000)
|
||||
audio, iq = [], []
|
||||
for _ in range(int(seconds / 0.05)):
|
||||
a, i = demod.step(mixer(dev.read_samples(102400)))
|
||||
audio.append(a)
|
||||
iq.append(i)
|
||||
audio = np.concatenate(audio)
|
||||
iq = np.concatenate(iq)
|
||||
cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=28)
|
||||
morse = None
|
||||
if cls.family in ("cw", "ook", "carrier"):
|
||||
cw = make_demodulator("cw", int(demod.if_rate), 800.0, 16_000)
|
||||
morse = decode_morse(cw.process(iq), cw.audio_rate)
|
||||
return assess(cls, audio, demod.audio_rate, morse=morse, freq_hz=freq_hz)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,freq,dmode,bw,kw,want", [
|
||||
("nfm", 146.52e6, "nfm", 12_500, {"ctcss": 100.0}, "voice"),
|
||||
("nfm", 146.52e6, "nfm", 12_500, {}, "voice"),
|
||||
("am", 121.5e6, "am", 8_000, {}, "voice"),
|
||||
("usb", 14.2e6, "usb", 3_000, {}, "voice"),
|
||||
("wfm", 97.5e6, "wfm", 180_000, {"deviation": 75_000}, "voice"),
|
||||
("carrier", 446.0e6, "nfm", 12_500, {}, "carrier"),
|
||||
("fsk4", 460.0e6, "nfm", 12_500, {"baud": 4800, "deviation": 1800}, "digital"),
|
||||
("fsk2", 929.6e6, "nfm", 12_500, {"baud": 1200, "deviation": 2400}, "digital"),
|
||||
("ook", 433.92e6, "nfm", 40_000, {"baud": 2000}, "digital"),
|
||||
("cw", 14.05e6, "cw", 800, {"wpm": 18}, "cw"),
|
||||
(None, 300.0e6, "nfm", 12_500, {}, "noise"),
|
||||
])
|
||||
def test_content_categories(mode, freq, dmode, bw, kw, want):
|
||||
v = _through_radio(mode, freq, dmode, bw, **kw)
|
||||
assert v.category == want, f"{mode} -> {v.category}: {v.reason}"
|
||||
|
||||
|
||||
def test_accept_list_controls_what_is_kept():
|
||||
v = _through_radio("carrier", 446.0e6, "nfm")
|
||||
assert v.category == "carrier" and not v.accept
|
||||
v2 = _through_radio("nfm", 146.52e6, "nfm")
|
||||
assert v2.category == "voice" and v2.accept
|
||||
|
||||
|
||||
def test_broadcast_leniency_does_not_apply_outside_the_fm_band():
|
||||
"""A wideband hump at 250 MHz is not a broadcast station."""
|
||||
v = _through_radio("wfm", 250.0e6, "wfm", 180_000, deviation=75_000)
|
||||
assert v.category != "voice" or v.voice.score >= 0.45
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regressions: each of these let static through at some point.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_voicing_is_necessary_not_merely_weighted():
|
||||
"""Noise can satisfy dynamics, syllable rate and band -- but not pitch.
|
||||
|
||||
Weighting voicing alongside the other three let hiss score over the line
|
||||
on their strength alone, which is how static ended up recorded as speech.
|
||||
"""
|
||||
rng = np.random.default_rng(7)
|
||||
n = AUDIO_FS * 5
|
||||
t = np.arange(n) / AUDIO_FS
|
||||
b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band")
|
||||
hiss = lfilter(b, a, rng.standard_normal(n))
|
||||
# Give it speech-like dynamics and a 4 Hz syllabic envelope.
|
||||
hiss *= (0.15 + 0.85 * np.abs(np.sin(2 * np.pi * 4.0 * t))) * \
|
||||
(np.sin(2 * np.pi * 0.3 * t) > -0.4)
|
||||
m = voice_metrics(hiss, AUDIO_FS)
|
||||
assert m.dynamic_range_db > 10, "test signal should have big dynamics"
|
||||
assert m.syllabic > 0.2, "test signal should have syllabic modulation"
|
||||
assert m.voiced_fraction < 0.15, "but no pitch track"
|
||||
assert m.score < 0.45, f"shaped hiss scored {m.score:.2f} as voice"
|
||||
|
||||
|
||||
def test_pitch_pinned_at_the_search_limit_is_not_a_pitch():
|
||||
"""A maximum on the edge of the search range is the search running out."""
|
||||
rng = np.random.default_rng(3)
|
||||
m = voice_metrics(rng.standard_normal(AUDIO_FS * 4), AUDIO_FS)
|
||||
assert not (0 < m.pitch_hz < 75) and m.pitch_hz < 395 or m.pitch_hz == 0.0
|
||||
|
||||
|
||||
def test_symbol_rate_must_hold_across_the_capture():
|
||||
"""Real data keeps one symbol rate; an estimator on noise does not."""
|
||||
from bandsaunter.classify import extract_features
|
||||
for kind in ("fsk2", "fsk4", "psk2", "psk4"):
|
||||
f = extract_features(make(kind), FS, snr_db=28)
|
||||
assert f.baud_stability > 0.8, f"{kind}: {f.baud_stability}"
|
||||
for kind in ("noise", "nfm", "carrier"):
|
||||
f = extract_features(make(kind), FS, snr_db=28)
|
||||
assert f.baud_stability < 0.8, f"{kind}: {f.baud_stability}"
|
||||
|
||||
|
||||
def test_keying_needs_a_regular_grid():
|
||||
"""On/off contrast alone is not keying: a fading carrier produces plenty."""
|
||||
from bandsaunter.classify import extract_features
|
||||
keyed = extract_features(make("cw"), FS, snr_db=28)
|
||||
noise = extract_features(make("noise"), FS, snr_db=0)
|
||||
assert keyed.keying_regularity > noise.keying_regularity
|
||||
|
||||
|
||||
def test_a_fading_carrier_is_not_digital():
|
||||
"""A carrier drifting across the squelch has contrast but no symbol grid."""
|
||||
from bandsaunter.classify import extract_features
|
||||
rng = np.random.default_rng(5)
|
||||
n = 64000
|
||||
t = np.arange(n) / FS
|
||||
# Slow random fading, nothing quantised about it.
|
||||
fade = np.interp(t, np.linspace(0, t[-1], 40), rng.random(40) ** 2)
|
||||
x = (fade * np.exp(2j * np.pi * 300 * t)).astype(np.complex64)
|
||||
x += 0.02 * (rng.standard_normal(n) + 1j * rng.standard_normal(n))
|
||||
score, bits = digital_structure(extract_features(x, FS, snr_db=20))
|
||||
assert score <= 0.3, f"fading carrier scored as digital: {bits}"
|
||||
213
tests/test_ranges.py
Normal file
213
tests/test_ranges.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import pytest
|
||||
|
||||
from bandsaunter.bandplan import PRESETS, by_key, fmt_hz
|
||||
from bandsaunter.ranges import (RangeError, ScanRange, build_plan,
|
||||
parse_frequency, parse_range, parse_range_list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("146.52M", 146_520_000), ("146520000", 146_520_000),
|
||||
("433.92 MHz", 433_920_000), ("14074k", 14_074_000),
|
||||
("1.09G", 1_090_000_000), ("162.4", 162_400_000), ("500Hz", 500),
|
||||
])
|
||||
def test_parse_frequency(text, expected):
|
||||
assert parse_frequency(text) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_parse_range_forms():
|
||||
assert parse_range("144M-148M").start == 144e6
|
||||
# the unit on the right end carries over to the left
|
||||
r = parse_range("144-148M")
|
||||
assert (r.start, r.stop) == (144e6, 148e6)
|
||||
r = parse_range("144M-148M/25k@nfm")
|
||||
assert r.step == 25_000 and r.mode == "nfm"
|
||||
assert parse_range("gmrs").preset_key == "gmrs"
|
||||
|
||||
|
||||
def test_single_frequency_gets_width():
|
||||
r = parse_range("146.52M")
|
||||
assert r.stop > r.start
|
||||
|
||||
|
||||
def test_parse_range_list_unlimited():
|
||||
rs = parse_range_list("144M-148M, 420-450M, gmrs, 162.4-162.55M, 88-108M")
|
||||
assert len(rs) == 5
|
||||
|
||||
|
||||
def test_bad_input_raises():
|
||||
with pytest.raises(RangeError):
|
||||
parse_range("banana")
|
||||
|
||||
|
||||
def test_plan_parks_lo_clear_of_the_covered_span():
|
||||
"""The DC spike must never land inside the frequencies being searched."""
|
||||
steps = build_plan(parse_range_list("144.05M-144.15M"), 2_048_000,
|
||||
dc_guard=8000.0)
|
||||
assert len(steps) == 1
|
||||
s = steps[0]
|
||||
assert s.center < s.low
|
||||
assert s.low - s.center == pytest.approx(8000.0)
|
||||
|
||||
|
||||
def test_plan_covers_whole_range_contiguously():
|
||||
ranges = parse_range_list("144M-148M")
|
||||
steps = build_plan(ranges, 2_048_000)
|
||||
assert steps[0].low == pytest.approx(144e6)
|
||||
assert steps[-1].high == pytest.approx(148e6)
|
||||
for a, b in zip(steps, steps[1:]):
|
||||
assert a.high == pytest.approx(b.low)
|
||||
|
||||
|
||||
def test_band_plan_is_well_formed():
|
||||
keys = [p.key for p in PRESETS]
|
||||
assert len(keys) == len(set(keys)), "duplicate preset keys"
|
||||
for p in PRESETS:
|
||||
assert p.stop > p.start, f"{p.key} has an empty span"
|
||||
assert p.mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw",
|
||||
"auto")
|
||||
# "auto" defers to the segments beneath it, so it must not also
|
||||
# declare a bandwidth that would be used in preference to theirs.
|
||||
if p.mode == "auto":
|
||||
assert p.bandwidth == 0, f"{p.key} both defers and dictates"
|
||||
assert by_key(p.key) is p
|
||||
|
||||
|
||||
def test_fmt_hz():
|
||||
assert fmt_hz(146_520_000) == "146.52 MHz"
|
||||
assert fmt_hz(1_090_000_000) == "1.09 GHz"
|
||||
assert fmt_hz(500) == "500 Hz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets that stand for a set of others
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_all_cw_expands_to_every_cw_segment():
|
||||
from bandsaunter.bandplan import PRESETS, by_key
|
||||
group = by_key("all-cw")
|
||||
assert group is not None and group.is_group
|
||||
members = group.expand()
|
||||
# every CW preset in the plan should be in it, and nothing else
|
||||
cw_keys = {p.key for p in PRESETS if p.mode == "cw" and not p.is_group}
|
||||
assert {p.key for p in members} == cw_keys, "the group and the plan disagree"
|
||||
assert all(p.mode == "cw" for p in members)
|
||||
assert len(members) >= 10
|
||||
|
||||
|
||||
def test_a_group_becomes_one_range_per_member():
|
||||
ranges = parse_range_list("all-cw")
|
||||
assert len(ranges) >= 10
|
||||
assert all(r.mode == "cw" for r in ranges)
|
||||
# separate ranges, not one span from the lowest to the highest
|
||||
assert all(r.span < 1e6 for r in ranges), [r.span for r in ranges]
|
||||
assert sum(r.span for r in ranges) < 5e6
|
||||
|
||||
|
||||
def test_a_group_declares_the_extent_of_its_members():
|
||||
"""The displayed span must not drift from what the group actually covers."""
|
||||
from bandsaunter.bandplan import PRESETS
|
||||
for group in [p for p in PRESETS if p.is_group]:
|
||||
members = group.expand()
|
||||
assert group.start == pytest.approx(min(m.start for m in members))
|
||||
assert group.stop == pytest.approx(max(m.stop for m in members))
|
||||
|
||||
|
||||
def test_group_members_all_exist():
|
||||
from bandsaunter.bandplan import PRESETS, by_key
|
||||
for group in [p for p in PRESETS if p.is_group]:
|
||||
for key in group.members:
|
||||
assert by_key(key) is not None, f"{group.key} names missing {key}"
|
||||
assert len(group.expand()) == len(group.members)
|
||||
|
||||
|
||||
def test_a_group_is_refused_where_only_one_range_fits():
|
||||
with pytest.raises(RangeError, match="separate ranges"):
|
||||
parse_range("all-cw")
|
||||
|
||||
|
||||
def test_groups_do_not_label_detections():
|
||||
"""A group spans a huge range; it must not be used to name a signal."""
|
||||
from bandsaunter.bandplan import presets_covering
|
||||
assert all(not p.is_group for p in presets_covering(14_050_000))
|
||||
|
||||
|
||||
def test_a_group_mixes_with_ordinary_ranges():
|
||||
ranges = parse_range_list("all-cw, 144M-148M, gmrs")
|
||||
labels = [r.label for r in ranges]
|
||||
assert "2 m CW" in labels
|
||||
assert "GMRS / FRS" in labels
|
||||
assert any("144 MHz-148 MHz" in x for x in labels)
|
||||
|
||||
|
||||
def test_the_cw_segments_are_inside_their_bands():
|
||||
"""A CW segment that strays outside its amateur band would be wrong."""
|
||||
from bandsaunter.bandplan import by_key
|
||||
bands = {"160m-cw": "160m", "80m-cw": "80m", "40m-cw": "40m",
|
||||
"20m-cw": "20m", "17m-cw": "17m", "15m-cw": "15m",
|
||||
"12m-cw": "12m", "10m-cw": "10m", "6m-cw": "6m", "2m-cw": "2m"}
|
||||
for cw_key, band_key in bands.items():
|
||||
cw, band = by_key(cw_key), by_key(band_key)
|
||||
assert cw.start >= band.start, cw_key
|
||||
assert cw.stop <= band.stop, cw_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whole amateur bands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMPLETE_BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
|
||||
"12m", "10m", "6m", "2m", "1.25m", "70cm", "33cm"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("band", COMPLETE_BANDS)
|
||||
def test_every_amateur_band_has_a_complete_entry(band):
|
||||
from bandsaunter.bandplan import by_key
|
||||
whole = by_key(f"{band}-complete")
|
||||
assert whole is not None, f"no {band}-complete"
|
||||
assert whole.category == "Amateur Radio"
|
||||
assert whole.mode == "auto"
|
||||
# It must actually cover the band it names.
|
||||
plain = by_key(band)
|
||||
assert whole.start <= plain.start and whole.stop >= plain.stop
|
||||
|
||||
|
||||
@pytest.mark.parametrize("band", COMPLETE_BANDS)
|
||||
def test_a_complete_band_resolves_a_real_mode_everywhere(band):
|
||||
""""auto" must never survive to the demodulator."""
|
||||
ranges = parse_range_list(f"{band}-complete")
|
||||
assert len(ranges) == 1
|
||||
r = ranges[0]
|
||||
step = r.span / 200
|
||||
freq = r.start
|
||||
while freq < r.stop:
|
||||
mode = r.resolved_mode(freq)
|
||||
assert mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw"), \
|
||||
f"{band} at {freq}: {mode}"
|
||||
assert r.resolved_bandwidth(freq) > 0, f"{band} at {freq}"
|
||||
freq += step
|
||||
|
||||
|
||||
def test_2m_complete_covers_cw_then_ssb_then_fm():
|
||||
r = parse_range_list("2m-complete")[0]
|
||||
assert r.resolved_mode(144_050_000) == "cw"
|
||||
assert r.resolved_mode(144_200_000) == "usb"
|
||||
assert r.resolved_mode(146_520_000) == "nfm"
|
||||
|
||||
|
||||
def test_70cm_complete_is_not_hijacked_by_the_ism_band():
|
||||
"""433 MHz is shared; inside an amateur sweep the amateur reading wins."""
|
||||
r = parse_range_list("70cm-complete")[0]
|
||||
assert r.resolved_mode(433_920_000) == "nfm"
|
||||
assert r.resolved_mode(432_050_000) == "cw"
|
||||
assert r.resolved_mode(432_200_000) == "usb"
|
||||
|
||||
|
||||
def test_a_plain_range_still_gets_the_ism_reading():
|
||||
assert ScanRange(433.9e6, 433.95e6, mode="auto").resolved_mode(433.92e6) \
|
||||
== "raw"
|
||||
|
||||
|
||||
def test_complete_bands_are_offered_where_they_would_be_looked_for():
|
||||
from bandsaunter.bandplan import in_category
|
||||
keys = {p.key for p in in_category("Amateur Radio")}
|
||||
assert {f"{b}-complete" for b in COMPLETE_BANDS} <= keys
|
||||
510
tests/test_scanner.py
Normal file
510
tests/test_scanner.py
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
"""End-to-end scanner tests driven by the built-in simulator."""
|
||||
import json
|
||||
import re
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.scanner import Scanner, ScannerCallbacks
|
||||
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
|
||||
|
||||
|
||||
def run_scan(tmp_path, transmitters, ranges, **over):
|
||||
dev = SimulatedDevice(transmitters=transmitters).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list(ranges), output_dir=str(tmp_path),
|
||||
record_seconds=3.0, hang_seconds=1.0,
|
||||
min_record_seconds=0.3, threshold_db=12,
|
||||
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.2)
|
||||
cfg.accept = list(over.pop("accept", cfg.accept))
|
||||
for k, v in over.items():
|
||||
setattr(cfg, k, v)
|
||||
hits = []
|
||||
s = Scanner(cfg, device=dev,
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
return s, [h for h in hits if h.kept]
|
||||
|
||||
|
||||
def test_finds_and_identifies_narrowband_fm(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice", ctcss=100.0)]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M")
|
||||
assert hits, "nothing recorded"
|
||||
h = hits[0]
|
||||
assert h.frequency == pytest.approx(146_520_000, abs=8_000)
|
||||
assert h.category == "voice"
|
||||
assert h.family == "nfm", h.classification
|
||||
assert "voice" in h.classification.lower()
|
||||
assert h.ctcss_hz == pytest.approx(100.0, abs=0.5)
|
||||
|
||||
|
||||
def test_record_seconds_is_honoured_exactly(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=2.0)
|
||||
assert hits
|
||||
for h in hits:
|
||||
assert h.duration == pytest.approx(2.0, abs=0.15)
|
||||
assert "record limit" in h.stop_reason
|
||||
|
||||
|
||||
def test_hang_seconds_ends_a_finished_transmission(tmp_path):
|
||||
"""A transmitter that stops must release the scanner after the hang time."""
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst",
|
||||
period_seconds=6.0, on_seconds=1.0)]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M",
|
||||
record_seconds=20.0, hang_seconds=0.8, max_cycles=3)
|
||||
assert hits, "nothing recorded"
|
||||
ended_on_silence = [h for h in hits if "quiet" in h.stop_reason]
|
||||
assert ended_on_silence, [h.stop_reason for h in hits]
|
||||
for h in ended_on_silence:
|
||||
assert h.duration < 20.0
|
||||
|
||||
|
||||
def test_min_record_seconds_discards_blips(tmp_path):
|
||||
"""A transmission shorter than the minimum must leave nothing behind."""
|
||||
tx = [V(146_520_000, "nfm", 0.5, 12_500, "blip",
|
||||
period_seconds=3.0, on_seconds=0.35)]
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=20.0,
|
||||
min_record_seconds=6.0, hang_seconds=0.5,
|
||||
threshold_db=12, dwell_seconds=0.05, max_cycles=4,
|
||||
revisit_seconds=0.1)
|
||||
all_hits = []
|
||||
s = Scanner(cfg, device=dev,
|
||||
callbacks=ScannerCallbacks(on_record_end=all_hits.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert all_hits, "the blip was never detected"
|
||||
assert not any(h.kept for h in all_hits)
|
||||
assert s.stats.discarded >= 1
|
||||
# Nothing should have been left behind on disk.
|
||||
assert not list(tmp_path.glob("*/*/audio.wav"))
|
||||
|
||||
|
||||
def test_invalid_config_is_rejected_before_touching_the_radio(tmp_path):
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path),
|
||||
record_seconds=5.0, min_record_seconds=9.0)
|
||||
s = Scanner(cfg, device=SimulatedDevice().open())
|
||||
with pytest.raises(ValueError, match="min_record_seconds"):
|
||||
s.prepare()
|
||||
|
||||
|
||||
def test_decodes_morse_from_a_cw_beacon(tmp_path):
|
||||
tx = [V(144_100_000, "cw", 0.35, 500, "beacon", wpm=18,
|
||||
message="VVV DE W1AW")]
|
||||
# Several sweeps, because a 50 ms dwell can easily land in a key-up gap.
|
||||
s, hits = run_scan(tmp_path, tx, "144.05M-144.15M",
|
||||
record_seconds=10.0, hang_seconds=4.0, max_cycles=8,
|
||||
revisit_seconds=0.1)
|
||||
assert hits
|
||||
h = hits[0]
|
||||
assert h.mode == "cw", "should have chosen the CW demodulator"
|
||||
assert "W1AW" in h.morse_text or "VVV" in h.morse_text, h.morse_text
|
||||
assert h.morse_wpm == pytest.approx(18, rel=0.15)
|
||||
|
||||
|
||||
def test_lockout_is_respected(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=2.5,
|
||||
threshold_db=12, dwell_seconds=0.05, max_cycles=2,
|
||||
lockout=[146_520_000.0])
|
||||
s = Scanner(cfg, device=dev)
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert s.stats.recordings == 0
|
||||
|
||||
|
||||
def test_writes_audio_iq_and_metadata(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M",
|
||||
record_seconds=2.5, save_iq=True, max_cycles=1)
|
||||
assert hits
|
||||
h = hits[0]
|
||||
with wave.open(h.audio_path) as w:
|
||||
assert w.getnframes() / w.getframerate() == pytest.approx(2.5, abs=0.2)
|
||||
assert w.getnchannels() == 1
|
||||
iq = np.fromfile(h.iq_path, dtype=np.complex64)
|
||||
assert iq.size > 0
|
||||
meta = json.loads(open(h.meta_path).read())
|
||||
assert meta["frequency_hz"] == pytest.approx(h.frequency)
|
||||
assert meta["hit"]["classification"]
|
||||
sigmf = json.loads(open(h.iq_path.replace(".cf32", ".sigmf-meta")).read())
|
||||
assert sigmf["global"]["core:datatype"] == "cf32_le"
|
||||
# the run log
|
||||
assert (tmp_path / "scan_log.jsonl").exists()
|
||||
lines = (tmp_path / "scan_log.csv").read_text().strip().split("\n")
|
||||
assert len(lines) >= 2
|
||||
|
||||
|
||||
def test_multiple_signals_in_one_step_all_get_visited(tmp_path):
|
||||
tx = [V(146_500_000, "nfm", 0.40, 12_500, "a"),
|
||||
V(146_800_000, "nfm", 0.30, 12_500, "b"),
|
||||
V(147_100_000, "carrier", 0.25, 1_000, "c")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-147.2M", record_seconds=2.5,
|
||||
max_cycles=2, accept=["voice", "cw", "digital", "carrier"])
|
||||
found = {round(h.frequency / 1e5) for h in hits}
|
||||
assert len(found) >= 3, f"only found {found}"
|
||||
|
||||
|
||||
def test_unreachable_frequencies_are_skipped_not_fatal(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
# 2.5 GHz is beyond the tuner; the scan must still run the reachable part.
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M, 2500M-2600M",
|
||||
record_seconds=2.5, max_cycles=1)
|
||||
assert hits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The content gate: only voice, CW and digital should reach the disk.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_static_is_never_recorded(tmp_path):
|
||||
"""An empty band must produce no files, whatever the squelch does."""
|
||||
dev = SimulatedDevice(transmitters=[], noise_amplitude=0.05).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146M-148M"),
|
||||
output_dir=str(tmp_path), record_seconds=4.0,
|
||||
hang_seconds=1.0, threshold_db=2.0, # deliberately wide open
|
||||
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.1)
|
||||
hits = []
|
||||
s = Scanner(cfg, device=dev,
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert not any(h.kept for h in hits), "static was recorded"
|
||||
assert not list(tmp_path.glob("*/*/audio.wav"))
|
||||
|
||||
|
||||
def test_bare_carrier_is_rejected_by_default(tmp_path):
|
||||
tx = [V(146_520_000, "carrier", 0.4, 1_000, "dead carrier")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
|
||||
assert not hits, "an unmodulated carrier is not a signal worth keeping"
|
||||
assert not list(tmp_path.glob("*/*/audio.wav"))
|
||||
|
||||
|
||||
def test_bare_carrier_kept_when_asked_for(tmp_path):
|
||||
tx = [V(146_520_000, "carrier", 0.4, 1_000, "dead carrier")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0,
|
||||
accept=["voice", "cw", "digital", "carrier"])
|
||||
assert hits and hits[0].category == "carrier"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,bw,category", [
|
||||
("nfm", 12_500, "voice"),
|
||||
("am", 8_000, "voice"),
|
||||
("fsk4", 12_500, "digital"),
|
||||
("fsk2", 12_500, "digital"),
|
||||
])
|
||||
def test_real_signals_are_categorised_and_kept(tmp_path, mode, bw, category):
|
||||
tx = [V(146_520_000, mode, 0.4, bw, "tx", baud=4800 if mode == "fsk4" else 1200,
|
||||
deviation=1800 if mode.startswith("fsk") else 2500)]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=4.0)
|
||||
assert hits, f"{mode} was rejected"
|
||||
assert hits[0].category == category, \
|
||||
f"{mode} -> {hits[0].category}: {hits[0].content_reason}"
|
||||
|
||||
|
||||
def test_keep_everything_disables_the_gate(tmp_path):
|
||||
"""--keep-everything restores plain power-threshold behaviour."""
|
||||
dev = SimulatedDevice(transmitters=[], noise_amplitude=0.05).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146M-148M"),
|
||||
output_dir=str(tmp_path), record_seconds=1.0,
|
||||
hang_seconds=0.5, threshold_db=1.0, min_record_seconds=0.2,
|
||||
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.1,
|
||||
require_signal=False, detector_bias_db=0.0)
|
||||
hits = []
|
||||
s = Scanner(cfg, device=dev,
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert s.stats.detections > 0, "threshold was not wide open enough"
|
||||
|
||||
|
||||
def test_filenames_are_flat_dated_and_named_for_the_modulation(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
|
||||
assert hits
|
||||
wavs = list(tmp_path.glob("*.wav"))
|
||||
assert wavs, "no wav written at the top level"
|
||||
assert not list(tmp_path.glob("*/*.wav")), "files should not be in subfolders"
|
||||
name = wavs[0].name
|
||||
assert re.match(r"^\d{4}\.\d{6}MHz--\d{4}-\d{2}-\d{2}_"
|
||||
r"\d{2}_\d{2}_\d{2}-[a-z0-9-]+\.wav$", name), name
|
||||
assert name.endswith("-nfm.wav"), name
|
||||
assert name.startswith("0146.5"), name
|
||||
# every artefact of one capture shares a stem
|
||||
stem = wavs[0].stem
|
||||
assert (tmp_path / f"{stem}.json").exists()
|
||||
|
||||
|
||||
def test_recorded_length_matches_the_requested_record_time(tmp_path):
|
||||
"""The WAV must hold as many seconds as the scanner says it captured.
|
||||
|
||||
A mismatch here is what makes a recording play at the wrong speed.
|
||||
"""
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
|
||||
assert hits
|
||||
h = hits[0]
|
||||
with wave.open(h.audio_path) as w:
|
||||
seconds = w.getnframes() / w.getframerate()
|
||||
assert seconds == pytest.approx(3.0, abs=0.15)
|
||||
assert seconds == pytest.approx(h.duration, abs=0.02)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two-way conversations: natural pauses must not end the capture.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _two_way(freq=146_520_000):
|
||||
"""Two stations alternating on one frequency with a 1.5 s gap between overs.
|
||||
|
||||
A is up for [0, 2.5), B for [4, 6.5), repeating every 8 s.
|
||||
"""
|
||||
return [
|
||||
V(freq, "nfm", 0.40, 12_500, "station A",
|
||||
period_seconds=8.0, on_seconds=2.5, phase_offset=0.0),
|
||||
V(freq, "nfm", 0.35, 12_500, "station B", pitch_hz=190.0,
|
||||
period_seconds=8.0, on_seconds=2.5, phase_offset=4.0),
|
||||
]
|
||||
|
||||
|
||||
def test_a_pause_between_overs_does_not_end_the_capture(tmp_path):
|
||||
"""With hang longer than the gap, both overs land in one recording."""
|
||||
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
|
||||
record_seconds=0.0, hang_seconds=3.0,
|
||||
max_record_seconds=14.0, max_cycles=1)
|
||||
assert hits, "nothing recorded"
|
||||
h = hits[0]
|
||||
# Must span the 1.5 s gap and reach the second station's over.
|
||||
assert h.duration > 6.0, \
|
||||
f"capture ended after {h.duration:.1f}s, so it cut at the pause"
|
||||
assert len(list(tmp_path.glob("*.wav"))) == 1, \
|
||||
"the exchange was split into separate files"
|
||||
|
||||
|
||||
def test_a_short_hang_stops_at_the_first_pause(tmp_path):
|
||||
"""With hang shorter than the gap, the capture ends when the over does."""
|
||||
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
|
||||
record_seconds=0.0, hang_seconds=0.8,
|
||||
max_record_seconds=14.0, max_cycles=1)
|
||||
assert hits
|
||||
h = hits[0]
|
||||
assert h.duration < 5.0, f"ran {h.duration:.1f}s past a {0.8}s hang"
|
||||
assert "quiet" in h.stop_reason or "no signal" in h.stop_reason
|
||||
|
||||
|
||||
def test_an_established_conversation_is_never_abandoned_as_noise(tmp_path):
|
||||
"""Once speech is heard, a quiet spell must not trigger the content abort."""
|
||||
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
|
||||
record_seconds=0.0, hang_seconds=3.0,
|
||||
verify_max_seconds=2.0, # would fire during the gap
|
||||
max_record_seconds=14.0, max_cycles=1)
|
||||
assert hits, "an established conversation was thrown away"
|
||||
assert hits[0].category == "voice"
|
||||
|
||||
|
||||
def test_max_record_seconds_bounds_an_unlimited_capture(tmp_path):
|
||||
"""--record 0 must still not run forever."""
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
|
||||
hang_seconds=5.0, max_record_seconds=5.0, max_cycles=1)
|
||||
assert hits
|
||||
assert hits[0].duration == pytest.approx(5.0, abs=0.3)
|
||||
assert "safety limit" in hits[0].stop_reason
|
||||
|
||||
|
||||
def test_interference_during_a_gap_does_not_hold_the_capture_open(tmp_path):
|
||||
"""Static breaking squelch mid-pause must still count as quiet.
|
||||
|
||||
Otherwise a burst of noise after the conversation ends keeps the receiver
|
||||
parked on a dead channel indefinitely.
|
||||
"""
|
||||
tx = [V(146_520_000, "nfm", 0.40, 12_500, "voice",
|
||||
period_seconds=40.0, on_seconds=3.0, phase_offset=0.0),
|
||||
V(146_520_000, "carrier", 0.35, 1_000, "interference",
|
||||
period_seconds=40.0, on_seconds=8.0, phase_offset=36.0)]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
|
||||
hang_seconds=2.0, max_record_seconds=20.0, max_cycles=1)
|
||||
assert hits
|
||||
h = hits[0]
|
||||
# The carrier holds squelch open until 12 s; the capture must not wait it
|
||||
# out. Some lag is unavoidable -- deciding a signal carries nothing takes
|
||||
# a couple of seconds of evidence -- but it must be well short of that.
|
||||
assert h.duration < 10.0, f"ran {h.duration:.1f}s on an empty carrier"
|
||||
assert h.duration > 3.0, "cut the real speech short"
|
||||
assert "silence, static or interference" in h.stop_reason
|
||||
|
||||
|
||||
def test_being_cut_off_mid_transmission_is_reported(tmp_path):
|
||||
"""The record limit silently truncating a live signal must be visible.
|
||||
|
||||
Otherwise the file just ends and there is nothing to say the hang time was
|
||||
never the thing that stopped it.
|
||||
"""
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
|
||||
notes = []
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=4.0,
|
||||
hang_seconds=3.0, threshold_db=12, dwell_seconds=0.05,
|
||||
max_cycles=1, revisit_seconds=0.2)
|
||||
hits = []
|
||||
s = Scanner(cfg, device=dev,
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append,
|
||||
on_status=notes.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert hits and "record limit" in hits[0].stop_reason
|
||||
assert s.stats.truncated == 1
|
||||
assert any("--record 0" in n for n in notes), notes
|
||||
|
||||
|
||||
def test_no_warning_when_the_signal_really_ended(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst",
|
||||
period_seconds=12.0, on_seconds=2.0)]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=20.0,
|
||||
hang_seconds=1.0, max_cycles=3, revisit_seconds=0.1)
|
||||
assert s.stats.truncated == 0
|
||||
|
||||
|
||||
def test_record_zero_follows_a_transmission_past_the_old_limit(tmp_path):
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
|
||||
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
|
||||
hang_seconds=3.0, max_record_seconds=9.0, max_cycles=1)
|
||||
assert hits
|
||||
assert hits[0].duration > 8.0, "stopped early despite no record limit"
|
||||
assert s.stats.truncated == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One file per frequency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _two_channels():
|
||||
return [V(146_520_000, "nfm", 0.40, 12_500, "A",
|
||||
period_seconds=6.0, on_seconds=2.0, phase_offset=0.0),
|
||||
V(147_100_000, "nfm", 0.35, 12_500, "B", pitch_hz=190.0,
|
||||
period_seconds=6.0, on_seconds=2.0, phase_offset=3.0)]
|
||||
|
||||
|
||||
def test_combining_gives_one_file_per_frequency(tmp_path):
|
||||
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
|
||||
record_seconds=3.0, hang_seconds=1.0, max_cycles=4,
|
||||
revisit_seconds=0.1, combine_by_frequency=True)
|
||||
assert len(hits) >= 3, "not enough transmissions to test combining"
|
||||
wavs = sorted(p.name for p in tmp_path.glob("*.wav"))
|
||||
assert len(wavs) == 2, wavs
|
||||
assert all(name.endswith("MHz.wav") for name in wavs), wavs
|
||||
# every capture went into one of them
|
||||
assert all(h.combined_path for h in hits)
|
||||
|
||||
|
||||
def test_combining_removes_the_separate_files_by_default(tmp_path):
|
||||
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
|
||||
record_seconds=3.0, max_cycles=3, revisit_seconds=0.1,
|
||||
combine_by_frequency=True)
|
||||
assert hits
|
||||
dated = [p for p in tmp_path.glob("*.wav") if "--" in p.name]
|
||||
assert not dated, f"per-transmission files left behind: {dated}"
|
||||
# the metadata for each capture is still there
|
||||
assert len(list(tmp_path.glob("*.json"))) >= len(hits)
|
||||
|
||||
|
||||
def test_combining_can_keep_the_separate_files_too(tmp_path):
|
||||
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
|
||||
record_seconds=3.0, max_cycles=3, revisit_seconds=0.1,
|
||||
combine_by_frequency=True, combine_keep_individual=True)
|
||||
assert hits
|
||||
dated = [p for p in tmp_path.glob("*.wav") if "--" in p.name]
|
||||
assert len(dated) == len(hits)
|
||||
|
||||
|
||||
def test_a_combined_file_grows_with_each_reception(tmp_path):
|
||||
from bandsaunter.recorder import read_wav
|
||||
# Continuously active, so each sweep produces another capture and the
|
||||
# combined file has to grow.
|
||||
tx = [V(146_520_000, "nfm", 0.4, 12_500, "A")]
|
||||
sizes = []
|
||||
|
||||
def note(hit):
|
||||
if hit.kept and hit.combined_path:
|
||||
sizes.append(read_wav(Path(hit.combined_path))[0].size)
|
||||
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=2.0,
|
||||
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
|
||||
max_cycles=3, revisit_seconds=0.05,
|
||||
combine_by_frequency=True, announce_timestamps=False)
|
||||
s = Scanner(cfg, device=dev, callbacks=ScannerCallbacks(on_record_end=note))
|
||||
s.prepare()
|
||||
s.run()
|
||||
assert len(sizes) >= 2, sizes
|
||||
assert sizes == sorted(sizes) and sizes[-1] > sizes[0], sizes
|
||||
|
||||
|
||||
def test_combining_off_keeps_the_old_layout(tmp_path):
|
||||
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
|
||||
record_seconds=3.0, max_cycles=2, revisit_seconds=0.1)
|
||||
assert hits
|
||||
assert not [p for p in tmp_path.glob("*.wav") if p.name.endswith("MHz.wav")]
|
||||
assert all(not h.combined_path for h in hits)
|
||||
|
||||
|
||||
def test_a_complete_band_demodulates_each_segment_correctly(tmp_path):
|
||||
"""One sweep of 2 m must handle its CW, SSB and FM segments each properly.
|
||||
|
||||
That is the whole point of a complete-band entry: the band is not one
|
||||
mode, so scanning it as one would demodulate two thirds of it wrongly.
|
||||
"""
|
||||
tx = [V(144_050_000, "cw", 0.35, 500, "beacon", wpm=18,
|
||||
message="VVV DE W1AW"),
|
||||
V(144_200_000, "usb", 0.35, 3_000, "ssb"),
|
||||
V(146_520_000, "nfm", 0.40, 12_500, "fm", ctcss=100.0)]
|
||||
s, hits = run_scan(tmp_path, tx, "2m-complete", record_seconds=6.0,
|
||||
hang_seconds=2.0, max_cycles=2, revisit_seconds=0.2)
|
||||
def segment(freq):
|
||||
if freq < 144.1e6:
|
||||
return "cw"
|
||||
return "ssb" if freq < 144.3e6 else "fm"
|
||||
|
||||
seen = {segment(h.frequency): (h.mode, h.category) for h in hits}
|
||||
assert seen.get("cw") == ("cw", "cw"), seen
|
||||
assert seen.get("ssb") == ("usb", "voice"), seen
|
||||
assert seen.get("fm") == ("nfm", "voice"), seen
|
||||
|
||||
|
||||
def test_frequencies_are_padded_so_names_sort_by_frequency(tmp_path):
|
||||
"""Unpadded, a directory listing puts 1090 MHz before 146 MHz."""
|
||||
from bandsaunter.recorder import build_stem, safe_freq_name
|
||||
import datetime
|
||||
assert safe_freq_name(146_520_000) == "0146.520000MHz"
|
||||
assert safe_freq_name(1_090_000_000) == "1090.000000MHz"
|
||||
assert safe_freq_name(1_800_000) == "0001.800000MHz"
|
||||
|
||||
when = datetime.datetime(2026, 8, 21, 19, 35, 48).timestamp()
|
||||
names = [build_stem(when, hz, "nfm") for hz in
|
||||
(1_090_000_000, 146_520_000, 98_295_546, 1_800_000, 14_058_000)]
|
||||
# sorted as text must equal sorted by frequency
|
||||
by_text = sorted(names)
|
||||
by_freq = [build_stem(when, hz, "nfm") for hz in
|
||||
sorted((1_090_000_000, 146_520_000, 98_295_546, 1_800_000,
|
||||
14_058_000))]
|
||||
assert by_text == by_freq
|
||||
|
||||
|
||||
def test_combined_files_are_padded_too(tmp_path):
|
||||
from bandsaunter.recorder import FrequencyLog
|
||||
log = FrequencyLog(tmp_path, announce=False)
|
||||
log.add(1_090_000_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||
log.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||
names = sorted(p.name for p in tmp_path.glob("*.wav"))
|
||||
assert names == ["0146.520000MHz.wav", "1090.000000MHz.wav"]
|
||||
288
tests/test_settings.py
Normal file
288
tests/test_settings.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""The settings table, and the two front ends generated from it."""
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from bandsaunter import settings as st
|
||||
from bandsaunter.cli import build_parser
|
||||
from bandsaunter.config import ScanConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parity: every setting reachable from both the command line and the menu.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_config_field_has_a_setting():
|
||||
"""A field with no entry would be invisible in the in-app menu."""
|
||||
missing, extra = st.coverage()
|
||||
assert not missing, f"config fields with no setting: {missing}"
|
||||
assert not extra, f"settings with no config field: {extra}"
|
||||
|
||||
|
||||
def test_every_setting_has_a_command_line_flag():
|
||||
"""Anything settable in the app must also be settable as an argument."""
|
||||
without = [s.key for s in st.SETTINGS if not s.flags and not s.off_flags]
|
||||
assert not without, f"settings with no flag: {without}"
|
||||
|
||||
|
||||
def test_every_flag_reaches_the_parser():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["scan"])
|
||||
for s in st.SETTINGS:
|
||||
assert hasattr(args, s.dest), f"{s.key} never reached argparse"
|
||||
assert getattr(args, s.dest) is None, \
|
||||
f"{s.key} defaults to a value, so it would override saved settings"
|
||||
|
||||
|
||||
def test_flags_are_unique():
|
||||
seen = {}
|
||||
for s in st.SETTINGS:
|
||||
for flag in s.flags + s.off_flags:
|
||||
assert flag not in seen, f"{flag} used by {seen.get(flag)} and {s.key}"
|
||||
seen[flag] = s.key
|
||||
|
||||
|
||||
def test_every_setting_is_documented():
|
||||
for s in st.SETTINGS:
|
||||
assert s.help, s.key
|
||||
assert not s.help.endswith("."), f"{s.key} help should not end in a full stop"
|
||||
assert s.label, s.key
|
||||
assert s.group in st.GROUPS, s.key
|
||||
if s.kind in ("choice", "accept_list"):
|
||||
assert s.choices, f"{s.key} is a choice with no choices"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_defaults_round_trip_through_parse_and_format():
|
||||
"""Whatever the menu shows must be something the menu accepts back."""
|
||||
cfg = ScanConfig()
|
||||
for s in st.SETTINGS:
|
||||
value = getattr(cfg, s.key)
|
||||
shown = st.format_value(s, value)
|
||||
if s.key in ("record_seconds", "max_record_seconds",
|
||||
"max_runtime_seconds", "max_cycles") and not value:
|
||||
continue # shown as "no limit", typed back as 0
|
||||
if s.kind == "freq_list" and not value:
|
||||
continue # shown as "(none)"
|
||||
back = st.parse_value(s, shown)
|
||||
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,text,expected", [
|
||||
("hang_seconds", "5", 5.0),
|
||||
("record_seconds", "0", 0.0),
|
||||
("gain", "auto", "auto"),
|
||||
("gain", "28.0", 28.0),
|
||||
("direct_sampling", "auto", "auto"),
|
||||
("direct_sampling", "2", 2),
|
||||
("detector", "PEAK", "peak"),
|
||||
("save_iq", "yes", True),
|
||||
("save_iq", "n", False),
|
||||
("accept", "voice, cw", ["voice", "cw"]),
|
||||
("accept", "voice cw digital", ["voice", "cw", "digital"]),
|
||||
("lockout", "162.55M, 146.52M", [162_550_000.0, 146_520_000.0]),
|
||||
("detector_bias_db", "", None),
|
||||
("detector_bias_db", "6", 6.0),
|
||||
("sample_rate", "2048000", 2_048_000),
|
||||
("threshold_db", "12", 12.0),
|
||||
])
|
||||
def test_parse(key, text, expected):
|
||||
assert st.parse_value(st.by_key(key), text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,text", [
|
||||
("hang_seconds", "-1"), # below the minimum
|
||||
("hang_seconds", "banana"),
|
||||
("detector", "sideways"),
|
||||
("accept", "voice,banana"),
|
||||
("min_voice_score", "1.5"), # above the maximum
|
||||
("usable_fraction", "0.01"),
|
||||
("save_iq", "maybe"),
|
||||
("lockout", "not-a-frequency"),
|
||||
("output_dir", ""),
|
||||
])
|
||||
def test_bad_values_are_refused_with_a_reason(key, text):
|
||||
with pytest.raises(st.SettingError) as exc:
|
||||
st.parse_value(st.by_key(key), text)
|
||||
assert str(exc.value), "an error with no explanation is no use"
|
||||
|
||||
|
||||
def test_search_finds_settings_by_words_from_anywhere():
|
||||
assert [s.key for s in st.search("voice score")] == ["min_voice_score"]
|
||||
assert st.search("hang")[0].key == "hang_seconds"
|
||||
assert st.search("--hang")[0].key == "hang_seconds"
|
||||
assert st.search("")== []
|
||||
assert st.search("zzzznothing") == []
|
||||
|
||||
|
||||
def test_name_matches_rank_above_description_matches():
|
||||
hits = [s.key for s in st.search("record")]
|
||||
assert hits.index("record_seconds") < hits.index("audio_rate")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Applying the command line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_apply_args_sets_only_what_was_given():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["scan", "--hang", "6", "--record", "0"])
|
||||
cfg = ScanConfig()
|
||||
changed = st.apply_args(cfg, args)
|
||||
assert cfg.hang_seconds == 6.0
|
||||
assert cfg.record_seconds == 0.0
|
||||
assert set(changed) == {"hang_seconds", "record_seconds"}
|
||||
assert cfg.threshold_db == ScanConfig().threshold_db
|
||||
|
||||
|
||||
def test_boolean_flags_work_both_ways():
|
||||
parser = build_parser()
|
||||
cfg = ScanConfig()
|
||||
st.apply_args(cfg, parser.parse_args(["scan", "--no-audio", "--iq"]))
|
||||
assert cfg.save_audio is False and cfg.save_iq is True
|
||||
cfg = ScanConfig()
|
||||
st.apply_args(cfg, parser.parse_args(["scan", "--keep-everything"]))
|
||||
assert cfg.require_signal is False
|
||||
|
||||
|
||||
def test_repeated_lockout_flags_accumulate():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["scan", "--lockout", "162.55M",
|
||||
"--lockout", "146.52M"])
|
||||
cfg = ScanConfig()
|
||||
st.apply_args(cfg, args)
|
||||
assert cfg.lockout == [162_550_000.0, 146_520_000.0]
|
||||
|
||||
|
||||
def test_help_text_renders_for_every_entry():
|
||||
"""--help must format without an error from any entry in the table."""
|
||||
top = build_parser().format_help()
|
||||
assert "scan" in top and "config" in top
|
||||
scan = build_parser().parse_known_args(["scan"])
|
||||
assert scan is not None
|
||||
# The scan sub-parser has to render too, since that is where the table goes.
|
||||
sub = [a for a in build_parser()._actions
|
||||
if isinstance(a, argparse._SubParsersAction)][0]
|
||||
text = sub.choices["scan"].format_help()
|
||||
for setting in st.SETTINGS:
|
||||
for flag in setting.flags + setting.off_flags:
|
||||
assert flag in text, f"{flag} missing from --help"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The saved settings file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_saved_settings_round_trip(tmp_path):
|
||||
from bandsaunter.config import load_default, save_default
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
cfg = ScanConfig(ranges=parse_range_list("144M-148M, gmrs"))
|
||||
cfg.hang_seconds = 6.0
|
||||
cfg.record_seconds = 0.0
|
||||
cfg.accept = ["voice", "cw"]
|
||||
cfg.lockout = [162_550_000.0]
|
||||
cfg.gain = 28.0
|
||||
save_default(cfg, tmp_path)
|
||||
|
||||
back, path = load_default(tmp_path)
|
||||
assert path == tmp_path / "config.yaml"
|
||||
assert back.hang_seconds == 6.0
|
||||
assert back.record_seconds == 0.0
|
||||
assert back.accept == ["voice", "cw"]
|
||||
assert back.lockout == [162_550_000.0]
|
||||
assert back.gain == 28.0
|
||||
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
|
||||
|
||||
|
||||
def test_every_setting_survives_a_save_and_load(tmp_path):
|
||||
"""No setting may be lost or mangled by the config file."""
|
||||
from bandsaunter.config import load_default, save_default
|
||||
cfg = ScanConfig()
|
||||
marks = {}
|
||||
for s in st.SETTINGS:
|
||||
current = getattr(cfg, s.key)
|
||||
if s.kind == "bool":
|
||||
value = not current
|
||||
elif s.kind == "choice":
|
||||
value = [c for c in s.choices if c != current][0]
|
||||
elif s.kind == "accept_list":
|
||||
value = ["cw"]
|
||||
elif s.kind == "freq_list":
|
||||
value = [146_520_000.0]
|
||||
elif s.kind in ("text", "path"):
|
||||
value = "somewhere"
|
||||
elif s.kind == "gain":
|
||||
value = 22.9
|
||||
elif s.kind == "direct":
|
||||
value = 2
|
||||
elif s.kind == "opt_float":
|
||||
value = 7.0
|
||||
elif s.kind == "int":
|
||||
value = int(max(s.minimum or 1, 3))
|
||||
else:
|
||||
lo = s.minimum if s.minimum is not None else 1.0
|
||||
hi = s.maximum if s.maximum is not None else lo + 5.0
|
||||
value = min(hi, lo + 0.5)
|
||||
setattr(cfg, s.key, value)
|
||||
marks[s.key] = value
|
||||
save_default(cfg, tmp_path)
|
||||
back, _ = load_default(tmp_path)
|
||||
for key, value in marks.items():
|
||||
assert getattr(back, key) == value, key
|
||||
|
||||
|
||||
def test_a_corrupt_config_file_does_not_crash(tmp_path):
|
||||
from bandsaunter.config import load_default
|
||||
(tmp_path / "config.yaml").write_text("this: [is: not: valid: yaml")
|
||||
cfg, path = load_default(tmp_path)
|
||||
assert cfg.hang_seconds == ScanConfig().hang_seconds
|
||||
assert path is None
|
||||
|
||||
|
||||
def test_unknown_keys_in_a_config_file_are_ignored(tmp_path):
|
||||
"""An old file from a newer or older version must still load."""
|
||||
from bandsaunter.config import load_default
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"hang_seconds: 9\nsomething_removed: 5\n")
|
||||
cfg, _ = load_default(tmp_path)
|
||||
assert cfg.hang_seconds == 9.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layering: saved settings, then a profile, then flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_flags_override_saved_settings(tmp_path, monkeypatch):
|
||||
from bandsaunter import cli
|
||||
from bandsaunter.config import save_default
|
||||
saved = ScanConfig()
|
||||
saved.hang_seconds = 6.0
|
||||
saved.threshold_db = 12.0
|
||||
save_default(saved, tmp_path)
|
||||
monkeypatch.setattr("bandsaunter.cli.load_default",
|
||||
lambda: __import__("bandsaunter.config", fromlist=["x"])
|
||||
.load_default(tmp_path))
|
||||
|
||||
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--hang", "1.5"])
|
||||
cfg, source = cli._build_config(args)
|
||||
assert cfg.hang_seconds == 1.5, "the flag must win"
|
||||
assert cfg.threshold_db == 12.0, "the saved value must survive"
|
||||
assert source is not None
|
||||
|
||||
|
||||
def test_no_config_ignores_the_saved_file(tmp_path, monkeypatch):
|
||||
from bandsaunter import cli
|
||||
from bandsaunter.config import save_default
|
||||
saved = ScanConfig()
|
||||
saved.hang_seconds = 6.0
|
||||
save_default(saved, tmp_path)
|
||||
monkeypatch.setattr("bandsaunter.cli.load_default",
|
||||
lambda: __import__("bandsaunter.config", fromlist=["x"])
|
||||
.load_default(tmp_path))
|
||||
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--no-config"])
|
||||
cfg, source = cli._build_config(args)
|
||||
assert cfg.hang_seconds == ScanConfig().hang_seconds
|
||||
assert source is None
|
||||
378
tests/test_transcribe.py
Normal file
378
tests/test_transcribe.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
"""Speech to text: the engine plumbing, and how captures reach it."""
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import transcribe as tr
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.scanner import Scanner, ScannerCallbacks
|
||||
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engine(monkeypatch):
|
||||
"""A recogniser that reports what it was given, so the plumbing is testable
|
||||
on a machine with none installed."""
|
||||
seen = []
|
||||
|
||||
def engine(audio, rate, model, language):
|
||||
seen.append({"samples": audio.size, "rate": rate, "model": model,
|
||||
"language": language})
|
||||
return tr.Transcript(text="this is the transcribed text",
|
||||
engine="fake", language=language or "en")
|
||||
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake", engine)
|
||||
monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES)
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
return seen
|
||||
|
||||
|
||||
def _speech(seconds=3.0, rate=16000):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from speech import synth_speech
|
||||
return synth_speech(seconds, rate, 120, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_engine_reports_rather_than_failing(monkeypatch):
|
||||
"""Every capture failing noisily would be worse than saying so once."""
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: False)
|
||||
assert tr.available_engine() is None
|
||||
assert tr.transcribe(np.zeros(1000, np.float32), 16000) is None
|
||||
|
||||
|
||||
def test_engine_listing_is_complete():
|
||||
listed = {name for name, _, _ in tr.describe_engines()}
|
||||
assert listed == set(tr.ENGINES)
|
||||
for _, _, how in tr.describe_engines():
|
||||
assert how, "every engine should say how to get it"
|
||||
|
||||
|
||||
def test_a_failing_engine_is_reported_not_raised(monkeypatch):
|
||||
def boom(audio, rate, model, language):
|
||||
raise RuntimeError("model file is corrupt")
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake", boom)
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
result = tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake")
|
||||
assert result is not None and not result
|
||||
assert "corrupt" in result.note
|
||||
|
||||
|
||||
def test_audio_is_resampled_to_what_the_engines_expect(fake_engine):
|
||||
for rate in (8000, 16000, 32000, 48000):
|
||||
tr.transcribe(np.zeros(int(rate * 2), np.float32), rate, engine="fake")
|
||||
assert [s["samples"] for s in fake_engine] == [32000] * 4
|
||||
|
||||
|
||||
def test_the_model_and_language_reach_the_engine(fake_engine):
|
||||
tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake",
|
||||
model="small.en", language="fr")
|
||||
assert fake_engine[-1]["model"] == "small.en"
|
||||
assert fake_engine[-1]["language"] == "fr"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _drain(worker, timeout=20.0):
|
||||
worker.close(timeout=timeout)
|
||||
|
||||
|
||||
def test_worker_writes_a_transcript_beside_the_recording(tmp_path, fake_engine):
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
out = tmp_path / "0146.520000MHz--2026-08-21_12_00_00-nfm_transcription.txt"
|
||||
worker.submit(_speech(), 16000, out, datetime(2026, 8, 21, 12, 0, 0),
|
||||
146.52e6)
|
||||
_drain(worker)
|
||||
assert out.exists()
|
||||
assert "transcribed text" in out.read_text()
|
||||
assert worker.written == 1
|
||||
|
||||
|
||||
def test_worker_appends_with_a_timestamp_when_combining(tmp_path, fake_engine):
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
out = tmp_path / "0146.520000MHz_transcription.txt"
|
||||
for minute in (0, 5, 9):
|
||||
worker.submit(_speech(), 16000, out,
|
||||
datetime(2026, 8, 21, 12, minute, 0), 146.52e6,
|
||||
append=True)
|
||||
_drain(worker)
|
||||
lines = out.read_text().strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0].startswith("[2026-08-21 12:00:00] ")
|
||||
assert lines[2].startswith("[2026-08-21 12:09:00] ")
|
||||
|
||||
|
||||
def test_nothing_recognised_writes_no_file_at_all(tmp_path, monkeypatch):
|
||||
"""A directory of placeholder files is worse than no file."""
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake",
|
||||
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
out = tmp_path / "quiet_transcription.txt"
|
||||
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
|
||||
_drain(worker)
|
||||
assert not out.exists()
|
||||
assert not list(tmp_path.iterdir())
|
||||
assert worker.empty == 1 and worker.written == 0
|
||||
|
||||
|
||||
def test_whitespace_only_speech_writes_no_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake",
|
||||
lambda a, r, m, l: tr.Transcript(text=" \n ",
|
||||
engine="fake"))
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
out = tmp_path / "blank_transcription.txt"
|
||||
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
|
||||
_drain(worker)
|
||||
assert not out.exists() and worker.empty == 1
|
||||
|
||||
|
||||
def test_an_empty_result_adds_no_line_when_combining(tmp_path, monkeypatch):
|
||||
"""Combined transcripts must not fill up with empty timestamps."""
|
||||
texts = iter(["something was said", "", "and something else"])
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake",
|
||||
lambda a, r, m, l: tr.Transcript(text=next(texts),
|
||||
engine="fake"))
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
out = tmp_path / "0146.520000MHz_transcription.txt"
|
||||
for minute in (0, 5, 9):
|
||||
worker.submit(np.zeros(16000, np.float32), 16000, out,
|
||||
datetime(2026, 8, 21, 12, minute, 0), 1e6, append=True)
|
||||
_drain(worker)
|
||||
lines = out.read_text().strip().split("\n")
|
||||
assert len(lines) == 2, lines
|
||||
assert "12:00:00" in lines[0] and "12:09:00" in lines[1]
|
||||
|
||||
|
||||
def test_worker_does_not_hold_up_the_caller(tmp_path, monkeypatch):
|
||||
"""Recognition takes seconds; a scan must not wait for it."""
|
||||
def slow(audio, rate, model, language):
|
||||
time.sleep(1.0)
|
||||
return tr.Transcript(text="eventually", engine="fake")
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
started = time.perf_counter()
|
||||
for i in range(3):
|
||||
worker.submit(_speech(), 16000, tmp_path / f"{i}.txt", datetime.now(),
|
||||
1e6)
|
||||
assert time.perf_counter() - started < 0.5, "submitting blocked"
|
||||
_drain(worker)
|
||||
assert worker.written == 3
|
||||
|
||||
|
||||
def test_a_full_queue_is_counted_not_blocked(tmp_path, monkeypatch):
|
||||
def slow(audio, rate, model, language):
|
||||
time.sleep(0.4)
|
||||
return tr.Transcript(text="x", engine="fake")
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
worker = tr.TranscriptionWorker(engine="fake", max_queue=2)
|
||||
worker.start()
|
||||
accepted = sum(worker.submit(_speech(0.5), 16000, tmp_path / f"{i}.txt",
|
||||
datetime.now(), 1e6) for i in range(12))
|
||||
assert accepted < 12 and worker.dropped > 0
|
||||
_drain(worker)
|
||||
|
||||
|
||||
def test_empty_audio_is_not_submitted(tmp_path, fake_engine):
|
||||
worker = tr.TranscriptionWorker(engine="fake")
|
||||
worker.start()
|
||||
assert not worker.submit(np.zeros(0, np.float32), 16000,
|
||||
tmp_path / "x.txt", datetime.now(), 1e6)
|
||||
_drain(worker)
|
||||
assert worker.written == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Through a scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scan(tmp_path, transmitters, **over):
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=4.0,
|
||||
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
|
||||
max_cycles=1, revisit_seconds=0.2, transcribe=True,
|
||||
transcribe_engine="fake")
|
||||
for k, v in over.items():
|
||||
setattr(cfg, k, v)
|
||||
hits = []
|
||||
scanner = Scanner(cfg, device=SimulatedDevice(transmitters=transmitters).open(),
|
||||
callbacks=ScannerCallbacks(on_record_end=hits.append))
|
||||
scanner.prepare()
|
||||
scanner.run()
|
||||
return scanner, [h for h in hits if h.kept]
|
||||
|
||||
|
||||
def test_a_voice_capture_is_transcribed(tmp_path, fake_engine, monkeypatch):
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
||||
assert hits and hits[0].category == "voice"
|
||||
written = list(tmp_path.glob("*_transcription.txt"))
|
||||
assert written, "no transcript written"
|
||||
assert written[0].stem.startswith(hits[0].filename)
|
||||
assert "transcribed text" in written[0].read_text()
|
||||
|
||||
|
||||
def test_morse_and_data_are_not_transcribed(tmp_path, fake_engine, monkeypatch):
|
||||
"""Running a recogniser over CW or a data burst wastes seconds per capture."""
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [
|
||||
V(146_520_000, "fsk4", 0.4, 12_500, "data", baud=4800, deviation=1800)])
|
||||
assert hits and hits[0].category == "digital"
|
||||
assert not list(tmp_path.glob("*_transcription.txt"))
|
||||
|
||||
|
||||
def test_short_captures_are_skipped(tmp_path, fake_engine, monkeypatch):
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
||||
transcribe_min_seconds=60.0)
|
||||
assert hits
|
||||
assert not list(tmp_path.glob("*_transcription.txt"))
|
||||
|
||||
|
||||
def test_the_transcript_is_recorded_in_the_metadata(tmp_path, fake_engine,
|
||||
monkeypatch):
|
||||
import json
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
||||
meta = json.loads(Path(hits[0].meta_path).read_text())
|
||||
assert meta["hit"]["transcript_path"].endswith("_transcription.txt")
|
||||
assert "transcribed text" in meta["hit"]["transcript"]
|
||||
|
||||
|
||||
def test_the_metadata_never_names_a_transcript_that_was_not_written(
|
||||
tmp_path, monkeypatch):
|
||||
"""Recording a path for a file that never appears would be a lie."""
|
||||
import json
|
||||
monkeypatch.setitem(tr._DISPATCH, "fake",
|
||||
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
|
||||
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
||||
assert hits
|
||||
assert not list(tmp_path.glob("*_transcription.txt"))
|
||||
meta = json.loads(Path(hits[0].meta_path).read_text())
|
||||
assert not meta["hit"].get("transcript_path")
|
||||
|
||||
|
||||
def test_combined_recordings_get_one_transcript_per_frequency(tmp_path,
|
||||
fake_engine,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(
|
||||
tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
||||
combine_by_frequency=True, announce_timestamps=False,
|
||||
record_seconds=2.0, max_cycles=3, revisit_seconds=0.05)
|
||||
assert len(hits) >= 2
|
||||
written = list(tmp_path.glob("*_transcription.txt"))
|
||||
assert len(written) == 1, written
|
||||
lines = written[0].read_text().strip().split("\n")
|
||||
assert len(lines) == len(hits)
|
||||
assert all(line.startswith("[") for line in lines)
|
||||
|
||||
|
||||
def test_transcription_off_writes_nothing(tmp_path, fake_engine, monkeypatch):
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
||||
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
||||
transcribe=False)
|
||||
assert hits
|
||||
assert not list(tmp_path.glob("*_transcription.txt"))
|
||||
|
||||
|
||||
def test_missing_engine_says_so_once(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: None)
|
||||
notes = []
|
||||
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||
output_dir=str(tmp_path), record_seconds=2.0,
|
||||
threshold_db=12, max_cycles=1, transcribe=True)
|
||||
scanner = Scanner(cfg,
|
||||
device=SimulatedDevice(transmitters=[
|
||||
V(146_520_000, "nfm", 0.4, 12_500, "v")]).open(),
|
||||
callbacks=ScannerCallbacks(on_status=notes.append))
|
||||
scanner.prepare()
|
||||
scanner.run()
|
||||
assert any("no speech recogniser" in n for n in notes), notes
|
||||
assert scanner.transcriber is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installed engines, when there are any
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INSTALLED = tr.available_engine()
|
||||
needs_engine = pytest.mark.skipif(INSTALLED is None,
|
||||
reason="no speech recogniser installed")
|
||||
|
||||
|
||||
@needs_engine
|
||||
def test_an_installed_engine_recognises_synthesised_speech():
|
||||
"""Round trip: say something, then read it back off the audio."""
|
||||
from bandsaunter.announce import say
|
||||
audio = say("one two three four five six seven eight nine", 16000)
|
||||
result = tr.transcribe(audio, 16000, engine="auto",
|
||||
model="base.en", language="en")
|
||||
assert result is not None
|
||||
assert not result.note, result.note
|
||||
# Engines are free to write numbers as digits, and whisper does.
|
||||
text = result.text.lower()
|
||||
spelled = ("one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine")
|
||||
hits = sum((word in text) or (str(i) in text)
|
||||
for i, word in enumerate(spelled, 1))
|
||||
assert hits >= 4, f"only {hits} of nine numbers recognised: {result.text!r}"
|
||||
|
||||
|
||||
@needs_engine
|
||||
@pytest.mark.parametrize("rate", [8000, 16000, 32000])
|
||||
def test_an_installed_engine_copes_with_any_rate(rate):
|
||||
from bandsaunter.announce import say
|
||||
audio = say("testing one two three", rate)
|
||||
result = tr.transcribe(audio, rate, engine="auto", model="base.en",
|
||||
language="en")
|
||||
assert result is not None and not result.note, result.note
|
||||
|
||||
|
||||
@needs_engine
|
||||
def test_silence_produces_no_transcript_rather_than_invention():
|
||||
result = tr.transcribe(np.zeros(16000 * 3, np.float32), 16000,
|
||||
engine="auto", model="base.en", language="en")
|
||||
assert result is not None
|
||||
assert not result.text.strip(), f"invented {result.text!r} from silence"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
|
||||
def test_vosk_falls_back_when_given_a_whisper_model_name():
|
||||
"""The model setting is shared with whisper, whose names are not paths."""
|
||||
from bandsaunter.announce import say
|
||||
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
|
||||
model="base.en", language="en")
|
||||
assert result is not None
|
||||
assert not result.note, result.note
|
||||
|
||||
|
||||
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
|
||||
def test_vosk_accepts_the_plain_language_code():
|
||||
"""Vosk names its models by region and rejects a bare "en"."""
|
||||
from bandsaunter.announce import say
|
||||
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
|
||||
language="en")
|
||||
assert result is not None and not result.note, result.note
|
||||
206
tests/test_tui.py
Normal file
206
tests/test_tui.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""The in-application menus, driven by scripted answers."""
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bandsaunter import settings as st, tui
|
||||
from bandsaunter.config import ScanConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console():
|
||||
return Console(width=100, file=open("/dev/null", "w"), force_terminal=False)
|
||||
|
||||
|
||||
def drive(monkeypatch, answers):
|
||||
"""Feed the menus a fixed list of answers, then stop."""
|
||||
script = list(answers)
|
||||
|
||||
def fake_ask(console, prompt, default=""):
|
||||
if not script:
|
||||
raise _Done()
|
||||
return script.pop(0)
|
||||
|
||||
monkeypatch.setattr(tui, "_ask", fake_ask)
|
||||
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
|
||||
return script
|
||||
|
||||
|
||||
class _Done(Exception):
|
||||
"""Raised to break out when the script runs out."""
|
||||
|
||||
|
||||
def run(monkeypatch, console, func, answers, *args):
|
||||
drive(monkeypatch, answers)
|
||||
try:
|
||||
return func(console, *args)
|
||||
except _Done:
|
||||
return None
|
||||
|
||||
|
||||
def test_settings_menu_edits_a_value(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.settings_menu, ["1", "2", "6", "", ""], cfg)
|
||||
assert cfg.hang_seconds == 6.0
|
||||
|
||||
|
||||
def test_settings_menu_finds_a_setting_by_search(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.settings_menu, ["voice score", "0.3", ""], cfg)
|
||||
assert cfg.min_voice_score == 0.3
|
||||
|
||||
|
||||
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
before = cfg.hang_seconds
|
||||
run(monkeypatch, console, tui.settings_menu,
|
||||
["1", "2", "banana", "", "", ""], cfg)
|
||||
assert cfg.hang_seconds == before
|
||||
|
||||
|
||||
def test_a_value_failing_validation_is_rolled_back(monkeypatch, console):
|
||||
"""min_record longer than record would mean nothing is ever kept."""
|
||||
cfg = ScanConfig()
|
||||
cfg.record_seconds = 5.0
|
||||
run(monkeypatch, console, tui.settings_menu,
|
||||
["1", "4", "9", "", "", ""], cfg)
|
||||
assert cfg.min_record_seconds != 9.0
|
||||
assert not [e for e in cfg.validate() if "ranges" not in e]
|
||||
|
||||
|
||||
def test_every_group_can_be_opened_and_left(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
for i in range(1, len(st.GROUPS) + 1):
|
||||
run(monkeypatch, console, tui.settings_menu, [str(i), "", ""], cfg)
|
||||
|
||||
|
||||
def test_every_setting_renders_its_help(console):
|
||||
"""Built-in help must exist and render for every single setting."""
|
||||
cfg = ScanConfig()
|
||||
for s in st.SETTINGS:
|
||||
tui.setting_help(console, s, cfg)
|
||||
|
||||
|
||||
def test_reset_a_group_restores_defaults(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
cfg.hang_seconds = 99.0
|
||||
cfg.record_seconds = 99.0
|
||||
run(monkeypatch, console, tui.settings_menu, ["1", "d", "", ""], cfg)
|
||||
assert cfg.hang_seconds == ScanConfig().hang_seconds
|
||||
assert cfg.record_seconds == ScanConfig().record_seconds
|
||||
|
||||
|
||||
def test_help_screen_shows_every_topic(monkeypatch, console):
|
||||
for topic in tui._TOPICS:
|
||||
run(monkeypatch, console, tui.help_screen, [topic, ""])
|
||||
|
||||
|
||||
def test_help_screen_looks_up_settings(monkeypatch, console):
|
||||
run(monkeypatch, console, tui.help_screen, ["hang", ""])
|
||||
|
||||
|
||||
def test_ranges_can_be_added_from_the_band_plan(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.choose_presets, ["gmrs", "1", ""], cfg)
|
||||
assert cfg.ranges, "no range was added"
|
||||
|
||||
|
||||
def test_ranges_can_be_typed_in(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.add_manual_ranges,
|
||||
["144M", "148M", "nfm", ""], cfg)
|
||||
assert len(cfg.ranges) == 1
|
||||
assert cfg.ranges[0].start == 144e6 and cfg.ranges[0].stop == 148e6
|
||||
assert cfg.ranges[0].mode == "nfm"
|
||||
|
||||
|
||||
def test_a_bad_frequency_is_refused(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.add_manual_ranges, ["banana", ""], cfg)
|
||||
assert not cfg.ranges
|
||||
|
||||
|
||||
def test_main_menu_can_start_a_scan(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
cfg.ranges = parse_range_list("144M-148M")
|
||||
out = run(monkeypatch, console, tui.run_tui, ["s"], cfg)
|
||||
assert out is cfg
|
||||
|
||||
|
||||
def test_main_menu_refuses_to_start_without_ranges(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
out = run(monkeypatch, console, tui.run_tui, ["s", "q"], cfg)
|
||||
assert out is None, "started a scan with nothing to scan"
|
||||
|
||||
|
||||
def test_main_menu_quits(monkeypatch, console):
|
||||
assert run(monkeypatch, console, tui.run_tui, ["q"], ScanConfig()) is None
|
||||
|
||||
|
||||
def test_closed_input_unwinds_instead_of_looping(monkeypatch, console):
|
||||
"""With no input left, the menus must give up rather than spin forever."""
|
||||
def eof(*a, **k):
|
||||
raise EOFError
|
||||
monkeypatch.setattr(tui.Prompt, "ask", eof)
|
||||
assert tui.run_tui(console, ScanConfig()) is None
|
||||
with pytest.raises(tui.TUIAbort):
|
||||
tui.settings_menu(console, ScanConfig())
|
||||
|
||||
|
||||
def test_interrupt_unwinds_too(monkeypatch, console):
|
||||
def interrupt(*a, **k):
|
||||
raise KeyboardInterrupt
|
||||
monkeypatch.setattr(tui.Prompt, "ask", interrupt)
|
||||
assert tui.run_tui(console, ScanConfig()) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_first_run_is_detected_and_then_not(tmp_path):
|
||||
from bandsaunter.config import ScanConfig, is_first_run, save_default
|
||||
assert is_first_run(tmp_path)
|
||||
save_default(ScanConfig(), tmp_path)
|
||||
assert not is_first_run(tmp_path)
|
||||
|
||||
|
||||
def test_first_run_asks_where_to_save_and_remembers(monkeypatch, console,
|
||||
tmp_path):
|
||||
from bandsaunter import tui
|
||||
from bandsaunter.config import load_default
|
||||
wanted = tmp_path / "my recordings"
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": str(wanted))
|
||||
monkeypatch.setattr(tui, "save_default",
|
||||
lambda cfg: __import__("bandsaunter.config",
|
||||
fromlist=["x"]).save_default(
|
||||
cfg, tmp_path))
|
||||
cfg = ScanConfig()
|
||||
assert tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(wanted)
|
||||
assert wanted.is_dir(), "the directory should be created"
|
||||
back, _ = load_default(tmp_path)
|
||||
assert back.output_dir == str(wanted)
|
||||
|
||||
|
||||
def test_first_run_accepts_the_suggested_default(monkeypatch, console,
|
||||
tmp_path):
|
||||
from bandsaunter import tui
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": "")
|
||||
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
||||
monkeypatch.setattr(tui, "DEFAULT_OUTPUT_DIR", str(tmp_path / "default"))
|
||||
cfg = ScanConfig()
|
||||
tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(tmp_path / "default")
|
||||
|
||||
|
||||
def test_first_run_rejects_a_directory_it_cannot_write(monkeypatch, console,
|
||||
tmp_path):
|
||||
"""Better to say so now than to fail on the first recording."""
|
||||
from bandsaunter import tui
|
||||
answers = iter(["/proc/nonsense/cannot-create", str(tmp_path / "ok")])
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": next(answers))
|
||||
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
||||
cfg = ScanConfig()
|
||||
tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(tmp_path / "ok")
|
||||
147
tests/test_ui.py
Normal file
147
tests/test_ui.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""The live display, and keeping the driver from writing over it."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.recorder import HitRecord
|
||||
from bandsaunter.scanner import Scanner
|
||||
from bandsaunter.simulator import SimulatedDevice
|
||||
from bandsaunter.ui import ScanDisplay, _sparkline
|
||||
|
||||
|
||||
def _display(height: int, hits: int, recording: bool) -> ScanDisplay:
|
||||
console = Console(width=100, height=height,
|
||||
file=open(os.devnull, "w"))
|
||||
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
|
||||
output_dir=tempfile.mkdtemp())
|
||||
scanner = Scanner(cfg, device=SimulatedDevice().open())
|
||||
scanner.prepare()
|
||||
display = ScanDisplay(scanner, console=console)
|
||||
display.attach()
|
||||
step = scanner.plan[0]
|
||||
display.on_step(0, 6, step, np.full(1024, -70.0),
|
||||
np.linspace(step.low, step.high, 1024))
|
||||
for i in range(hits):
|
||||
display.hits.append(HitRecord(
|
||||
frequency=146e6 + i * 1e5, started_at=time.time(), duration=5.0,
|
||||
snr_db=20.0, classification="FM broadcast station (stereo)"))
|
||||
display._rec.active = recording
|
||||
display._rec.frequency = 146.52e6
|
||||
display._rec.mode = "nfm"
|
||||
return display
|
||||
|
||||
|
||||
def _rendered_height(display: ScanDisplay) -> int:
|
||||
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
||||
record=True)
|
||||
probe.print(display.render())
|
||||
return len(probe.export_text().rstrip("\n").split("\n"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("height", [16, 20, 24, 30, 40, 60])
|
||||
@pytest.mark.parametrize("hits,recording", [(0, False), (3, False),
|
||||
(12, False), (12, True)])
|
||||
def test_the_display_never_outgrows_the_terminal(height, hits, recording):
|
||||
"""A frame taller than the terminal cannot be redrawn in place.
|
||||
|
||||
Every refresh then scrolls another copy into the scrollback, which is why
|
||||
the header ends up on screen several times over.
|
||||
"""
|
||||
display = _display(height, hits, recording)
|
||||
assert _rendered_height(display) <= height, \
|
||||
f"{hits} hits, rec={recording}: overflowed a {height}-line terminal"
|
||||
|
||||
|
||||
def test_older_hits_are_dropped_not_the_layout():
|
||||
"""When space runs short the list shortens; the panels stay."""
|
||||
display = _display(20, 12, True)
|
||||
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
||||
record=True)
|
||||
probe.print(display.render())
|
||||
text = probe.export_text()
|
||||
assert "receiver" in text and "sweep" in text
|
||||
assert "more above" in text, "no sign that hits were trimmed"
|
||||
|
||||
|
||||
def test_a_tall_terminal_shows_every_hit():
|
||||
display = _display(60, 12, False)
|
||||
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
||||
record=True)
|
||||
probe.print(display.render())
|
||||
assert "more above" not in probe.export_text()
|
||||
|
||||
|
||||
def test_sparkline_keeps_narrow_carriers_visible():
|
||||
values = np.full(200, -70.0)
|
||||
values[100] = -20.0
|
||||
assert "█" in _sparkline(values, 60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The driver writes its own messages straight to file descriptor 2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_quiet_driver_suppresses_writes_to_fd_2():
|
||||
"""librtlsdr prints from C, so Python-level redirection cannot catch it."""
|
||||
code = (
|
||||
"import os, sys; sys.path.insert(0, %r)\n"
|
||||
"from bandsaunter.device import quiet_driver\n"
|
||||
"with quiet_driver():\n"
|
||||
" os.write(2, b'CHATTER\\n')\n"
|
||||
"os.write(2, b'AFTERWARDS\\n')\n"
|
||||
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
||||
text=True, timeout=30)
|
||||
assert "CHATTER" not in out.stderr
|
||||
assert "AFTERWARDS" in out.stderr, "stderr was not restored"
|
||||
|
||||
|
||||
def test_quiet_driver_restores_on_an_exception():
|
||||
code = (
|
||||
"import os, sys; sys.path.insert(0, %r)\n"
|
||||
"from bandsaunter.device import quiet_driver\n"
|
||||
"try:\n"
|
||||
" with quiet_driver():\n"
|
||||
" raise ValueError('boom')\n"
|
||||
"except ValueError:\n"
|
||||
" pass\n"
|
||||
"os.write(2, b'RESTORED\\n')\n"
|
||||
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
||||
text=True, timeout=30)
|
||||
assert "RESTORED" in out.stderr
|
||||
|
||||
|
||||
def test_driver_messages_can_be_turned_back_on_for_debugging():
|
||||
code = (
|
||||
"import os, sys; sys.path.insert(0, %r)\n"
|
||||
"from bandsaunter.device import quiet_driver\n"
|
||||
"with quiet_driver():\n"
|
||||
" os.write(2, b'CHATTER\\n')\n"
|
||||
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
env = dict(os.environ, BANDSAUNTER_DRIVER_MESSAGES="1")
|
||||
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
||||
text=True, timeout=30, env=env)
|
||||
assert "CHATTER" in out.stderr
|
||||
|
||||
|
||||
def test_python_errors_still_reach_stderr():
|
||||
"""Silencing the driver must not swallow a traceback."""
|
||||
code = (
|
||||
"import sys; sys.path.insert(0, %r)\n"
|
||||
"from bandsaunter.device import quiet_driver\n"
|
||||
"with quiet_driver():\n"
|
||||
" pass\n"
|
||||
"raise RuntimeError('visible')\n"
|
||||
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
||||
text=True, timeout=30)
|
||||
assert "visible" in out.stderr
|
||||
Loading…
Add table
Add a link
Reference in a new issue