A multi-dongle RTL-SDR radio receiver in Rust, tuned for marine VHF. hertzd owns
the dongles, demodulates NFM/AM, runs a squelch, records each transmission to WAV,
transcribes it on-device with Cactus Whistle, and serves
everything over REST, WebSocket and SSE. hertz is the terminal client.
Prebuilt hertzd and hertz for Linux arm64 (Raspberry Pi 4/5, HaLOS), Linux
x64, macOS (Apple Silicon and Intel) and Windows x64. Needs Node 18 or later; npm
installs only the binary for your machine.
npm install -g @deepbluedynamics/hertz
hertzd --version
cp "$(npm root -g)/@deepbluedynamics/hertz/hertz.example.toml" hertz.toml # then edit
hertzd hertz.toml --listen
hertz # console, connects to http://localhost:9080The band plans ship with the package (HERTZ_BANDPLANS overrides them). On first
use hertzd downloads Whistle's engine and weights (about 17 MB) and caches them,
so run it once with internet before going offline.
On Linux and the Pi, do the one-time USB setup npm can't do: the system libraries, the kernel TV-driver blacklist and the udev rule:
sudo apt install libasound2 libudev1
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
sudo curl -o /etc/udev/rules.d/60-hertz-rtlsdr.rules \
https://raw.githubusercontent.com/DeepBlueDynamics/hertz/main/docs/60-hertz-rtlsdr.rules
sudo udevadm control --reload && sudo udevadm triggerThen replug the dongle (use a powered USB hub). On Windows the dongle needs the WinUSB driver (docs/INSTALL.md). To run hertzd under systemd, and for how releases are built and published, see docs/NPM.md.
One RTL-SDR on a WinUSB driver (see docs/INSTALL.md). Copy
hertz.windows.example.toml to data\hertz.windows.toml, set your dongle serial, then:
cargo build --release -p hertz-daemon
.\target\release\hertzd.exe data\hertz.windows.toml --listen--listen plays squelch-gated audio on the default output device and prints one
console line per squelch open/close and per transcript:
INFO scanning 4 channel(s): 68,69,71,72
INFO SQUELCH OPEN | Ch 71 | 156.575 MHz | Signal: 1.6dB |
INFO SQUELCH CLOSED | Ch 71 | 156.575 MHz | Signal: -23.7dB | VOICE
INFO transcribed transmission_20261004_233946_Ch71_NON-COMM_156.575MHz_1 in 0.1s: Alpha India, this is Alpha Tango, come in, over.
Without --listen the daemon runs headless; connect a client with
cargo run --release -p hertz-tui -- --connect http://127.0.0.1:9080.
hertzd [config.toml] [--listen] [--mock] [--weather[=WX3,...]] [--weather-every=<minutes>]
(or HERTZ_CONFIG).
Weather is off unless you pass --weather. It turns on NOAA weather monitoring for
every scanning dongle: it samples the weather channels for 3 minutes every 15
minutes (--weather-every=30 for every 30), starting right away, and plays
and live-transcribes the broadcast while it does. The full transcript is logged as
WEATHER | WX3 | ... and sent to Lume as radio.weather. One dongle can't hear
both, so receive on the scan channels is offline while it samples: hertzd logs
RADIO RECEIVE OFFLINE / RADIO RECEIVE BACK ONLINE, the dongle's status message
says so, and a ScanState event is published. Channels come from the flag
(--weather=WX3), else the config's [dongle.weather], else WX1; without the flag
a [dongle.weather] section does nothing. NOAA channels:
WX1 162.550, WX2 162.400, WX3 162.475, WX4 162.425, WX5 162.450, WX6 162.500, WX7
162.525 MHz.
Weather can also be switched while hertzd runs, from hertzd's MCP server at
/mcp on the same port:
claude mcp add --transport http hertz http://127.0.0.1:9080/mcp| Tool | Does |
|---|---|
weather_status |
On or off, channels, minutes between samples, whether it is sampling now, when the next sample is due |
weather_set |
{"enabled": true, "channels": ["WX3"], "every_minutes": 15}: on samples right away, then every every_minutes; off stops a sample in progress. Left-out fields keep their value |
GET/POST /api/weather do the same over REST. /mcp follows the REST auth
rule (loopback is exempt, otherwise the bearer token when one is set) and refuses
a browser Origin other than localhost.
A working native config:
[daemon]
listen = "127.0.0.1:9080"
data_dir = "./data"
[[dongle]]
serial = "00000001"
role = "monitor" # one channel at 240 kS/s (or a scan list, below)
bandplan = "marine-vhf-us"
frequency_hz = 156625000 # fixed frequency when not scanning
scan = ["68", "69", "71", "72"]
squelch_db = 6.0 # open at noise floor + 6 dB
record = true
[transcription]
engine = "whistle" # or omit the section to disable
language = "en" # "auto" detects (en, de, fr, es, it, nl, pl)
keywords = ["Alpha India", "Alpha Tango", "come in", "over"]Dongle roles:
| Role | What it does |
|---|---|
monitor |
Tunes one frequency directly at 240 kS/s. With scan, hops the listed channel ids, locks on any channel whose squelch opens, stays 5 s after it closes for replies, then resumes (about 1 s per sweep of 4 channels). |
channelized |
One 2.4 MS/s capture covering the bandplan; channels with activity are extracted and demodulated in parallel. tap_channel is always demodulated. |
hopscan |
Accepted by the config but not implemented yet; the dongle is skipped. |
Bandplans (bandplans/*.toml, 419 channels across marine, NOAA WX, FRS/GMRS, MURS,
ham 2 m/70 cm, CB, airband, railroad and public-safety interop) load from
HERTZ_BANDPLANS, ./bandplans, ../bandplans or /etc/hertz/bandplans.
A scan entry is a channel id ("72", "gmrs-1") or a band group, which expands
to that group's NFM receive channels in frequency order. Repeater inputs, AM
channels and continuous broadcasts (NOAA weather) are left out of a group.
To listen to FRS and GMRS (462.550–467.725 MHz, the unlicensed and GMRS handhelds) instead of marine VHF, point the dongle at that band. Recording, transcription, the relay and Lume work the same way:
[[dongle]]
serial = "00000001"
role = "monitor"
bandplan = "frs-gmrs"
frequency_hz = 462562500
scan = ["frs-gmrs"] # the 22 simplex channels; or ids like "gmrs-1", "frs-8"
squelch_db = 6.0
record = trueA sweep of 22 channels takes about 5 s, so a short single call can be missed;
list only the channels you use for a faster sweep. A marine antenna hears UHF
poorly, so use one cut for 460 MHz if you can. One dongle covers one band; use a
second dongle with its own [[dongle]] section for marine and FRS/GMRS together.
- Squelch. A frame opens the squelch when in-channel power clears the noise
floor plus
squelch_db, or when the demodulated audio looks like voice (spectral flatness). The floor is measured for the first second, adapts only while the squelch is closed, and is rate-limited downward, so the dip after a key-up can't latch it open. - Audio. NFM is demodulated with an 81-tap channel filter and AFC, then de-emphasized (750 µs), low-passed at 3 kHz and played at a fixed gain with a soft limiter. Only frames with signal present are played; the speaker keeps a 300 ms jitter cushion and reopens the output if the device disappears.
- Recording. Each transmission is written to
data/recordings/transmission_<utc>_Ch<id>_<label>_<freq>MHz_<n>.wav(48 kHz mono 16-bit). - Transcription. Whistle transcribes the WAV off the recording path and writes
data/transcriptions/<same name>.txt. Silence and static come back empty and are written as(no speech detected). Recordings without a transcript are picked up at startup.
Whistle is a 16.9 MB on-device recognizer for 7 languages, used through the
whistle crate in crates/whistle. On first use it downloads the engine
library and weights to %LOCALAPPDATA%\whistle (~/.cache/whistle on Unix):
whistle.cact 16.9 MB speech model
3.3.1/libneedle3.dll 1.5 MB needle engine
Override with WHISTLE_WEIGHTS, WHISTLE_LIB_PATH or WHISTLE_CACHE. On CPU a
3 s transmission transcribes in about 70 ms. keywords biases recognition toward
call signs and vessel names. live = true also prints words in the console as you
talk, using Whistle's streaming mode (engine 3.3.1); routing still uses the full
recording.
By default hertz transcribes on the device only (Whistle) and never contacts Whisper. Give it a Whisper HTTP service, on this machine or another, and the relay uses it as a second, slower transcriber:
[transcription]
whisper_url = "http://127.0.0.1:8765" # or http://<other host>:8765
whisper_model = "medium" # for the logWith it, the relay does two more things:
- Double-check: after a call is delivered, its recording is re-transcribed, and if Whisper's version differs, a follow-up goes to the same pane.
- Retry on a miss: when the on-device transcript names no one, routing is tried again on Whisper's version, which is better on names.
hertz keeps its own copy of the service in
deploy/transcription.
First time, or after the image is gone. On an NVIDIA GPU this builds the
image (model medium) and starts it; the first build downloads CUDA PyTorch,
several GB:
cd deploy/transcription
docker compose up -d --build
curl http://127.0.0.1:8765/health # "model_loaded": true when it's readyOn CPU only: docker compose -f docker-compose.cpu.yml up -d --build (model
base).
Once the image is built, the container restarts with Docker
(restart: unless-stopped):
cd deploy/transcription
docker compose up -d # start
docker compose down # stop
docker compose logs -f # watch jobshertzd doesn't need restarting when the service comes or goes; it checks
/health on each call. At start it logs which mode it's in
(second opinion from Whisper (medium) at … or on-device transcription only). If a configured service stops answering, it says so once and again
once when it's back. The older [relay] verify_url setting still works;
whisper_url wins.
The service has no auth, so the compose files publish it on 127.0.0.1 only.
hertz-relay connects hertz to the Hyperia terminal and the ollaya decision
model. With a [relay] section in the config, hertzd routes each transcript to the
Hyperia pane it names: say "Top Rabbit, come in, over" and ollaya picks Top Rabbit
from the live pane list, and hertz msg_sends the transcript to it. Hyperia's
rotating two-word call signs ("Quebec Lima") work too, and a sign stops working
once it has been used on the air. Calls that name no pane are only logged. Setup and details: docs/OPERATIONS.md.
hertz-panes shows what ollaya sees:
cargo run --release -p hertz-relay --bin hertz-panesIt reads the window/tab/pane layout from Hyperia (HYPERIA_MCP_URL,
HYPERIA_AGENT_TOKEN, both set in every Hyperia pane), sends it to ollaya
(OLLAYA_URL, default http://127.0.0.1:11435, model laya:en) and asks which
tab and pane are active, printing ollaya's answer next to Hyperia's own.
Setting up ollaya (container, model pulls, the /api/decide format):
docs/OLLAYA.md.
| Crate | Purpose |
|---|---|
hertz-types |
Config, events, wire format |
hertz-channels |
Bandplan loader and channel database |
hertz-sdr |
RTL-SDR access (pure-Rust rtl-sdr-rs, vendored libusb), worker thread, reconnect |
hertz-dsp |
Channelizer, NFM/AM demod, squelch pipeline, listening chain, recorder |
hertz-daemon |
hertzd: runtime, scanner, REST/WS/SSE server, recorder, speaker, transcription |
hertz-tui |
hertz: TUI and CLI (status, doctor, records, tail, tune) |
hertz-relay |
Hyperia layout and ollaya clients, hertz-panes |
hertz-tx |
Placeholder for transmit support |
cargo test --workspace
cargo clippy --workspace --all-targets
cargo fmt --allDiagnostics: cargo run --release -p hertz-sdr --example dump_fft prints a live
spectrum, and --example probe_levels compares raw IQ levels across gain and
bandwidth settings.
- Gain. The pure-Rust driver's manual gain pins the tuner VGA at 16.3 dB, about 10 dB below librtlsdr's, so dongles run on tuner AGC.
- Hopscan is not implemented, and the TUI's band scope does not render the monitor role's spectrum correctly yet.
More: docs/INSTALL.md (drivers per OS, Whistle), docs/OPERATIONS.md (data, bandplans, diagnostics), docs/OLLAYA.md (ollaya setup), and the frontend plans in plan/ (TUI waterfall spec and client brief).
BSD 3-Clause; see LICENSE. The whistle crate is Apache-2.0. The
prebuilt binaries statically link libusb, which is LGPL-2.1
(source); build from this repository to relink
against your own copy.
