Skip to content
DeepBlueDynamicsPublic

About

The radio stack for DeepBlue Dynamics' projects.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Hertz: radio in, words out. Multi-dongle RTL-SDR with on-device transcription.

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.

Install (npm)

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:9080

The 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 trigger

Then 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.

Quick start (Windows, from source)

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.

Configuration

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.

FRS/GMRS walkie-talkies

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 = true

A 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.

How a transmission is handled

  1. 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.
  2. 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.
  3. Recording. Each transmission is written to data/recordings/transmission_<utc>_Ch<id>_<label>_<freq>MHz_<n>.wav (48 kHz mono 16-bit).
  4. 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.

Transcription (Cactus Whistle)

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.

Transcription service (Whisper)

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 log

With 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 ready

On 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 jobs

hertzd 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.

Hyperia and ollaya (hertz-relay)

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-panes

It 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.

Workspace

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 --all

Diagnostics: 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.

Known limitations

  • 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).

License

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.

About

The radio stack for DeepBlue Dynamics' projects.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages