SDRmm
SDRmm listens to, decodes, and records radio signals from an SDR, a network receiver, or an IQ recording.
You build a receiver by wiring nodes together in Patch view, then pin the controls you use to Rack view. An RTL-SDR and a local FM station are enough to start.
Start here
- Install SDRmm.
- Build your first receiver.
- Learn how nodes and wires fit together.
Find a guide
| Task | Guide |
|---|---|
| Connect a radio | Radios |
| Listen to a signal | Channels |
| Decode data | Decoders |
| Save and replay signals | Recording and playback |
| Find where a signal comes from | Direction finding |
| Use a phone in the field | Phones |
| Save and switch setups | Workspaces and presets |
| Program a radio or measure an antenna | Tools |
| Run the radio somewhere else | Deployment |
| Fix a problem | Troubleshooting |
| Work on SDRmm | Build and test |
How it runs
A server talks to the radio and does all signal processing. The desktop app and the browser show the same interface on top of it. Run both on one computer, or put the server next to the antenna and connect over the network. Every connected client sees the same workspace, and a phone can join as a remote head in the field.
Install
Pick the desktop app when the radio is plugged into your computer. Pick the server when the radio sits somewhere else and you connect from a browser. Both run the same receiver.
| Installation | Best for |
|---|---|
| Desktop app | A radio on your computer |
| Portable server | A Raspberry Pi, home server, or remote receiver |
| Homebrew | macOS or Linux with Homebrew |
| APT | Debian and Ubuntu |
| DNF | Fedora |
| Nix | Linux managed with Nix |
| Container | Docker |
Desktop app
Download the installer from the download page and open SDRmm. The app starts its own server on a random port only this computer can reach. For a fixed port or access from other devices, run the server instead.
| Platform | Package |
|---|---|
| macOS | .dmg for Apple silicon or Intel |
| Linux | .deb, .rpm or .AppImage for x86-64 or ARM64 |
| Windows | .msi or .exe for x86-64, .exe for ARM64 |
Portable server
Download and unpack the sdrmm archive for your system from the download page,
then run it:
./sdrmm
On Windows, run sdrmm.exe. Open http://localhost:8080 on the server.
It only accepts this machine. To reach it from another computer, start it with
--bind 0.0.0.0:8080. It then makes a token and serves
HTTPS at https://<server>:8080.
Homebrew
App, macOS:
brew install sdrmm-app
Server, macOS or Linux:
brew install sdrmm
brew services start sdrmm
The cask installs into /Applications. The service starts the server at login. Open
http://localhost:8080.
APT
curl -fsSL https://downloads.sdrmm.com/packages/key.gpg \
| sudo tee /usr/share/keyrings/sdrmm.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/sdrmm.gpg] https://downloads.sdrmm.com/packages/deb stable main" \
| sudo tee /etc/apt/sources.list.d/sdrmm.list
sudo apt update
sudo apt install sdrmm-app
APT and DNF install the desktop app. For the sdrmm server, use Homebrew or the
portable server.
DNF
sudo dnf config-manager addrepo \
--from-repofile=https://downloads.sdrmm.com/packages/rpm/sdrmm.repo
sudo dnf install sdrmm-app
Nix
Install and launch the desktop app on x86_64 or aarch64 Linux from a release's source tarball, which carries the built web interface:
VERSION=1.2.3
nix --extra-experimental-features 'nix-command flakes' profile install \
"https://github.com/Newspicel/sdrmm/releases/download/v$VERSION/sdrmm-$VERSION-src.tar.gz"
sdrmm-desktop
The flake exports the desktop app as sdrmm-desktop, sdrmm, and default. From a checkout,
run pnpm --dir web build, then nix build path:. produces result/bin/sdrmm-desktop.
Radios without a built-in driver need SoapySDR modules, picked with soapyPlugins. For SDRplay,
enable services.sdrplayApi and pass the unfree pkgs.sdrplay as sdrplayApi. This NixOS
example assumes the flake input is named sdrmm:
environment.systemPackages = [
(inputs.sdrmm.packages.${pkgs.stdenv.hostPlatform.system}.sdrmm.override {
soapyPlugins = with pkgs; [ soapybladerf soapyremote ];
})
];
hardware.rtl-sdr.enable = true;
users.users.your-user.extraGroups = [ "plugdev" ];
Container
On Linux:
git clone https://github.com/Newspicel/sdrmm.git
cd sdrmm
docker compose pull
docker compose up -d
Without the pull, Compose builds the image from the checkout.
The container listens on every interface, so it makes a token on first start. Open the link
docker compose logs sdrmm | grep token shows. Data lives in the sdrmm-data volume. For USB
radios, tokens, and HTTPS, see Deployment.
Stable or nightly
Use a stable release. The desktop app checks for updates at startup and asks before installing.
On Linux only the AppImage updates itself; APT, DNF, and Nix installs update through the package
manager. The nightly release follows
main and never updates itself. A database a newer build has opened does not open in an older
one.
Next
- Plug in a radio and check Radios if it needs a driver.
- Build your first receiver.
- To build from source, see Build and test.
Your first receiver
Listen to a local FM station with an RTL-SDR. You need the receiver, an antenna, and SDRmm installed.
1. Pick the radio
Attach the antenna and plug the RTL-SDR into the computer running the server. Open SDRmm.
A new installation starts with a Device wired to a Scope, and a Speaker. Pick your RTL-SDR on the Device node and set the rate to 2.4 MS/s.
Radio not listed? Press Check hardware on the Device node, or see RTL-SDR.
2. Add a WFM channel
WFM is broadcast FM. Press + Add, search for WFM, and add it. Wire it up:
Device iq → WFM iq
WFM audio → Speaker audio
Set the WFM dial to a local station. The Device follows the channel, so you never tune the radio itself. See Tuning.
3. Listen
Press play on the Speaker. Adjust the Device gain until the station stands clearly above the noise without clipping.
Silent? Drag the WFM squelch handle far left, and press Resume audio if the Speaker shows it. More in Troubleshooting.
4. Station name and text
Add a Readout and wire WFM events to it. It shows the RDS station name and radio text when
the station sends them and the signal is strong enough.
5. Arrange
Select a node and press p to pin it to the Rack. Press v to switch between Patch and Rack.
Changes save automatically.
Next
- Nodes and wires explains what you just built.
- Library → Templates sets up other receivers in one click.
- The decoder catalog lists every mode.
Nodes and wires
Everything in SDRmm is a node, and every flow between nodes is a wire you can see. The set of nodes, wires, and settings is a workspace. It lives on the server and every client shares it.
Patch and Rack
Patch shows every node and wire. Use it to build and change a receiver.
Rack shows only the nodes you pinned. Use it for everyday operation. Select a node and press
p to pin or unpin it, and v to switch views. Moving a node in the Rack leaves its wires alone.
Ports
A wire joins an output port to an input port of the same type:
| Port | Carries | Typical wire |
|---|---|---|
iq | Raw radio samples | Device → channel, Scope, Recorder |
audio | Sound | Channel → Audio FX, Speaker, Audio recorder |
events | Decoded messages | Channel → Readout, Decoder log, Map |
baseband | One channel's filtered IQ | Channel → Baseband scope, Baseband recorder, Network IQ |
video | Pictures and video | Channel → Video |
control | Tune, point and run commands | Scanner, Satellite → channel; Schedule → Device, recorder |
position | Location and heading | GPS position → Map, Array, Satellite, channel |
array | Aligned lanes of an antenna array | Array → Direction finder, Beamformer, Passive radar |
bearing | A live bearing | Direction finder → Beamformer |
transmit | What a radio sends | Transmitter → Device |
Node types
+ Add groups nodes as Sources (Device, GPS position, Microphone), Decoders (every decoder by family, and the Modulator), Tools (Scanner, Array, Audio FX and more) and Outputs (Scope, Speaker, Map, recorders and more). It lists what the running server offers. Wires and what they carry lists every node and its ports.
To add a node at the cursor, double-click the canvas, or right-click it and pick Add node here.
Devices and channels
A Device opens one radio. A channel decodes one frequency from the Device's IQ.
You tune channels, not the radio. The Device moves its window to cover as many wired channels as
its sample rate can hold, and its header counts them: 5/5 is green, 3/5 yellow, 0/5 red. See
Tuning.
A Device node remembers which radio it holds. Unplug it and the node, wires, and settings stay. Plug the same radio back in and it reconnects, or press Open radio. Stop closes the radio and releases it to other programs, keeping the node as it is; Start opens it again with its settings. Forget radio frees the node for another one.
Changes apply live
Edits apply as you make them: radios open, settings restore, channels update, and anything the workspace no longer uses closes. A node that could not apply says why on its face.
Tuning, switching workspaces, and applying templates affect every connected client. If two clients edit the same revision at once, the second gets a conflict instead of overwriting the first. See Workspaces and presets for saving, undo, and sharing.
Wires and what they carry
Every port has a type and a contract. A wire joins an output to an input of the same type, and the server refuses one the contract does not allow. This page is generated by cargo xtask codegen from the node catalog.
Port types
| Type | Carries |
|---|---|
| IQ | One radio lane, retuned by its decoders unless locked |
| Baseband | One channel, filtered and decimated |
| Audio | Live audio |
| Events | Decoded events. Calls and transmissions bring their clip |
| Video | Decoded video frames and pictures |
| Control | Commands that retune a decoder, point a rotator or run a radio |
| Position | A live fix and the heading it comes with |
| Array | One lane per antenna element |
| Bearing | A live bearing that steers an array |
| Transmit | What a radio puts on the air |
Event kinds
| Kind | Name | Shows as | Facts |
|---|---|---|---|
acars | ACARS | Aircraft | log, station, geo |
adsb | ADS-B | Aircraft | log, station, geo, track |
ais | AIS | Vessels | log, station, geo |
aprs | APRS | Stations | log, station, geo |
apt | NOAA APT | Pictures | log, picture |
bearing_fix | Bearing fix | Fixes | log, station, geo |
ble | BLE | Devices | log, station |
broadcast | Broadcast | Services | |
broadcast_data | Broadcast data | Services | log |
call | Call | Calls | log, station, voice, duration, clip |
cw_skimmer | CW skimmer | CW spots | log |
dect | DECT | Base stations | log, station |
df | Bearing | Bearings | log, station, geo, bearing |
df_fix | Fix | Fixes | log, station, geo |
dsc | DSC | Vessels | log, station, geo |
dv | Digital voice | Radios | log, station, geo, voice |
eot | End-of-Train | Trains | log, station |
ermes | ERMES | Pagers | log, station |
flex | FLEX | Pagers | log, station |
ft4 | FT4 | Stations | log, station, spot |
ft8 | FT8 | Stations | log, station, spot |
gnss | GNSS lab | Satellites | log, station |
gsm | GSM | Cells | log, station |
hfdl | HFDL | Aircraft | log, station, geo |
ident | Signal ID | Signals | log |
ils | ILS | ILS | log |
inmarsat_aero | Inmarsat Aero | Aircraft | log, station, geo |
inmarsat_stdc | Inmarsat STD-C | Vessels | log, station, geo |
iridium | Iridium | Bursts | log, station, geo |
ism | ISM sensor | Sensors | log, station |
ism_survey | 2.4 GHz | 2.4 GHz | log |
lora | LoRa | Nodes | log, station, geo |
lrpt | Meteor LRPT | Pictures | log, picture |
morse | Morse | Morse | log, text |
nav | Guidance | Targets | station, geo |
navtex | NAVTEX | Stations | log, station |
noise_figure | Noise figure | Noise figure | log |
per | PER | PER | log |
pocsag | POCSAG | Pagers | log, station |
psk | PSK | PSK | log, text |
radar | Radar | Tracks | log, station, geo |
radar_sites | Radar sites | Sites | station, geo |
radio_clock | Radio clock | Clocks | log, station |
radiosonde | Radiosonde | Sondes | log, station, geo |
rds | RDS | Stations | log, station |
remote_id | Remote ID | Drones | log, station, geo |
rtty | RTTY | RTTY | log, text |
same | SAME | Alerts | log, station |
sarsat | 406 MHz beacon | Beacons | log, station, geo |
satellite | Satellite | Satellites | station, geo |
scrambler | Scrambler | Scramblers | log |
selcall | Selcall | Selcall | log |
sid | SID | SID | log |
sstv | SSTV | Pictures | log, picture |
stanag | STANAG | STANAG | log |
tetrapol | Tetrapol | Radios | log, station |
tone | Tone | Tones | log |
transmission | Transmission | Transmissions | log, duration, clip |
vdl2 | VDL Mode 2 | Aircraft | log, station, geo |
vor | VOR | Beacons | log, station, geo, bearing |
wefax | WEFAX | Pictures | log, picture |
wifi_occupancy | Wi-Fi load | Wi-Fi load | log |
wmbus | wM-Bus | Meters | log, station |
wspr | WSPR | Stations | log, station, spot |
Sources
Device
A radio, a recording or a generator: where a patch starts
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | out | IQ | radio | many, one per lane |
control | in | Control | run | many. Opens the radio only while a schedule or satellite says so |
transmit | in | Transmit | What a radio puts on the air | one. The transmitter this radio sends |
GPS position
Your station location, live or fixed
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
position | out | Position | A live fix and the heading it comes with | many |
Microphone
Your voice while you hold the key
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
audio | out | Audio | Live audio | many |
Channels
Channel
Tunes and decodes one signal
Ports follow the decoder the node runs.
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | many lanes. every radio that may carry this decoder; it runs on the one that hears it |
control | in | Control | tune, doppler, sweep | one. a scanner, signal hunt or satellite drives this decoder; its radio follows |
position | in | Position | A live fix and the heading it comes with | one |
baseband | out | Baseband | One channel, filtered and decimated | many |
audio | out | Audio | Live audio | many |
events | out | Events | any kind | many. Decoded messages and calls. Analog calls need squelch |
video | out | Video | Decoded video frames and pictures | many |
Modulator
Turns audio or a message into a signal
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
audio | in | Audio | Live audio | many. Voice for an analog mode |
baseband | out | Baseband | One channel, filtered and decimated | one |
Tools
Array
Radio lanes as one antenna array
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
lane | in | IQ | radio | one, one per array lane. One radio lane per antenna, in antenna order |
position | in | Position | A live fix and the heading it comes with | one. Array place and heading |
array | out | Array | One lane per antenna element | many |
Spectrum monitor
Catches and decodes everything in view
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one |
events | out | Events | any kind | many |
Trunk system
Follows calls across a trunked radio system
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one. the radio the control channel sits on; the system runs its own decoders |
events | out | Events | Call, Digital voice | many |
audio | out | Audio | Live audio | many |
Event filter
Passes only matching events
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | any kind | many |
events | out | Events | any kind | many |
Audio FX
Filters, denoise and AGC
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
audio | in | Audio | Live audio | many |
audio | out | Audio | Live audio | many |
Scanner
Steps through frequencies, stops on activity
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one. The lane it watches |
control | out | Control | sweep | one |
Signal hunt
Walks you towards a transmitter
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one. The lane it watches |
control | out | Control | sweep | one |
position | in | Position | A live fix and the heading it comes with | one. Where you stand and which way you point |
events | out | Events | Bearing | many. Sweep and mark bearings |
Schedule
Runs radios and recorders by the clock
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
control | out | Control | run | many. Radios and recorders to run in its windows |
Satellite
Predicts passes and follows Doppler
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
position | in | Position | A live fix and the heading it comes with | one |
control | out | Control | tune, doppler, point, run | many. decoders to tune and Doppler correct, rotators to point, radios to run |
events | out | Events | Satellite | many. Position and ground track for a Map |
Direction finder
Bearings from an array
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
events | out | Events | Bearing | many. True bearings |
bearing | out | Bearing | A live bearing that steers an array | many. Live bearing to steer a beam |
Beamformer
Steer, null or combine an array
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
steer | in | Bearing | A live bearing that steers an array | one. A direction finder to steer at |
beam | out | IQ | derived | many. The combined lane |
Passive radar
Aircraft echoes of FM, DAB or DVB-T
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
tx | in | Position | A live fix and the heading it comes with | one. GPS node fixed at the transmitter |
truth | in | Events | ADS-B | many. Tracks with altitude used as truth |
events | out | Events | Radar, Radar sites | many. Track events |
Stitch
Spread lanes joined into one wide band
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
wide | out | IQ | derived | many. Every lane joined |
Spatial spectrum
Bearing over frequency
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
Correlator
Baseline visibilities
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
Polarimeter
Polarisation of two crossed antennas
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
beam | out | IQ | derived | many. Matched to the wave |
Processor
An array processor from a plugin
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
array | in | Array | One lane per antenna element | one |
events | out | Events | any kind | many |
Triangulation
Crosses bearings into a position
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | Bearing | many. Bearings |
position | in | Position | A live fix and the heading it comes with | one. Vehicle to guide |
events | out | Events | Fix, Guidance | many |
Bearing fix
Live fix from each station's newest bearing
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | Bearing, VOR | many. Bearings with a known origin |
events | out | Events | Bearing fix | many |
Note
Markdown you leave on the canvas
Outputs
Scope
Spectrum and waterfall
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one |
Baseband scope
Constellation and eye of one channel
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
baseband | in | Baseband | One channel, filtered and decimated | one |
Speaker
Plays channel audio
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
audio | in | Audio | Live audio | many |
Map
Decoded positions on a map
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | 23 kinds | many |
position | in | Position | A live fix and the heading it comes with | many |
Signal survey
Maps signal strength while you move
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one |
position | in | Position | A live fix and the heading it comes with | one |
Propagation map
Where FT8, FT4 and WSPR signals came from
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | FT4, FT8, WSPR | many, keeps history |
position | in | Position | A live fix and the heading it comes with | one |
Readout
Current decoder state
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | any kind | many |
Decoder log
Every decoded message in a table
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | 60 kinds | many, keeps history, clips |
Event output
Sends events out, saves call audio
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | 60 kinds | many |
Video
ATV frames and decoded pictures
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
video | in | Video | Decoded video frames and pictures | many, keeps pictures |
Recorder
Records a radio's full IQ, with an optional pre-roll
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one |
position | in | Position | A live fix and the heading it comes with | one |
control | in | Control | run | many. Armed, it records only while a schedule or satellite says so |
Audio recorder
Records channel audio to WAV
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
audio | in | Audio | Live audio | many |
control | in | Control | run | many. Armed, it records only while a schedule or satellite says so |
Baseband recorder
Records one channel's IQ
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
baseband | in | Baseband | One channel, filtered and decimated | many |
control | in | Control | run | many. Armed, it records only while a schedule or satellite says so |
Network IQ
Streams IQ to other programs
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one |
baseband | in | Baseband | One channel, filtered and decimated | one |
Export
Saves logged rows as CSV or JSON
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
events | in | Events | 60 kinds | many, keeps history |
Rotator
Points an antenna at a satellite
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
control | in | Control | point | one. The satellite to follow |
Transmitter
Puts a modulator or a lane on the air
| Port | Way | Type | Carries | Wires |
|---|---|---|---|---|
iq | in | IQ | radio, synthetic, derived | one. A lane to send as it is |
baseband | in | Baseband | One channel, filtered and decimated | one |
transmit | out | Transmit | What a radio puts on the air | one |
Workspaces and presets
| Holds | Use it to | |
|---|---|---|
| Workspace | Nodes, wires, rack, radio settings, band-plan region | Keep a whole receiver |
| Template | A ready-made receiver, built in | Start a common setup |
| Example | A whole working workspace, built in | Learn which nodes a job needs |
| Preset | Settings and channels of every open radio | Get back to a known state |
| Bookmark | A frequency, a name, and an optional mode | Retune quickly |
Workspaces
Create, switch, rename, duplicate, export, and delete workspaces from the name in the top bar. A new database starts with a Device wired to a Scope, and a Speaker. Later workspaces start empty. Changes save automatically.
One workspace runs per server. Switching changes it for every client, including phones, which can switch it too. When others are connected you confirm first, and they see who switched.
Working together
Everyone on the same server edits the same workspace. The top bar shows who is here; click your own circle to set your name. Other people's pointers, selections, and drags show live on the canvas. Edits to different nodes, or different settings of one node, never overwrite each other. When two people change the same setting, the later one wins.
Undo
Use the top-bar arrows, Ctrl/⌘ Z, and Ctrl/⌘ Shift Z. Undo takes back your own last change
and keeps what others did since. It changes the running receiver: undoing an added channel closes
it, and undoing a dial move tunes back. Moves of one control within a second count as one step.
The server keeps 100 steps per workspace.
Copy and paste
Select nodes, then Ctrl/⌘ C and Ctrl/⌘ V. Copies land beside the originals with the wires
between them. A pasted Device node needs its radio, recording or generator picked again. The
clipboard works across workspaces while the tab stays open.
Notes
A Note node, under Tools, holds markdown. Double-click it to write, click away to keep. It travels with the workspace and its exports. Pin it to the rack to keep it in view.
Export and import
The download button beside a workspace saves it as JSON, with its tuning. Import a workspace file adds it as a new workspace and switches to it. Radios that are present open with the saved settings. Missing ones stay disconnected and show radio not connected, so you can pick replacements.
Templates
Select a Device, or have only one, then open Library → Templates and press Apply. A template retunes that radio, sets its rate, and adds channels and outputs. Templates the radio cannot run are greyed out.
Undo removes the added nodes but leaves the radio's new frequency and rate.
Examples
Library → Examples holds whole workspaces. Import adds one as a new workspace and switches to it. A template changes the radio you have; an example brings its own.
Each row names what the example needs and the nodes it uses, so you can find one that shows a node you have not used. An example imports even without its radios: its Device nodes stay empty until you pick replacements. Each carries a Note explaining its nodes.
No radio runs on the signal generator, so it works before any hardware arrives.
Presets
A preset saves every open radio as it is now: its settings and its channels. Applying it puts them back on the matching radios open in the active workspace. It does not add or remove nodes.
Bookmarks and band plans
Bookmarks saves the selected Device's or channel's frequency under a label and tunes it back.
Bands picks the workspace's band-plan region and searches its allocations. The default is ITU Region 1. A hit tunes the selected Device, or the selected channel, moving its radio if needed. Ruler in Bands, or Band plan in a Scope's settings, draws allocations on every Scope. Hover one for details, click it to tune.
Radios
A Device node opens one radio: over USB, over the network, or through SoapySDR. It also plays recordings and generates test signals, see Other sources.
Supported radios
Packaged builds include every driver here except CR-8:
| Radio | Connects over | Needs |
|---|---|---|
| RTL-SDR | USB | Nothing |
| KrakenSDR, KerberosSDR | USB | Nothing |
| HackRF | USB | Nothing |
| Airspy R2, Mini, HF+, HF+ Discovery | USB | Nothing |
| FUNcube Dongle Pro+ | USB | WinUSB on Windows |
| ESP32 with ESP-SDR | USB serial, Ethernet (S31) | ESP-SDR firmware |
| AntSDR | Ethernet or USB | Nothing |
| ADALM-Pluto, other AD936x boards | USB or Ethernet | Nothing |
| SDRplay RSP1, RSP1A, RSP1B, RSP2, RSPduo, RSPdx, RSPdx-R2 | USB | SDRplay API 3.15+ |
| SDRplay on another machine | Network | SDRconnect there |
| Aaronia SPECTRAN V6, V6 ECO | USB | RTSA-Suite PRO, Windows or Linux |
| KiwiSDR | Network | Nothing |
rtl_tcp and SpyServer servers | Network | Nothing |
| Dragon Labs CR-8 | USB | Vendor library, a build with cr8 |
| bladeRF, LimeSDR, USRP, others | USB | A SoapySDR module |
Making a radio? Write to hi@jhaag.me to get it supported and tested.
Compare radios
| Radio | Tunes | Max rate | Bandwidth | Bits | RX | TX | DC spike |
|---|---|---|---|---|---|---|---|
| RTL-SDR | 24 MHz to 1.766 GHz | 3.2 MS/s | 3.2 MHz | 8 | 1 | 0 | Small |
| RTL-SDR Blog V4 | 500 kHz to 1.766 GHz | 3.2 MS/s | 3.2 MHz | 8 | 1 | 0 | Small |
| KrakenSDR lane | 24 MHz to 1.766 GHz | 2.56 MS/s | 2.56 MHz | 8 | 1 | 0 | Small |
| HackRF One | 1 MHz to 6 GHz | 20 MS/s | 20 MHz | 8 | 1 | 1 | Yes |
| Airspy R2 | 24 MHz to 1.8 GHz | 10 MS/s | 9 MHz | 12 | 1 | 0 | No |
| Airspy Mini | 24 MHz to 1.8 GHz | 10 MS/s | 6 MHz | 12 | 1 | 0 | No |
| Airspy HF+ Discovery | 1 kHz to 31 MHz, 60 to 260 MHz | 768 kS/s | 660 kHz | 16 | 1 | 0 | Some rates |
| FUNcube Dongle Pro+ | 150 kHz to 260 MHz, 410 MHz to 2.05 GHz | 192 kS/s | 192 kHz | 16 | 1 | 0 | Yes |
| ESP32 with ESP-SDR | 2.4 GHz, 5 GHz on C5 | 80 MS/s, bursts | Per chip, up to 69 MHz | 8 or 10 | 1 | 0 | Yes |
| AntSDR E200, E310 | 70 MHz to 6 GHz | 61.44 MS/s | 56 MHz | 12 | 2 | 2 | Yes |
| ADALM-Pluto | 325 MHz to 3.8 GHz | 61.44 MS/s | 20 MHz | 12 | 1 | 1 | Yes |
| SDRplay RSP1, RSP2 | 10 kHz to 2 GHz | 10.66 MS/s | 8 MHz | 12 | 1 | 0 | Corrected |
| SDRplay RSP1A, RSP1B, RSPdx, RSPdx-R2 | 1 kHz to 2 GHz | 10.66 MS/s | 8 MHz | 14 | 1 | 0 | Corrected |
| SDRplay RSPduo | 1 kHz to 2 GHz | 10.66 MS/s | 8 MHz | 14 | 2 | 0 | Corrected |
| Aaronia SPECTRAN V6 | Per model | Per model and clock | Rate / 1.5 | 16 | 1 | 1 | Unknown |
| Aaronia SPECTRAN V6 ECO | Per model | 61.44 MS/s | 40 MHz | 16 | 1 | 1 | Unknown |
| KiwiSDR | 0 to 30 MHz | 20 kS/s | 20 kHz | 14 | 1 | 0 | No |
| Dragon Labs CR-8 | 24 MHz to 1.766 GHz | 12.5 MS/s | 8 MHz | 12 | 8 | 0 | Unknown |
| bladeRF 2.0 micro | 70 MHz to 6 GHz | 61.44 MS/s | 56 MHz | 12 | 2 | 2 | Yes |
| LimeSDR USB | 100 kHz to 3.8 GHz | 61.44 MS/s | 61.44 MHz | 12 | 2 | 2 | Yes |
| LimeSDR Mini 2.0 | 10 MHz to 3.5 GHz | 30.72 MS/s | 30.72 MHz | 12 | 1 | 1 | Yes |
| USRP B200, B205mini | 70 MHz to 6 GHz | 61.44 MS/s | 56 MHz | 12 | 1 | 1 | Yes |
| USRP B210 | 70 MHz to 6 GHz | 61.44 MS/s | 56 MHz | 12 | 2 | 2 | Yes |
- Max rate is what the radio delivers. USB or Ethernet often carries less: a Pluto over USB streams about 4 MS/s without loss.
- Bandwidth is the widest span received at once, set by the analog filter or the rate. RSPduo with both tuners gets 1.536 MHz each.
- Bits are the ADC's. SDRplay drops to 12 bits above 6 MS/s and to 8 above 9.2 MS/s. The HF+ sends 16 bits after its own decimation.
- RX counts lanes one Device node streams.
- TX counts transmit channels on the hardware.
- DC spike: Yes and Small start with DC block on, except through SoapySDR. Corrected means the SDRplay API removes it. On HF+ it appears only at rates that leave a spike at the centre.
- Boards with an AD9363 instead of an AD9361 tune 325 MHz to 3.8 GHz.
Connect a radio
USB
Plug it in and it appears in the Radios tab of every empty Device node.
On Linux, install your radio's udev rules and add the server's user to the group they name, usually
plugdev. Reload udev and replug the radio. SDRmm never needs root. For containers, see
USB devices.
Network radios
On an empty Device node, open the Network tab, pick the protocol under Via, and enter
host:port. Without a port it uses the default:
| Protocol | Default port |
|---|---|
rtl_tcp | 1234 |
| SpyServer | 5555 |
| SDRconnect | 5454 |
| KiwiSDR | 8073 |
| AntSDR / Pluto | 30431 |
| ESP-SDR | 80 |
The address becomes the radio's identity in the workspace. The bookmark button next to Add saves it; saved radios are listed above the form.
Network IQ uses a lot of bandwidth. When the link cannot keep up, the node shows Lost with the share of samples missing: lower the rate.
Radio missing
Press Check hardware on an empty Device node, or run:
sdrmm --doctor
Both list compiled drivers, loaded libraries, SoapySDR modules, found radios, data paths, and Linux USB permissions. See also Troubleshooting.
Device controls
Controls mean the same thing on every radio:
| Control | Sets |
|---|---|
| Rate | Sample rate |
| BW or Filter | Analog bandwidth before sampling. BW has an auto setting. |
| Antenna | Input port, when there is a choice |
| Auto | AGC, on the gain row. The slider shows the gain the radio chose, where it reports it. |
| AGC | AGC mode, on radios that have several |
| LNA, Mixer, VGA, IF, RF, Tuner, Attenuator | One gain stage each, in dB or firmware steps |
| Amp | A switchable preamp |
| Bias tee | Power on the antenna port for an active antenna or LNA |
| PPM | Crystal correction |
| Conv | Local oscillator of an up- or downconverter, in MHz |
| DC block | Removes the radio's own DC spike |
On a radio with several lanes, each lane's gain row is labelled iq1, iq2, and so on, and the
All row sets every lane. On radios that can choose, the −/+ buttons on that row set how many
lanes stream. The footer shows Queue delay, Drops, Lost samples, and Clipping.
With Conv set, every frequency shown is the one at the antenna. Enter a positive value for a downconverter, like 9750 for a Ku-band LNB, and a negative one for an upconverter, like -125 for a Ham It Up.
Settings only one radio has appear as extra chips. Some change the others: RTL-SDR direct sampling changes the tuning range. Radios that send are covered in Transmit.
Calibration
PPM and the converter offset belong to the radio, not the node: set them once and every Device node that opens that radio uses them. A USB radio is known by its serial, a network radio by its address. RTL-SDRs need serials of their own.
RTL-SDR
The built-in driver needs an R820T or R828D tuner; E4000, FC0012, FC0013, and FC2580 dongles are refused. On Windows, install the WinUSB driver with Zadig.
| Control | Does |
|---|---|
| Tuner | Gain, in the tuner's own steps: 20 dB on an R820T becomes 19.7 dB |
| Auto | Tuner AGC |
| Bias tee | Antenna-port power |
| Direct sampling | off, i, or q. Not on the RTL-SDR Blog V4 or V4 Lite, which upconvert HF. |
Rates: 225 to 300 kHz, or 900 kHz to 3.2 MHz. BW: 290 kHz to 8 MHz.
Serials
Many dongles ship with the serial 00000001. Two dongles with one serial are told apart by USB
port instead, shown with (bus/address) after the name, and their settings and
calibration can follow the wrong one after a replug.
SDRmm asks once when it finds such a dongle. Yes writes a random serial, or one you type, to its EEPROM. Replug it afterwards. The dongle must be closed, and one without an EEPROM cannot keep a serial.
KrakenSDR
Five tuners on one clock, a Device each; KerberosSDR has four. SDRmm knows them by serial and USB
hub, so the vendor Pi image is not needed, and names them KrakenSDR <hub> lane 1 to lane 5.
Add a Device per lane and wire them to one Array in antenna order. Each
lane reports the bank it shares, so the Array takes Shared clock from the hardware and not from
what you declare. There is no direct sampling, and the rate tops out at 2.56 MS/s.
The lanes share a clock, not a phase. The Array switches on the built-in noise source to solve delay, phase, and gain after every retune and gain change, and checks them every minute. The antennas are cut off while the source is on; processors pause.
- Switches: the noise source and all five bias tees hang off lane 1's tuner, so lane 1 has to be open and in the Array to move them.
- Gain: lane phase moves by up to about 10° across the gain steps. The Array solves at the gain you run.
- Clipping: the noise source clips at higher gain. Past 75% clipped samples the Array shows
Noise clips, lower gain. - Starts: the lanes start one by one, so the Array searches its offsets on the first lock and again whenever a lane restarts.
- Noise source: the Array needs every lane of the bank to run it, or it shows
Noise needs all lanes.
A lane that is missing shows up in sdrmm --doctor under Coherent banks.
Hardware tests has measurements.
Tested on hardware provided by KrakenRF. Thank you.
HackRF
| Control | Does |
|---|---|
| LNA | Gain in 8 dB steps |
| VGA | Gain in 2 dB steps |
| Amp | +14 dB RF amplifier |
| BW | Baseband filter, or auto |
| Bias tee | Antenna-port power |
Airspy
Built in, no vendor library needed. To use SoapySDR instead, build without airspy and airspyhf.
R2 and Mini: tunes 24 MHz to 1.8 GHz. LNA, Mixer, and VGA gain use firmware steps, not dB. AGC can run the LNA, the mixer, or both. Bias tee available.
HF+ and HF+ Discovery: tunes up to 31 MHz and 60 to 260 MHz. Controls are Amp (+6 dB), Attenuator in 6 dB steps down to -48 dB, AGC with a low or high threshold, and PPM, which starts from the calibration stored on the radio. A centre below 180 kHz (84 kHz at the narrower rates) tunes to that floor, and the band still shows it. DC block appears only at rates that leave a spike at the centre.
Tested on hardware provided by Airspy. Thank you.
FUNcube Dongle Pro+
Built in. Controls are LNA and Mixer (on or off), IF gain 0 to 59 dB, IF filter (BW, 200 kHz to 8 MHz), RF filter (auto or a fixed band), bias tee, and PPM. Rate is 192 kS/s. With LNA and Mixer on, IF gain above about 25 dB can overload it.
The dongle presents itself as a sound card, so each system needs one step:
- Linux: SDRmm reads it over raw USB and takes it from the sound and HID drivers while it is
open. The user needs access to the USB node, for example with
SUBSYSTEM=="usb", ATTRS{idVendor}=="04d8", ATTRS{idProduct}=="fb31", MODE="0660", GROUP="plugdev". - Windows: raw USB through WinUSB. In Zadig, turn on List All Devices, turn off Ignore Hubs or Composite Parents, pick FUNcube Dongle V2.0 (Composite Parent), and install WinUSB. It then no longer shows up as a sound card.
- macOS: samples come through Core Audio, since macOS keeps raw USB for its own driver. Allow
Microphone for SDRmm (or the terminal running
sdrmm) in System Settings, or the node reports silence.
Tested on hardware provided by FUNcube Dongle. Thank you.
ESP-SDR
An ESP32 running ESP-SDR firmware receives with its Wi-Fi radio. Flash it with the browser installer.
It captures bursts, not a stream: 4096 samples by default, then a pause while they cross the serial link, about 0.1 s at 921.6 kBd. Longer bursts update the Scope less often. The timeline marks each gap.
| Control | Does |
|---|---|
| Frequency | 100 MHz to 6 GHz in 1 MHz steps; only 2.4 GHz (and 5 GHz on C5) is reliable |
| Rate | 80, 40 or 16 MS/s on ESP32, per chip otherwise |
| Tuner | Gain index, not dB. Auto is the hardware AGC. |
| BW | Analog bandwidth, or auto for widest |
| Bits | 8 or 10 bits per sample; 10 is slower over serial |
| Burst | Samples per capture |
SDRmm finds the firmware at 2 MBd, 1 MBd or 921.6 kBd. A CP2102 bridge cannot reach 1 MBd, so build
the firmware with CONFIG_ESP_SDR_UART_BAUD=921600 for those boards. On Linux, add the server's
user to dialout.
Ethernet: an ESP32-S31 board with the esp32s31-stream firmware streams without gaps: 2150 to
2800 MHz, 4 to 40 MS/s, 8 bits. Add it under Network as ESP-SDR with the IP its serial log
prints. It needs a Gigabit link for 40 MS/s.
AntSDR
SDRmm talks to the iiod server of the AntSDR's Pluto firmware directly, with no libiio. The E310
has two receive lanes on one synthesizer, so they are phase coherent. UHD firmware is not
supported. MicroPhase ships UHD images for the E200 and E316 only; the e310v2 target in
antsdr_uhd is the E316 and does not run on an E310.
USB: connect the USB 2.0 port and the board appears with no network setup. Windows needs the PlutoSDR drivers.
Ethernet, direct cable: the board sits at 192.168.1.10. Give the computer's Ethernet port a
fixed address in the same range once, and leave the router empty so the internet stays on Wi-Fi:
| System | Where |
|---|---|
| macOS | System Settings, Network, the Ethernet adapter, Details, TCP/IP. Configure IPv4 Manually, IP 192.168.1.100, subnet mask 255.255.255.0 |
| Windows | Settings, Network & internet, Ethernet, IP assignment, Edit. Manual, IPv4 on, IP 192.168.1.100, subnet mask 255.255.255.0 |
| Linux | nmcli connection add type ethernet ifname <port> con-name antsdr ipv4.method manual ipv4.addresses 192.168.1.100/24 |
Ethernet, through your router: if your network already uses 192.168.1.x and nothing else sits
at .10, plug the board into the router and it works from every computer on it. Otherwise give the
board a free address in your range: connect it over USB, open the drive it shows, set
ipaddr_eth and netmask_eth in config.txt, and eject. Over SSH the login is root /
analog.
Empty Device nodes look for a board at ant.local, 192.168.1.10, pluto.local, and
192.168.2.1; enter any other address in the Network tab. If nothing is found,
ping 192.168.1.10: no answer means the cable or the computer's address.
Ethernet carries far more than USB 2.0, and two lanes split the link. The RX1 and RX2 toggles pick which receivers stream, one or both. The E310 locks its antenna and TX ports in firmware, so those menus are hidden. The other controls are the AD936x ones.
Faster streaming: sdrmm-iqlinkd runs on the board and sends samples over UDP, about 20 MS/s
on one lane instead of 15 with iiod. SDRmm uses it by itself when it answers on port 30432, and
iiod otherwise. Install it once:
rustup target add armv7-unknown-linux-musleabihf
cargo build --release --target armv7-unknown-linux-musleabihf -p sdrmm-iqlinkd
scp -O target/armv7-unknown-linux-musleabihf/release/sdrmm-iqlinkd root@192.168.1.10:/mnt/jffs2/
ssh root@192.168.1.10 'echo "/mnt/jffs2/sdrmm-iqlinkd > /tmp/iqlinkd.log 2>&1 &" > /mnt/jffs2/autorun.sh; reboot'
Tested on hardware provided by MicroPhase. Thank you.
PlutoSDR and other AD936x boards
Talks to iiod directly over USB or Ethernet, with no libiio or SoapySDR. USB boards appear
automatically, and network boards at pluto.local, 192.168.2.1, ant.local, or
192.168.1.10.
The board reports its range: typically 70 MHz to 6 GHz on an AD9361, 325 MHz to 3.8 GHz on an AD9363. Rates run from about 260 kS/s to 61.44 MS/s; below 2.08 MS/s the FPGA decimates. The link sets the real limit. On a 2×2 board both RX lanes share a clock and are phase coherent.
| Control | Does |
|---|---|
| −/+ | 1 or 2 lanes on a 2×2 board. Starts at 1, which gets the whole link |
| Tuner | Receive gain per lane. The range follows the band |
| TX | Transmit attenuation per lane |
| AGC | Per lane: fast attack, slow attack, or hybrid |
| Filter | Analog bandwidth |
| Tracking | Quadrature, RF DC and baseband DC corrections |
| Tone | Two DDS test tones on every TX lane, with offset and level. Transmits without a stream |
| AGC | High and low thresholds of the gain loop, in dBFS |
| Chip | Rate governor, calibration (auto or manual), and state: run, alert, wait or sleep |
| LO | External RX or TX LO |
| Antenna, TX port | Shown only if the board lets the port change |
The face shows chip and FPGA temperature and RSSI per lane, refreshed every second.
A frequency tuned before is recalled from one of 8 fast-lock profiles instead of recalibrating the synthesizer, so a scanner hopping between known channels retunes in about 2 ms instead of 10. The TX synthesizer sleeps while nothing transmits and no tone is on.
Linux needs the libiio udev rules. sdrmm --doctor checks for them.
SDRplay
Install the SDRplay API 3.15 or newer and keep
sdrplay_apiService running. No SoapySDR module needed. If an RSP is missing, see the SDRplay
API section of sdrmm --doctor. For containers, see
SDRplay receivers. For NixOS, see
Nix.
Both gain sliders raise gain when moved up:
| Slider | Sets |
|---|---|
| RF | LNA gain. The steps depend on frequency, port, and HDR mode. |
| IF | 0 to 39 dB |
AGC runs the IF gain at 5, 50, or 100 Hz.
Rates run from 62.5 kS/s to 10.66 MS/s on one tuner.
RSPduo: each mode is its own entry: Tuner 1, Tuner 2, Dual Tuner, Master (on either tuner), and Slave. Modes in use by another program are hidden. Dual Tuner gives two independent streams at up to 2 MS/s each. Slave waits for a master program, which owns the clock.
SDRconnect
Reach an RSP on another machine through SDRconnect, with no
local SDRplay API. Enable its WebSocket API, or run SDRconnect_headless --websocket_port=5454. On
a Device node pick Network → SDRconnect and enter host:5454, or host:5454/secondary for an
RSPduo's second tuner.
The link is unencrypted ws://. Use it on a trusted network or through a tunnel.
SDRmm receives raw IQ and does its own demodulation. Settings:
| Setting | Does |
|---|---|
| LNA | RF gain over the LNA states; lower means more gain. There is no IF gain. |
| Device VFO | SDRconnect's VFO inside the sampled window |
| Filter | SDRconnect's channel filter |
| Receiver | Which radio: name, slot, or serial |
| Network mode | Stream quality |
| Device profile | Load a saved SDRconnect profile |
| Recording | Record on the SDRconnect machine |
The driver follows the public SDRplay API specification. No vendor code is included.
Aaronia SPECTRAN V6
Install the RTSA-Suite PRO. SDRmm loads its RTSA API at
runtime; nothing else is needed. Aaronia ships no macOS version. On Linux, run the suite's
setup_udev.sh once. Set SDRMM_AARONIA_DIR to the suite's folder if it is not in the default
place, and check it with sdrmm --doctor. Close the RTSA-Suite before opening the radio here.
| Control | Sets |
|---|---|
| GAIN | The reference level, reversed: 20 dB gain is a -20 dBm reference, which reads as full scale |
| Antenna | Rx1 or Rx2, V6 only |
| Amplifier, RX filter, USB compression | As the radio lists them |
The rate is the span times 1.5, so the clean part is two thirds of the rate. The slowest receiver clock that carries the rate is picked for you.
Transmit is half duplex: the radio reopens as a transmitter and back. Full scale sends 0 dBm before the TX gain.
The driver follows the public RTSA API interface. No vendor code is included.
KiwiSDR
Pick Network → KiwiSDR and paste the receiver's address, http:// or https://. Public
receivers are listed at rx.kiwisdr.com. A private Kiwi, or one whose time
limits a password lifts, takes password@host:8073. The password becomes part of the radio's
address in the workspace.
A Kiwi streams 12 or 20 kHz of IQ anywhere in 0 to 30 MHz: enough for SSB, CW, AM and the narrowband decoders. Wider channels show the width they need. Gain is the Kiwi's AGC or a manual RF gain.
Public Kiwis are shared. When one is full, kicks you, or hits its time limit, SDRmm stops and does not reconnect. A dropped connection is retried.
Dragon Labs CR-8
Eight phase-coherent lanes on one Device, iq1 to iq8. All lanes tune together, 24 MHz to
1.766 GHz, at a fixed 12.5 MS/s, with LNA, Mixer, and VGA gain per lane in firmware steps.
Clock source is internal or external.
The packaged builds leave CR-8 out. Build the server with cr8, install the vendor library, and
check it with sdrmm --doctor. Set SDRMM_DLCR_LIBRARY if the library is somewhere unusual.
SoapySDR
SoapySDR covers radios without a built-in driver. Install the core and a module for your radio:
| Radio | Module |
|---|---|
| bladeRF | SoapyBladeRF |
| LimeSDR | SoapyLMS7 |
| USRP | SoapyUHD |
| Remote SoapySDR server | SoapyRemote |
| System | Core | Example module |
|---|---|---|
| Debian, Ubuntu, Raspberry Pi OS | sudo apt install libsoapysdr0.8 | soapysdr-module-bladerf |
| Fedora | sudo dnf install SoapySDR | soapy-uhd |
| Arch | sudo pacman -S soapysdr | soapybladerf |
| macOS | brew install soapysdr | pothosware/pothos/soapybladerf |
| Windows | PothosSDR, default install folder | Included |
| NixOS | soapyPlugins |
Packaged builds load SoapySDR at runtime and work without it. The container ships the core with
bladeRF, LimeSDR, and SoapyRemote modules. A remote SoapySDRServer shows up in the normal radio
list, through SoapyRemote.
SDRmm needs SoapySDR 0.9 or 0.8 and modules built for it. For unusual install locations:
| Variable | Value |
|---|---|
SDRMM_SOAPY_LIBRARY | Full path to the core library |
SOAPY_SDR_ROOT | SoapySDR install prefix |
SOAPY_SDR_PLUGIN_PATH | Extra module folders |
SoapySDRUtil --find shows what SoapySDR itself sees. It knows nothing about the built-in drivers,
which SDRmm prefers when both could open a radio.
SDRmm probes SoapySDR in a child process, so a crashing module cannot take it down. Set
SDRMM_SOAPY_PROBE=in-process to turn that off while debugging.
Other sources
An empty Device node picks these from its own tabs, the same way it picks a radio.
| Tab | Gives |
|---|---|
| Recording | Plays a SigMF recording |
| Virtual | The signal generator: test signals, from a carrier tone to DVB-T and ATV |
Debug builds also list synthetic radios.
Channels
A channel takes a Device's IQ and turns one frequency into audio, messages, or pictures. The channel you pick decides the mode: AM, WFM, ADS-B, and so on. Decoders lists them all.
Add a channel
- Press + Add and pick a mode.
- Wire Device
iqto the channel'siq. - Set the channel frequency.
- Wire the outputs you need:
| Output | Wire to | You get |
|---|---|---|
audio | Speaker | Live sound |
audio | Audio recorder | A WAV file |
audio | Audio FX | Filtered, denoised or levelled sound |
events | Readout | Current state: station text, aircraft table |
events | Decoder log | Message history |
events | Map | Positions |
events | Export | CSV or JSON of logged rows |
events | Event output | Events sent out, call audio saved |
video | Video | ATV, DVB, SSTV, WEFAX and weather satellite pictures |
baseband | Baseband scope, Baseband recorder, or Network IQ | The channel's filtered IQ |
To swap the mode, right-click the channel and choose Replace with…. The frequency and squelch
stay; wires the new mode has no port for are dropped. m and M cycle NFM, WFM, AM and SSB.
Tuning
Use the channel dial, drag its marker on the Scope, or use the keyboard. Scroll or
press the arrow keys over a dial digit to step it. Typed frequencies are in MHz unless you add
Hz, kHz, or GHz. You can set frequencies before a radio is connected.
Auto and manual
Every Device starts on Auto, shown by the lit radar button next to its dial. It places its
window over as many wired channels as its sample rate can hold, and keeps its own DC spike off
them. The count on the Device, such as 2/3, shows how many it hears. To hear more, raise the
sample rate or move some channels to another radio.
Tuning the Device itself, from its dial, the keyboard, or the Scope, asks first and then switches it to Manual. The radio then stays put, and channels outside its window wait until it covers them again. Press the radar button to return to Auto. Use Manual to watch a fixed band, or when no channel is wired.
The lock beside a dial freezes that frequency. Locking a channel does not lock its Device.
Several radios
Wire a channel to more than one Device and it runs on whichever radio hears it. Radios on Auto split their channels so as many as possible are heard. The channel face names the radio carrying it. While a scanner, signal hunt, recording, or network export uses a channel, it stays on its radio until that stops.
Sample rate
Each channel runs at a rate set by its mode. The Device's IQ is resampled to match, so any Device rate works as long as the channel's full bandwidth fits inside the Device's window.
When the Device rate equals the channel rate, resampling is skipped: ADS-B runs at 2.4 MS/s, DAB and GNSS at 2.048 MS/s, ATV at 16 MS/s. Use the lowest rate that covers your signals. It saves USB bandwidth and CPU.
Squelch
Squelch mutes audio when nothing is there. Only channels with audio have it.
| Mode | Opens | Set with |
|---|---|---|
| Off | Always | Slider at far left |
| Manual | Above the slider level | The slider |
| Auto | Margin dB above the measured noise floor, 2 to 40 | The Auto button |
The level meter marks the threshold. Auto learns the floor while the channel is quiet, so a
signal that never stops can be mistaken for noise. Once squelch is open, the floor cannot rise
and cut off a long transmission. Turning Auto off keeps the threshold it reached. Analog channels
report calls on events only with squelch on.
NFM also has Tone squelch:
| Setting | Behaviour |
|---|---|
| Detect | Shows the CTCSS tone or DCS code, never mutes |
| CTCSS | Opens only for the chosen tone |
| DCS | Opens only for the chosen code |
Scrambler undoes voice inversion: set the Carrier, 1.5 to 4.5 kHz, or pick Auto. Compander expands audio 2:1 for links that compress it. Leave it off for ordinary NFM.
Noise blanker
The Blanker on audio channels removes impulse noise from the IQ before filtering. A lower threshold removes more, but can damage the signal.
IF filter
The IF chip shapes the IQ ahead of the demodulator, so a neighbour is gone before it can capture an FM receiver or swamp a decoder.
- Edges move either side of the passband in, one side at a time. Cut the side a stronger adjacent station sits on.
- Notches remove narrow interferers such as carriers and birdies. Each sits at a radio frequency, so it stays on its interferer when you retune.
The spectrum shades the narrowed passband and marks each notch in red.
Audio FX
Wire a channel's audio through an Audio FX node to process what you hear. The raw audio
stays available on the channel's other wires, so a recorder can keep it while a Speaker plays the
cleaned version. Chain several nodes to stack effects. Stages run in this order, all off by
default:
| Stage | Does |
|---|---|
| De-click | Removes short clicks |
| Passband | Cuts audio below and above two frequencies |
| Notches | Removes up to four chosen tones, each with its own width |
| Auto notch | Finds and removes steady tones |
| Denoise | Spectral cuts noise by up to 20 dB, light on CPU. DPDFNet is the strongest speech model, built for 8 kHz radio voice, downloaded on first use. |
| AGC | Levels the volume: Slow, Med or Fast. AM and SSB channels already level their own. |
DPDFNet is trained on speech. Leave it off for music, data tones and CW.
Pick a DPDFNet model, then press the download button next to it. DPDFNet 2 is light; 8 sounds better and costs more CPU. The button next to a downloaded model removes it.
Speaker output
Out on a Speaker picks its sound device. Give each decoder program its own Speaker and virtual device:
| System | Virtual device |
|---|---|
| Linux | pactl load-module module-null-sink sink_name=js8, then read js8.monitor in the program |
| Windows | VB-Cable |
| macOS | BlackHole |
In a browser, press Show device names once to list devices by name.
Filter events
Put an Event filter between a decoder and its outputs. Every rule you set must match.
| Mode | Passes |
|---|---|
| Keep | Only matching events |
| Drop | Everything except matching events |
A rule that does not apply to an event is ignored: a talkgroup rule never judges an aircraft. A
drop filter with no rules drops nothing. Chain filters to combine them, for example keep POCSAG,
then drop messages containing TEST. A filter only affects events that arrive after it is set.
Decoders
Every mode is a channel. This page lists them, says how well each is tested, and covers the modes that need more than a frequency.
Catalog
+ Add lists every mode under Decoders, grouped as below.
| Group | Tested on air | Reference fixture | Own modulator | Experimental |
|---|---|---|---|---|
| Analog voice | AM, NFM, SSB, WFM (broadcast) | |||
| Digital voice | DMR, FreeDV 1600, TETRA, Tetrapol | D-STAR, System Fusion, NXDN, P25 Phase 1, dPMR, M17 | ||
| Aviation | ADS-B (1090ES), ACARS, Inmarsat Classic Aero | Drone Remote ID | VDL Mode 2, High Frequency Data Link | VOR, ILS localizer / glideslope |
| Marine | Digital Selective Calling, Inmarsat STD-C / EGC | AIS, NAVTEX | ||
| Amateur and HF | FT8 | CW skimmer, FT4, WSPR, RTTY, Morse (CW), PSK, APRS / AX.25 with weather | ||
| Paging and telemetry | FLEX pager, Radio clock (DCF77 / WWVB / MSF / JJY) | POCSAG, Selcall (CCIR/ZVEI), ERMES pager | ||
| Pictures and video | SSTV, ATV | |||
| Weather and satellites | Meteor LRPT | WEFAX, NOAA APT, Radiosonde, SAME alerts | ||
| Broadcast digital | DAB / DAB+, DRM30, DRM+ | DVB-T/T2, DATV (DVB-S / S2) | ||
| Utility | Signal identifier, End-of-Train, GSM downlink, LoRa, BLE advertisements, 2.4 GHz survey, Wi-Fi occupancy, HF modems, Wireless M-Bus, ISM sensors | Iridium bursts | DECT, 406 MHz beacons, LoRaWAN, Meshtastic, MeshCore, Noise figure, PER tester, SID monitor | GNSS lab (GPS L1 C/A) |
| Label | Means |
|---|---|
| Tested on air | Verified on a real off-air signal, live or recorded |
| Reference fixture | Verified on another implementation's waveform or published vectors |
| Own modulator | Verified only against this project's own modulator |
| Experimental | Works with the limits below |
A fixture catches decoding bugs but says little about drift, fading, or interference; the project's own modulator says nothing about what a real transmitter sends. The fixture library lists where each recording came from.
Experimental limits
| Mode | Works | Missing |
|---|---|---|
| DVB-T/T2 | DVB-T HP and LP, T2-Base and Lite, SISO and MISO, 1K to 32K, PLP choice, audio, video | GSE, multi-RF TFS |
| GNSS lab | GPS L1 C/A acquisition, tracking and navigation data, one PRN per channel | Position fix |
Trunking
Add Trunk system, wire Device iq, and enter the control channel in MHz under Control.
Set Protocol or leave it on Auto-detect. The node runs the channels it needs on the
decoder picked under Decoder: DMR by default, or P25 Phase 1.
| Protocol | Finds its channels by |
|---|---|
| DMR Tier III / Capacity Max | Reading the channel plan from the control channel |
| DMR Capacity Plus | Search: carriers that follow the same rest channel |
| Hytera XPT | Same as Capacity Plus |
| P25 trunking | Reading the channel identifiers from the control channel |
Range narrows the search. Channels can also be entered in the plan table. Learned channels are kept per site, by DMR colour code or P25 NAC.
Priority talkgroups take a receiver from other calls when all are busy, Lockout talkgroups are never followed, and Only these follows just the priority list. Each followed channel shows the talkgroup it was granted to. A control channel that goes quiet hands over to the site's other control channels the search found.
Following runs on the server with no browser open. Voice channels must fit inside the Device's window; a grant outside it is reported. To save call audio, see Record each call.
Voice privacy
Enter each key ID and its key under the decoder's Keys. The key's length picks the algorithm:
| Decoder | Hex digits |
|---|---|
| DMR | 10 ARC4, 32 AES-128, 64 AES-256 |
| P25 Phase 1 | 10 ADP, 16 DES-OFB, 32 AES-128, 64 AES-256 |
| NXDN | up to 4 scrambler, 16 DES-OFB, 64 AES-256 |
DMR Motorola Basic Privacy needs no key: the decoder reads it off the first silence frame,
marked Basic Privacy and the key. A deciphered call names its cipher. A trunk node takes its
own Keys, as key ID:hex key, for the calls it follows. A DMR call joined after its PI
header starts once two superframes have carried their late-entry MI, if exactly one key fits.
Calls without their key stay muted.
Vocoder chip
DMR, NXDN, dPMR, System Fusion, P25 Phase 1 and D-STAR voice plays through blip25 in software.
Servers built with the dvsi feature (cargo build -p sdrmm --features dvsi) can use a DVSI AMBE-3000
or AMBE-3003 chip instead (ThumbDV, DV3000U, USB-3000, USB-3003): pick its serial port under
Vocoder. A trunk node has its own Vocoder for the calls it follows.
The server finds the chip at 460800 or 921600 baud and shows its product under Vocoder. Each call takes one chip channel while it lasts: one on an AMBE-3000, three on an AMBE-3003. A call that finds every channel busy, or a chip that stops answering, plays through blip25 and the readout says so.
Tetrapol
The Tetrapol channel reads 12.5 kHz Tetrapol PMR downlinks. Tune the carrier; the scrambling code is found on its own, or set Scrambling to pin it. Pick VHF for 70 to 87 MHz systems.
On a control channel it logs cell system info, group activations with the traffic channel and its scrambling codes, group paging and the paging channel. On a traffic channel it logs group idle and end messages and the length of each voice burst; voice audio is not decoded. Frames that fail their checks count under Rejected. Uplinks are not decoded.
Inmarsat and Iridium
Inmarsat Classic Aero reads one Channel: P to aircraft, R/T bursts from aircraft, or C voice circuit signalling. It plays no audio.
Iridium decodes one 50 kHz channel. A Span of 1, 2.5, 5 or 10 MHz decodes bursts across a radio running at that sample rate, using the middle 80%.
Inmarsat STD-C draws each EGC call's sea area on a wired Map: a NAVAREA or METAREA by its limits, a coastal call by its NAVAREA, and rectangles and circles as sent. NAVAREA limits are simplified from the United States Coast Guard layer.
Pager text
Some German POCSAG networks send umlauts as { | } [ \ ] ~. SDRmm converts them inside
lowercase words only: M}nchen becomes München, Stra~e becomes Straße. [ALARM] and
all-caps messages stay as sent.
Swissphone pagers can carry IDEA-encrypted text. Enter the message key of each RIC and the
organisation code from the pager programming (PSW900) under the POCSAG settings, and those
messages decode as text marked IDEA. The seed includes the local date and hour, so the
host clock, or a recording's capture time, has to be within the hour of the transmission.
The pager checks twelve header bits that depend on no key; a page that fails them is dropped and
counted as rejected. A wrong key gives noise, not an error.
End-of-Train
The End-of-Train channel reads railroad telemetry. Tune 457.9375 MHz for the rear unit: brake pipe pressure, motion, marker light, battery and arming. Tune 452.9375 MHz for the locomotive: status requests and emergency brake commands. Australia uses 477.7 MHz, New Zealand 450.2625 MHz. Only frames whose BCH check passes are logged; Rejected counts the ones that failed.
ISM sensors
The ISM sensors channel reads OOK and FSK weather stations, thermometers, tyre sensors, remotes and door sensors on 315, 345, 433.92, 868 and 915 MHz. It splits each burst into pulses, slices them like rtl_433 and tries every decoder below. The channel is 240 kHz wide, so sensors a little off centre still decode. A repeat of the same message within 2 s is logged once.
| Family | Models |
|---|---|
| Acurite | Tower, 5n1, 606TX, 609TXC |
| LaCrosse | TX141 / TX141TH, TX29IT, TX35DTH-IT |
| Fine Offset, Ambient Weather | WH2, WH5, WHx080, WH24, WH65B, WH31E, WH31B, WH40, F007TH |
| Oregon Scientific | THGR122N, THGR228N, THGR968, THN132N, WGR800, THGR810 |
| Bresser | 5in1, 6in1 |
| Others | Nexus, Prologue, Rubicson, Alecto V1, ThermoPro TP12, Schrader TPMS, Honeywell / 2GIG, Kerui, PT2262 remotes |
Unknown logs a code no decoder knows when the same row repeats, with its coding and pulse widths. A burst whose checksum fails in every decoder counts under Rejected.
Wireless M-Bus
Reads water, heat, gas and electricity meters (EN 13757-4). One channel decodes modes T and C on 868.95 MHz; tune 868.3 MHz for mode S, tested only against our own modulator. Each frame shows the meter's manufacturer, ID and type, and the repeater it came through.
Most meters encrypt their readings. Add the meter ID and its AES key under Keys to open them; OMS security mode 5 and Kamstrup's ELL encryption are supported. Plain and decrypted frames list their records: volume, energy, power, flow, temperatures and dates. Only frames whose CRCs pass are logged; Rejected counts the ones that failed after a valid header.
406 MHz beacons
The 406 MHz beacon channel reads first-generation COSPAS-SARSAT EPIRBs, ELTs and PLBs. Each sends a half-second burst every 50 seconds. The default channel spans 20 kHz: centre it on 406.034 MHz to cover 406.025 to 406.040 MHz. It logs the 15-hex ID, country, protocol, beacon identity and, for location protocols, the encoded position, which the Map plots under Beacons. Self-test bursts are marked. A frame whose first BCH field fails counts under Rejected; a long frame whose second field fails is logged as PDF-2 failed with the coarse position only. Second-generation beacons are not decoded.
SSTV
Tune SSTV to the USB dial frequency. A picture takes from 36 seconds to four and a half minutes.
| Setting | Does |
|---|---|
| Mode | Follow VIS reads the mode from the transmission. Pick one when the header was missed. |
| Slant | Straightens pictures from a sender whose clock runs off. Leave it on. |
| Partial | Keeps a picture cut short by a fade |
Modes: Robot 36 and 72, Martin M1 and M2, Scottie S1, S2 and DX, PD50, PD90, PD120, PD180, Wraase SC2-180.
Wire video to Video to watch a picture arrive. Finished pictures show in the Video node's
gallery and are kept as PNG on the server for 24 hours, up to 512 pictures or 256 MB. Without a
video wire nothing is kept.
Weather satellites
Meteor LRPT decodes the digital picture from Meteor-M on 137.9 MHz. It composes channels 64 and 65 into colour, or shows the strongest single channel. Set Mode to QPSK 72k, OQPSK 72k or OQPSK 80k to match the satellite; Meteor-M N2-4 sends OQPSK 72k.
NOAA APT decodes the analog picture on 137 MHz: both AVHRR channels side by side, with the channel numbers read from the telemetry wedges. Partial keeps a pass cut short. NOAA-15, 18 and 19 were decommissioned in 2025, so this is for recordings.
A picture starts when the signal locks and is saved when the pass ends, in the same store as SSTV. Use a Satellite node to follow Doppler and know when a pass begins.
WEFAX
Tune WEFAX to the USB carrier, 1.9 kHz below the published frequency. The start tone picks the IOC, phasing lines set the line start and straighten the slant, and the stop tone ends the chart.
| Setting | Does |
|---|---|
| IOC | 576 or 288, used when a chart starts without a start tone |
| LPM | Lines per minute: 60, 90, 120 or 240. Most stations use 120. |
| Partial | Keeps a chart cut short by a fade |
Radiosondes
The Radiosonde channel reads RS41, DFM, M10, M20 and iMet-4 weather balloons. Sonde on Auto runs every type at once. Each frame gives serial, position, altitude, climb and, where the sonde sends it, temperature, humidity and pressure. Wire it to Map for the flight track and to Decoder log or Readout for the readings. RS41 temperature and humidity appear once its calibration data has arrived, about a minute after first lock.
On Map a climbing or falling sonde gets a dashed forecast path, a ring at the burst point and a landing mark. The forecast uses the winds the sonde measured on the way up. Burst is the altitude assumed while it still climbs; Ground is the altitude it lands at.
FT8, FT4 and WSPR
Each decode carries its UTC Slot and a Time offset (DT) from it, read from the time the radio's samples arrived, as WSJT-X reads its sound card. Keep the server clock synced (NTP). A recording played back is timed by when it plays.
A station heard in one period is looked for again one period later, at the same frequency, with the messages it is likely to send next. This confirms repeats too weak to decode blind, like the a7 pass in WSJT-X.
SAME alerts
The SAME alerts channel reads the headers NOAA Weather Radio and the Emergency Alert System send
ahead of an announcement. Tune one of the seven NWR channels between 162.400 and 162.550 MHz. Each
alert gives the event code and its name, the originator, the counties it covers, how long it stays
valid and the station that sent it; the closing NNNN burst is logged as its own end-of-message
entry. Wire events to Decoder log to keep them, or to an
Event output to drive your own notifications.
A header is sent three times. Two matching copies settle it, and three copies that differ by a byte settle by majority vote. A burst that never finds a match inside 20 s is dropped and counted in Rejected. The channel reads no audio: add an NFM channel on the same frequency for the forecast voice, which the NOAA Weather Radio template does for you.
County names come from the NWS SAME code list; a code outside it is shown as its six digits.
Map
Wire the events of any decoder that reports positions, such as ADS-B, AIS or APRS, to Map.
Each target draws its track. The gear at the bottom left picks the style, hides tracks, switches to
a globe, or takes a custom URL: an XYZ template (https://…/{z}/{x}/{y}.png) or a MapLibre style URL, with any API key in the URL. The choice is
kept in this browser.
APRS weather
The APRS channel reads weather reports, positioned or positionless, into wind, gust, temperature, rain, snow, humidity, pressure and luminosity in metric units. The APRS readout lists each weather station with its latest values and the extremes seen.
DAB and DAB+
Wire audio to a Speaker. Type limits the choice to DAB or DAB+. Mode picks transmission
mode I to IV, or Auto detects it; it starts on I. Service picks a service; Auto plays the
first playable one. All run at 2.048 MS/s. Tuner offsets up to 40 kHz are corrected and shown as
frequency error.
A Readout shows the dynamic label and slideshow. The Decoder log keeps received MOT objects with a download link. Files are offered for download, never opened in the interface.
Packet services appear in the same list as audio services. IP services emit datagrams, see IP data.
DRM
Wire audio to a Speaker. Mode picks DRM30 (robustness A to D), DRM+ (E) or Auto. Auto
covers the DRM+ width. BW sets the DRM30 channel, 4.5 to 20 kHz; tune to the DRM reference
frequency. Service picks one of up to four services; Auto plays the first playable one.
AAC, HE-AAC, HE-AAC v2 and xHE-AAC play. The dynamic label shows the text message. SDC and text CRC failures count as data failures, broken audio frames as audio failures.
DVB
DVB-T/T2 and DVB-S/S2 play the chosen programme's first audio and video streams. Wire audio to
a Speaker and video to a Video node. Service picks a discovered programme or takes its
number; Auto plays the first.
DVB-T/T2: set Standard and BW. Everything else is read from the signal. Low priority picks the DVB-T LP stream. PLP picks a DVB-T2 pipe, or the first TS pipe if left empty. 1.7 MHz fits a 2.048 MS/s radio.
DVB-S/S2: set Rate from 100 kBd to 4 MBd. The radio must be wider than the carrier, symbol rate × (1 + roll-off): 2.7 MHz for 2 MBd at 0.35. On DVB-S, FEC sets the code rate or finds it on Auto, and roll-off is always 0.35. On DVB-S2, set Roll-off to match the transmitter; the MODCOD is found automatically, including VL-SNR. Stream picks one input stream on a multistream carrier. GSE packets can go to your network, see IP data.
Superframes enables DVB-S2X Annex E, formats 0 to 7. Walsh-Hadamard rows are found automatically. Set Ref code and Data code when the carrier does not use the default scrambling, or turn on Code search to find them on a clean signal.
DECT
The DECT channel surveys base stations (identity, capabilities, security) and plays unencrypted calls.
It needs a radio that reaches 1.9 GHz and is wider than one 1.728 MHz carrier. HackRF and SDRplay work, RTL-SDR does not.
| Setting | Choice |
|---|---|
| Band | EU 1880 to 1900 MHz, or US 1920 to 1930 MHz |
| Span | Carrier: one carrier at 2.304 MS/s. Band: every carrier at once |
| Side | Base, Handset, or Both |
Carriers are 1.728 MHz apart. EU carrier 0 is 1897.344 MHz and the numbers count down. US carriers count up from 1921.536 MHz.
For Band, tune the channel to the band centre: 1889.568 MHz (EU) or 1924.992 MHz (US). The radio needs 20 MS/s for all ten EU carriers and 10 MS/s for the five US ones. Off centre, the channel reads the carriers the radio reaches.
Each record lists the base identity (RFPI), system information, capabilities, advertised and observed security, and handset IDs seen during encryption setup. Capabilities include the extended messages (Q header 4, C and E); DSAA2 and DSC2 come from part 2. Encryption is marked active only after a grant is seen. Advertised support does not prove a call was encrypted, and missing signalling does not prove it was not.
Calls use 32 kbit/s ADPCM (G.726). The channel plays both directions of the first clear call it hears and holds it until it goes quiet. Encrypted bearers stay muted. Voice before the first multiframe marker cannot be descrambled; those frames, X-CRC errors, and late frames are counted in the record.
GSM downlink
The GSM channel logs one cell, like OsmocomBB's cell monitor. Tune it to a downlink carrier (C0); band and ARFCN follow from the frequency. It runs at 541.667 kS/s over 200 kHz.
It locks on FCCH and SCH, then decodes BCCH and CCCH: system information (cell identity, neighbours, CCCH layout), paging identities and immediate assignments.
| Setting | Choice |
|---|---|
| Paging | Show paging, assignments and rejects |
| Follow | Decode assigned SDCCH on this carrier |
Follow decodes a non-hopping SDCCH/8 or SDCCH/4 on the same ARFCN until release, ciphering or silence. Records show LAPDm frames, RR, MM, CC, SS and SMS message names, identities, and the A5 algorithm from a Ciphering Mode Command. Ciphered traffic, hopping channels and other carriers are not decoded. Blocks that fail CRC count as rejected.
TETRA
The TETRA channel reads one 25 kHz downlink carrier. It needs no settings.
| Logged | From |
|---|---|
| Cell | MCC, MNC, colour code, location area, carrier and duplex frequencies |
| Calls | D-SETUP, D-CONNECT, D-TX GRANTED, D-RELEASE with group, caller and talker |
| Traffic | A voice record when a timeslot turns to traffic |
| SDS | Status codes, text, LIP positions on the map, other data as hex |
| Mobility | Location update and group attach messages |
Voice is not decoded. Air-encrypted cells report only their identity. Control blocks that fail their CRC are counted as rejected.
LoRa
The LoRa channel reads LoRa packets and decodes LoRaWAN, Meshtastic and MeshCore inside them. Tune to the centre of the LoRa channel. A sender up to a third of the bandwidth off still decodes, and its clock drift is followed from the preamble.
| Setting | Does |
|---|---|
| Preset | Sets BW, SF, IQ and Protocol for a Meshtastic modem preset, MeshCore EU or US, or LoRaWAN. The frequency stays as tuned. |
| BW | 7.8 to 500 kHz |
| SF | One spreading factor, or All for SF7 to SF12 at once |
| IQ | Inverted hears LoRaWAN downlinks, Both hears both directions |
| Protocol | Auto picks by sync word. LoRa shows bytes only. |
| Keys | Extra channel and session keys |
| Implicit | Payload length for packets sent without a header, then CR and CRC |
Meshtastic's default key on every preset channel name and the MeshCore Public channel are built in. Add others under Keys:
| Key | Fields |
|---|---|
| Meshtastic | Channel name and base64 PSK. An empty PSK is an open channel. |
| MeshCore | Channel name and base64 or hex secret. A #tag name needs no secret. |
| ABP | LoRaWAN DevAddr, NwkSKey and AppSKey: checks the MIC and decrypts the payload |
| OTAA | LoRaWAN AppKey: checks join requests and opens join accepts |
The LoRa readout lists each node heard, keyed by Meshtastic node ID, MeshCore name or LoRaWAN DevAddr. Meshtastic positions, waypoints and map reports and MeshCore adverts with a location appear on Map. Meshtastic direct messages and MeshCore private messages are end-to-end encrypted and show as such. The signal generator sends Meshtastic, MeshCore and LoRaWAN test packets.
Drone Remote ID
The Drone Remote ID channel reads what drones broadcast under ASTM F3411 and EN 4709-002: serial number, position, height, speed, pilot position, operator ID and self ID. It also reads the French beacon format and DJI's own DroneID beacons.
It needs a radio that reaches 2.4 GHz. HackRF, SDRplay and Pluto work, RTL-SDR does not.
| Link | Hears | Rate |
|---|---|---|
| Bluetooth | Bluetooth 4 adverts on one channel | 4 MS/s |
| BT band | Every Bluetooth channel in reach, including long range data | 20 MS/s |
| Wi-Fi | Beacons and NAN frames on one channel, 802.11b and 802.11g | 20 MS/s |
The Drone Remote ID and Drone Remote ID · Wi-Fi templates set this up.
Tune Bluetooth to an advertising channel: 2402, 2426 or 2480 MHz. Tune Wi-Fi to the channel centre, usually channel 6 at 2437 MHz, or 2412, 2462 or 5745 MHz.
Long range drones send their data on any of 37 data channels. BT band covers about 8 of them, so it hears about a fifth of those packets.
A Bluetooth 4 advert carries one message, so the readout joins each drone's adverts into one row keyed by serial number. Map shows the drone and its track; the detail lists the pilot and the distance to them. Frames that fail their checksum after the header are counted as rejected.
Tested on real Bluetooth and Wi-Fi captures and on real Remote ID frames from packet captures. No public IQ recording of a real Remote ID transmission exists yet.
BLE advertisements
The BLE channel lists Bluetooth LE devices: address and its type, name, vendor, services, Tx power, and beacons: iBeacon, AltBeacon, Eddystone, Find My, Fast Pair and Exposure Notification. Scan requests and connection requests show who asked whom.
| Link | Hears | Rate |
|---|---|---|
| Channel | One Bluetooth channel | 4 MS/s |
| Band | Every Bluetooth channel in reach, including long range on the data channels | 20 MS/s |
Tune Channel to an advertising channel: 2402, 2426 or 2480 MHz. A device that repeats itself is shown once a second, with the count of repeats. Packets that fail their CRC are counted as rejected. The BLE devices template sets this up.
2.4 GHz survey and Wi-Fi occupancy
Both read energy only and report once a second. Neither decodes a packet.
2.4 GHz survey sorts every burst by width, length and channel grid into Wi-Fi, Bluetooth, 802.15.4 (Zigbee, Thread), microwave oven, narrowband, wideband and continuous. It reports bursts, airtime, typical length and where they sit. A carrier that never stops shows as continuous.
Wi-Fi occupancy gives the busy time of each 20 MHz Wi-Fi channel fully in view, with its level and noise floor. It works on 2.4, 5 and 6 GHz.
| Span | Rate | Sees |
|---|---|---|
| 20 | 20 MS/s | One Wi-Fi channel |
| 40 | 40 MS/s | Five channels at 2.4 GHz |
| 80 | 80 MS/s | All of 2.4 GHz |
Margin sets how far over the noise floor counts as busy. The floor is learnt in the first eighth of a second and follows the band slowly upward, at once downward. The 2.4 GHz survey and Wi-Fi occupancy templates set them up. The signal generator sends BLE beacons and mixed 2.4 GHz traffic.
Tested on recordings: a USRP X310 at 100 MS/s beside a DJI Mini 4 Pro (RFUAV) and a 40 MS/s Bluetooth capture with OmniSIG labels, where the survey counted 138 Bluetooth bursts against 137 labelled.
HF modems
The STANAG modem identifier names HF data modems from USB audio: STANAG 4285, STANAG 4529, MIL-STD-188-110A serial, STANAG 4539 (110B Appendix C), 39-tone, 16-tone and 2G ALE. Put the channel on the USB dial. Each report gives the modem, its carrier and the frequency; for 110A the rate and interleave come from the preamble. A modem that stops is reported gone.
Window is how much signal each verdict uses, 2 to 12 s; longer finds weaker signals. A busy channel that matches no modem counts as rejected. The HF data modems template sets it up.
Tested on 49 sigidwiki recordings, including all 14 MIL-STD-188-110A rates.
Measurements
Noise figure uses the Y-factor method. Connect a calibrated noise source, set ENR to its value at this frequency and switch it on and off. Each level is averaged over 400 kHz once it holds for half a second; the higher one is the source on. The report gives NF, noise temperature and Y. Keep gain fixed while measuring. The LO spike is left out.
PER tester counts numbered 2-FSK test frames: 32 bit preamble, sync 1ACFFC1D, 32 bit
sequence number, a PRBS9 payload and CRC-16/CCITT. Gaps in the sequence count as missed, a failed
CRC as rejected, and every payload bit is compared against PRBS9 for the BER. A sequence that
jumps back counts as a restart. Set Baud and Payload to match the sender. The signal
generator sends PER test frames at the defaults; lower its level or add noise to find where
frames start to drop.
SID monitor follows the VLF transmitters between 16 and 46 kHz that fall in its 42 kHz window and reports their level and SNR every interval. A level that moves more than Threshold from its 30 minute baseline for 30 s is flagged as a sudden ionospheric disturbance; the baseline holds while it lasts. Leave it running through the day with a long wire or loop.
The Noise figure, PER tester and SID monitor templates set them up. The signal generator also sends a switched noise source and VLF stations around 30 kHz.
Finding signals
| Node | Use it to |
|---|---|
| Scanner | Step through frequencies and stop on activity |
| Signal identifier | Name an unknown signal |
| Spectrum monitor | Catch and decode everything in the Device's window |
| Signal hunt | Walk towards a transmitter by signal strength |
| Signal survey | Map signal strength while you move |
Library → Occupancy shows how busy each frequency on the selected Device has been, hour by hour.
Scan
A Scanner drives one channel, never the radio. On auto tuning the radio follows the channel. Tuned by hand, the radio stays put and the scan skips targets outside its window. The Scanner keeps its settings, lockouts and priority frequencies.
- Add a channel in the mode you want to hear and wire it to a Speaker.
- Add Scanner, wire its
controlto the channel'scontroland the channel's Deviceiqto itsiq. A scanner watching a different lane from its channel is refused. Removing thecontrolwire stops the scan. - Pick a Band, or set each range's From, To and Step. Add range adds another.
- Pick what to Find and press Start scan.
| Find | Does |
|---|---|
| Listed | Stops on the first busy frequency until it goes quiet |
| Strongest | Stops on the strongest signal anywhere in the span |
| All | Visits every busy frequency once for Hold, then reads done |
Busy means at least Over noise above the noise floor (default 12 dB), measured over the channel's own bandwidth. Match the step to the service's channel spacing.
Band lists the bands of the band plan region that have a channel raster. Bands with named channels, such as DAB blocks 5A to 12D or PMR446, scan exactly those channels.
On a hit the channel parks there so you hear it. The scan resumes after 1.5 s of quiet. Skip leaves the current frequency and adds it to Lockouts. Priority frequencies are checked every 2 s and win over anything else, also during a hold when the radio's window covers them. The channel's dial is locked while scanning, and stays on the last frequency after Stop scan.
List every station
The decoder reads the station, not the scanner. To list every DAB ensemble:
- Wire Scanner → DAB channel → Decoder log.
- Pick the Band with the DAB blocks, Find
All, Hold 5 s. - Press Start scan. Each busy block is visited once and the log fills with what the DAB channel decodes there.
Read back a scan
Wire the scanned channel's events to a Decoder log and set its Group:
| Group | One row per |
|---|---|
| List | Decoded frame, newest first |
| Frequency | Frequency, with hits, airtime and the latest decode |
| Station | Station: a plane's ICAO address, a ship's MMSI, a callsign |
An analog channel logs each squelch opening as a call, so Frequency shows how often and how long each frequency was busy. Click Frequency, Hits or Last to sort, and a row to play its latest call. The log node keeps its grouping.
Radios that support it sweep in firmware, shown as Firmware sweep (on by default). Other
channels on that radio pause while it runs. It is refused while the radio records IQ, holds a
pre-roll or exports IQ. The Sweep readout shows the radio's own or
by retuning.
Identify a signal
Add Signal identifier from the Utility channels. It lists each transmission in its window, strongest first, with modulation, bandwidth, SNR, symbol rate, deviation, and burst timing.
| Setting | Does |
|---|---|
| Width | Span to search, up to 192 kHz |
| Every | How long it listens per report. Default 1,000 ms. |
| Detect | How far above the noise a signal must be. Default 8 dB. |
For each signal it suggests likely protocols. A suggestion marked confirmed was proven by a
decoder or a frame sync found in the signal. The others are guesses from the waveform and the band.
A quiet span is reported once, not every interval.
It cannot see signals below the noise, such as spread spectrum, or separate tightly packed HF signals like FT8.
Monitor a band
Wire Device iq → Spectrum monitor → Decoder log. The monitor watches the Device's whole window, finds every transmission, and tries the matching decoders on each one. It adds no channel nodes and never tunes the radio.
Protocols picks what it decodes. Everything is on by default. Pick a preset such as Analog voice, or toggle single protocols. Off protocols are skipped, not logged. Unidentified signals also logs transmissions no protocol matches. Start and Stop limit it to part of the window. Leave them empty to watch all of it.
Decoded messages arrive as they happen. Each transmission also gives one event when it ends, with frequency, bandwidth, confidence, the decoder used, and optional audio. Long transmissions report every 30 seconds. Open an event in the log to play the audio.
| Setting | Does |
|---|---|
| Min | Skips weaker guesses. Default 70%, 0 accepts everything. |
Wire the monitor's events to a Decoder log to attach an 8 kHz WAV clip to each transmission.
Clips stay for up to 24 hours. To keep them, also wire events to an
Event output set to Recordings.
Limits: 32 signals at once, three decoders at a time per signal, two seconds of IQ kept for late decoders. Anything dropped is reported. Pictures and video are not decoded here.
Hunt a transmitter
Signal hunt reads one channel's signal strength fast enough to walk with. Wire its control
to the channel's control, the channel's Device iq to its iq, and press Start hunt. Retune the channel to retune the hunt.
With Clicks on, Geiger clicks speed up as the signal gets stronger.
Sweep for a bearing
With a directional antenna and a phone you get bearings, not only warmer and colder:
- Wire a GPS node with the Phone source to the hunt's
position. - Hold the antenna and the phone together. Mount sets the antenna's direction relative to the phone.
- Press Sweep and turn slowly all the way round. The rose fills in and Peak shows the strongest direction.
- When Sweep reads
Done, the bearing is sent onevents.
Mark sends the direction you face as a bearing. Wire events to a
Triangulation to cross bearings from several places.
End sweep goes back to warmer and colder.
Survey an area
- Add Signal survey and wire Device
iqand GPSpositionto it. - Set Offset inside the Device's window and a measurement Width.
- Wait for a level and a GPS fix, then press Start survey.
- Press Export CSV when done.
Each GPS fix records the peak level in dBFS within the slice, averaged into cells of about ten metres. Keep gain, antenna, and width the same, or the numbers will not compare. Pause before you change the receiver. Offset and width are fixed until you Clear the survey.
The server keeps surveying with the page closed, so a phone can run it as a Survey mission. It keeps up to 5,000 cells in memory and drops the oldest beyond that. A server restart loses them.
Recording and playback
| Node | Records | Wire from | Format |
|---|---|---|---|
| Recorder | The Device's full IQ, with an optional pre-roll | Device iq | SigMF |
| Baseband recorder | One channel's filtered IQ | Channel baseband | SigMF |
| Audio recorder | One channel's audio | Channel audio | 48 kHz 16-bit WAV |
| Event output, Recordings | One WAV per call | Channel events | 8 kHz 16-bit WAV |
A SigMF recording is two files: samples in .sigmf-data, frequency, rate, and time in
.sigmf-meta. Keep them together.
Record on a recorder is a switch saved with the workspace. While it is on, the server records whatever is wired in: it follows rewiring, keeps going with the browser closed, and starts again after a restart.
Decoded messages are not recordings. For those, wire events to a Decoder log.
Record IQ
Wire Device iq to a Recorder, press Record, then Stop. On a multi-lane radio the
wired port picks the lane. Wire GPS position to store the location. The sample rate is locked
while recording.
A radio that restarts or drops samples leaves a gap, not an end: the recording goes on, and each gap starts a new capture segment in the metadata. A clean server shutdown finishes open recordings. Killing the process can leave one incomplete.
Record on a schedule or a pass
Wire a Schedule or a Satellite control to a recorder's control, then
press Record to arm it. It records only while the controller says so.
A Schedule holds windows such as 06:00 to 08:00 on weekdays. A stop before the start runs past
midnight, and the same time for both keeps it on all day. Times follow the Zone you set, or
the server's clock. Wire it to a Device's control too and the radio rests outside its windows.
Pre-roll
Catch a signal after it happened:
- Turn Pre-roll on and set Keep, 1 to 120 seconds (default 10).
- The recorder holds that much IQ in memory from then on.
- Record writes the held seconds first, then keeps going live.
- Stop ends the file. The pre-roll keeps running.
The pre-roll belongs to the workspace: switching away lets the radio go, switching back holds it again.
The buffer uses seconds × sample rate × 8 bytes, up to 1 GiB. Memory shows it. The sample
rate is locked while a pre-roll is held. Retuning starts a new segment in the same recording.
Record a channel
Baseband recorder keeps a channel's IQ after filtering and before squelch. The files are much smaller than full Device IQ and can be played back like any other recording. Changing the mode or the Device rate starts a new file. Removing the channel ends it.
Audio recorder keeps what reaches it, after squelch and any Audio FX it is wired behind. Closed squelch writes silence so timing stays intact; Skip silence pauses instead. Mode and rate changes do not stop it. The file stays playable if the server stops mid-recording.
Both recorders take several channels and write one file per wired channel. Each Audio recorder keeps its own files, even when another one records the same channel.
Record each call
Wire a channel's events and audio to an Event output set to Recordings. Every call
becomes its own WAV in the audio library, named by start time (UTC), mode, and frequency:
20261001T223105Z_NFM_144.200MHz.wav
20260815T100000Z_DMR_451.125MHz_TG91.wav
What makes a call:
| Channel | A call is |
|---|---|
| Digital voice, trunk system | One call, from header to end |
| AM, NFM, SSB, DECT | One squelch opening. Gaps under 1.5 s stay in the call |
An analog channel with squelch off has no calls; use the Audio recorder for continuous audio. Calls longer than 10 minutes continue in a new file. Encrypted calls have no audio to save. Several channels can share one Event output. An Event filter between them skips short calls or keeps certain talkgroups. A Spectrum monitor wired here saves its clips the same way.
A call carries its clip on the events wire. Clips are kept only when they reach a Decoder log,
which plays them, or an Event output that keeps them: Recordings, Matrix or a Discord webhook.
Record an array
Rec on an Array records every lane into one SigMF collection: a
.sigmf-collection file that ties one recording per lane together, with the geometry, tier and
noise source windows. Stop ends it. Library → Recordings lists the collection once, with
its lane count.
Press Calibrate while recording. Playback calibrates from the recorded noise windows.
Play a recording
In Library → Recordings, press Open as source. A Device node appears pinned to that recording. Wire it to channels and displays like any other Device, then use play, pause, and seek to decode the same samples again with different settings. A Device node also offers the library itself, under Recording.
A recording made with GPS wired plays its location back too. Wire the Device position to a
Map, or to anything that takes a GPS. The position follows the playhead, also on seek, and the
Device node shows it.
Upload SigMF adds a recording from your computer, as a .sigmf archive or a
.sigmf-meta and .sigmf-data pair. A downloaded array collection uploads whole. An upload may be
up to 16 GiB; archives stream to disk.
Play an array recording
Open a collection as a source. The Device node gets one output per lane, iq to iq5 for
a five-lane array. Press Make array, or wire the outputs to an Array's lanes in order. Set the
Array's geometry as it was when recording. Wire a GPS to the Array's position and add a
processor, such as a Direction finder. Each lane keeps its recorded
frequency, so the Array cannot retune. The Array calibrates on the recorded noise windows; when the
recording ends it keeps the last calibration.
Tags and notes
In Library → Recordings, press the pencil to give a recording a name, comma-separated tags, and a note. Search covers names, tags, and notes. Annotations live in the SigMF metadata, so they travel with the files.
Download
Download IQ as .sigmf, the original archive, or .wav, a stereo float WAV with I and Q as channels. The WAV keeps only the centre frequency and start time from the metadata. An array collection downloads as one SigMF archive with every lane, and has no WAV. A failed download aborts instead of handing you a truncated file.
Where files go
Recordings go to sdrmm/recordings in the platform data folder, with audio in audio/. Change it
with --recordings-dir. Containers use /data/recordings. The library rebuilds itself from the
SigMF files on disk.
Network export
Send live IQ or decoded events to other programs.
| Node | Mode | For |
|---|---|---|
| Network IQ | UDP or TCP | GNU Radio and other raw IQ tools |
| Network IQ | rtl_tcp server | rtl_433 and other rtl_tcp clients |
| Event output | Feed (ADS-B Beast) | Flight tracking feeders |
| Event output | Webhook, Matrix, MQTT | Chat, automation and alerts |
| Event output | Notification | Alerts in the desktop app |
| Event output | PostgreSQL, InfluxDB | Databases |
| Event output | CSV file | Spreadsheets and logs |
| Event output | Network interface | IP packets from DAB and DVB-S2 |
Network IQ and feed ports skip the server's token and TLS. Keep them on trusted networks. A
listener address belongs to the server: use its LAN address or 0.0.0.0 to accept other
machines.
Raw IQ over UDP or TCP
- Add Network IQ and wire one Device
iqlane or one channelbaseband. - Pick Transport, Samples, and the destination
host:portin To. - Start the receiving program, then press Start export.
- Set the receiver to the rate and centre frequency shown on the node.
Channel baseband sends only that channel, at its lower rate. The sample rate is locked during
export. A radio restart pauses the export instead of ending it. Retuning works, but you must update the receiver yourself. The node counts bytes sent,
datagrams or writes, and drops.
Samples are interleaved I, Q, I, Q, with no header or timestamps:
| Samples | Encoding | Bytes per I/Q pair | GNU Radio type |
|---|---|---|---|
| Complex float 32 LE | cf32_le, 32-bit float | 8 | Complex |
| Complex int 16 LE | ci16_le, signed 16-bit | 4 | Short, then Interleaved Short to Complex |
| Complex unsigned 8 | cu8, unsigned 8-bit, zero at 127.5 | 2 | RTL-SDR byte IQ |
Names follow SigMF. VITA 49 and DIFI are not supported.
UDP datagrams sends whole samples in datagrams of up to 1,400 bytes. In GNU Radio's
UDP Source, set header to None and payload size to 1,400. There are no sequence numbers, so
lost packets cannot be detected.
TCP stream connects to your listening program. If it reads too slowly, the export stops with an error.
rtl_433
- Tune the Device to 433.92 MHz.
- Wire Device
iqinto Network IQ and pick rtl_tcp server (rtl_433). It listens on127.0.0.1:1234. - Press Start export and run rtl_433 with the rate and frequency shown:
rtl_433 -d rtl_tcp:127.0.0.1:1234 -s 1024000 -f 433920000 -F json
A channel's baseband works too if it covers the sensor. Commands from the client cannot retune
the radio; set that on the canvas. Up to eight clients can connect. A slow one is dropped without
affecting the others.
Feeds
A feed streams events in a format other software reads. Decoders bring their own formats; ADS-B brings Beast.
- Wire the ADS-B channel's
eventsinto Event output. - Pick Feed, format Beast, keep Listen at
127.0.0.1:30005, and press Open server. - Point your feeder at that address.
The server sends Beast binary frames with 12 MHz timestamps and signal levels. Timestamps count samples, not GPS time, and restart after gaps. Use one ADS-B channel per output. Up to 16 clients can connect.
Webhooks, Matrix and MQTT
Wire events to an Event output and pick a Service. Each event is sent as it arrives.
| Service | Sends |
|---|---|
| Webhook, JSON | An HTTP POST with output, kind, text, and the full record |
| Webhook, Discord | The text as a message, with any audio attached |
| Matrix | The text to a room, with any audio uploaded |
| MQTT | The JSON payload to a topic, at least once. Use mqtt:// or mqtts://. |
| Notification | The text as a system notification. Desktop app only. |
Long messages are cut at 1,900 characters. A rate-limited send is retried up to four times. Failures go to the server log.
Databases and files
Wire events to an Event output and choose PostgreSQL, InfluxDB or CSV file.
PostgreSQL creates the table (default sdrmm_events) on first write: one row per event with time, kind, frequency,
station, summary, and the full record as jsonb. A server on another host must speak TLS unless
the URL sets sslmode, such as ?sslmode=disable.
CSV file appends one row per event to Recordings/events/<file>.csv: time, kind, output,
device set, channel, frequency, station, summary, and the event data as JSON. Text that starts
like a formula gets a leading '.
InfluxDB 2 and 3 take one point per event. The measurement is the event kind, and numbers, flags, and short text from the event become fields.
Credentials
Webhook URLs, the Matrix token, MQTT and PostgreSQL passwords and the InfluxDB token stay in the
server's database. The workspace and its export name them by id only. On another server the node
shows, for example, Matrix token is not set on this server until you enter it again.
IP data
DAB IP services and DVB-S2 GSE carry IP packets. Wire the channel's events to an
Event output, choose Network interface, and set Interface, IPv4, and Prefix.
SDRmm creates a TUN interface and writes the packets to it.
| System | Needs |
|---|---|
| Linux | CAP_NET_ADMIN for the server |
| macOS | Permission to create a utun interface; name it like utun8 |
| Windows | Administrator rights and wintun.dll beside the executable |
Routing and multicast are up to your operating system.
Transmit
A Transmitter puts a signal on the air through a radio that can send. Nothing transmits until you arm it, and arming is never saved: a workspace always opens with every transmitter off.
You are responsible for what you radiate. Check your licence, the band plan and your antenna before you arm.
Radios that send
| Radio | Duplex | Notes |
|---|---|---|
| HackRF One | Half | Receive pauses for each burst. The 14 dB amp is shared by receive and transmit |
| ADALM-Pluto, AntSDR | Full | TX attenuation per lane on the Device |
| Aaronia SPECTRAN V6 | Half | If the unit opens as a transmitter. Receive pauses while it sends |
| bladeRF, LimeSDR, USRP (Soapy) | Per board | One TX gain per element the board has, set alike on every transmit channel |
| Transceiver, Half-duplex (virtual) | Full, half | Debug builds. What one virtual radio sends, the others hear |
The transmit gain is the TX gain on the Device; a Soapy board shows one per gain element, such
as TX PAD. The radio sends around the Device's centre, so
a transmit frequency has to fall inside the Device's window.
Send a message
- Add a Modulator and pick a mode that sends messages, such as APRS.
- Add a Transmitter. Wire Modulator
basebandto it, and itstransmitto the Device'stransmit. - Set the Transmitter's frequency. Empty means the Device's centre.
- Press Arm.
- Fill in the message on the Modulator and press Send.
The burst goes out once and the Transmitter returns to idle, still armed.
Talk
- Add a Microphone, a Modulator on NFM, AM or SSB, and a Transmitter.
- Wire Microphone
audioto the Modulator, then on to the Transmitter and the Device as above. - Arm, then hold the key on the Microphone while you speak. The keyboard has a key for it too.
The browser asks for the microphone the first time. A channel's audio works in place of the
Microphone: the transmitter then sends whatever that channel hears, keyed by its squelch.
Turn on VOX to key by voice. The Microphone then listens all the time and sends while you are louder than the threshold, and for the hang time after you stop.
Send a channel
Wire a channel's baseband into a Transmitter to send that channel's filtered IQ again, on
another frequency or another radio. Without a frequency it goes out where it is heard, so set one
unless the two radios are far apart.
Send a lane
Wire a Device's iq straight into a Transmitter to send that lane as it is: a recording, a signal
generator, or another radio. Without a frequency it goes out where it was received. A lane as wide
as the sending radio's window has to sit on that radio's centre.
Level, timeout and state
| Setting | Meaning |
|---|---|
| Level | Digital drive below full scale. Leave a few dB of headroom |
| Timeout | The longest one transmission may last. Past it the Transmitter ends the burst and disarms |
The Transmitter shows when it is on the air, its frequency, the peak drive of the last burst and how often the source could not keep up. Width is the band that held 99 % of the last transmission's power. It turns to a warning when a modulator's signal is wider than its mode's channel: lower the level or the audio. An error, such as a frequency outside the Device's window, mutes it until the cause is gone.
Changing a Transmitter's wires while it is armed takes it off the air. Changing its source arms it again on the new one.
Position and GPS
The GPS position node tells other nodes where the station is and, if the source knows it, which way it points. One node can feed many.
Pick a source
| Tab | Source |
|---|---|
| Receiver | A serial or USB NMEA GPS on the server. Set Port, Baud and Rate. |
| Network | gpsd, default 127.0.0.1:2947 |
| Fixed | Latitude and longitude you type in |
| Phone | A paired phone: position and heading |
Serial and gpsd sources must be reachable from the server, and reconnect on their own. Forget source picks a different one.
The node shows the fix, your Maidenhead locator, accuracy, speed, and heading and tilt when the source has them. A lost fix is reported, and old coordinates stop being used. A phone that drops out reads Offline.
Heading
Heading is true north.
| Source | Heading from |
|---|---|
| Phone | Compass, gyro and GPS course, fused on the phone |
| NMEA | HDT and THS sentences. Magnetic headings are ignored. |
| gpsd | ATT reports |
An NMEA or gpsd heading older than two seconds is dropped. An Array set to Heading turns with it.
What uses it
| Node | Uses position for |
|---|---|
| ADS-B channel | Decoding aircraft positions |
| Array | Where the array stands and which way it points |
| Map | Your station and its GPS trail |
Passive radar tx | Where the transmitter stands, from a Fixed source |
| Propagation map | Your end of every path |
| Recorder | Location in the recording's metadata |
| Satellite | Pass and Doppler prediction |
| Signal hunt | Where you stand and which way you point, for sweeps |
| Signal survey | Where each measurement was taken |
| Triangulation | The vehicle to guide |
Satellites
The Satellite node tunes its decoders to a satellite's downlink and removes the Doppler shift as it passes, so the decoder never has to chase the carrier.
Build a satellite receiver
- Add GPS position. A fixed position is fine for a station that never moves.
- Add Satellite and wire GPS
positionto itsposition. - Search by name or NORAD number, or paste element lines.
- Pick a transmitter in Signal, or tune the downlink on the dial.
- Wire Satellite
controlto each decoder'scontrol.
On auto tuning the radio follows the decoder, as it does for a scanner.
Run the radio only for passes
Wire Satellite control to the Device's control. The radio opens 30 s before the satellite
rises and closes when it sets, so between passes it costs no CPU, USB or network. A recorder
wired the same way records each pass while armed.
A Device with a control wire runs only while a controller says so. Stop on the Device
still wins.
Point an antenna
- Add Rotator and pick its link: a Yaesu GS-232 controller on a serial port, a
Winegard Trav'ler mount on a serial port, or Hamlib rotctld (
127.0.0.1:4533by default). - Wire Satellite
controlto the Rotator'scontrol.
During a pass the rotator follows the satellite. Between passes it waits where the next one rises.
A Winegard link talks to the motor console at 57600 baud, on the ODU debug port or the board's MOT/DCM header. It reads the steps per revolution each motor reports, so SM and LG elevation motors need no setting, and it holds the mount inside its own travel of 445° azimuth and 94° elevation. It reaches the motor console from any menu the mount was left in, and resends a move the console dropped.
| Setting | Does |
|---|---|
| Mode | Track the satellite, point by hand (Manual), Park, or Stop both axes |
| Axes | Azimuth and elevation, or azimuth only |
| Range | 360°, 450° or 540° of azimuth. Past 360° a pass crosses north without unwinding |
| Heading | Compass bearing the mount's 0° azimuth faces, for a mount not set up facing north. Manual and Park take compass bearings |
| Flip | Elevation reaches 180°. Passes over the top, or across the azimuth stop, are followed with the antenna flipped instead of swinging round |
| Step | Moves smaller than this wait, to spare the motors |
Each pass is planned before it rises, so the rotator never has to unwind halfway through.
On the map
Wire Satellite events to Map. It shows the point below the satellite, the footprint where it
is above the horizon, and the ground track half an orbit back and one ahead. The footprint is
brighter while the satellite is above your horizon. Globe in the map settings suits wide
footprints.
Readouts
| Readout | Shows |
|---|---|
| Azimuth | Compass bearing |
| Elevation | Angle above the horizon; green while visible |
| Range | Distance to the satellite |
| Doppler | The shift being corrected, and how fast it changes |
| Send on | The Doppler-corrected uplink, if the transmitter has one |
| Next pass | Time until rise, or until set during a pass, with peak elevation |
| Elements | Age of the orbit data. Warns after 7 days; press the refresh button. |
Orbits come from CelesTrak and transmitters from SatNOGS DB. Both are cached for two hours.
What remains after correction is the radio's own oscillator error and a small orbit error. Decoders that lock onto a carrier, such as DVB-S2, pull that in themselves.
Propagation map
The Propagation map plots where FT8, FT4, and WSPR signals came from, and estimates a lower bound on the maximum usable frequency (MUF). It only reads decoder events.
Build one
- Add FT8, FT4, or WSPR channels and a Propagation map.
- Wire each channel's
eventsto the map. - Wire GPS position to
position. A fixed position works.
On opening, the map loads the last six hours of logged decodes.
Layers
| Setting | Shows |
|---|---|
| Map, Activity | Heard stations per grid square, weighted by count and age |
| Map, MUF | Estimated MUF lower bound at each reflection point |
| Paths | Great-circle paths on top, one per grid square and band. Off by default. |
Only messages with a locator count. Reports and 73 usually have none.
For MUF, each path is split into hops. A one-hop path reflects at its midpoint. Points lose half their weight every Half-life, from five minutes to twelve hours (default 30 minutes).
Measured MUF
A decoded signal proves its path carried that frequency at that moment. The map scales it to a 3000 km hop:
MUF(3000) ≥ f × M(3000) / M(D / hops)
f is the received frequency, D the path length, M the obliquity factor for a thin layer over
a round Earth. At a 300 km layer height, M(3000) is about 3.28.
Read the result as a lower bound:
- Paths under 500 km count as activity but not towards MUF.
- No decodes on a band does not mean the band was closed.
- Layer height matters. Set Height to 300 km (the default) for F2, 110 km for sporadic E.
Ionosondes
Ionosondes overlays GIRO and INGV soundings from prop.kc2g.com, cached for fifteen minutes. The footer compares your estimates with the soundings within 3000 km. If the feed fails, your own decodes stay on the map. Turn it off to stop the requests.
Arrays
An Array node turns radio lanes into one antenna array. Every array processor reads its array
output: Direction finder, Beamformer, Stitch, Spatial spectrum,
Correlator, Polarimeter and Passive radar.
Make an array
On a multi-lane radio such as an RSPduo or a CR-8, press Make array on the Device. It adds an Array and wires every lane to it. The button is greyed out when the lanes share no clock. A playing array recording has the same button.
For separate radios, a KrakenSDR among them:
- Add a Device for each radio.
- Wire each Device
iqto the Array in antenna order:lane,lane2, and so on. - Open Tier and pick what the radios share: Shared clock, or Shared LO for clock and local oscillator. Radios that know they share a clock, the lanes of a KrakenSDR among them, say so themselves and keep that tier whatever you pick.
A lane belongs to one Array. The Device then shows Array on that lane, and tuning and gain move to the Array.
Tiers
| Tier | Lanes share |
|---|---|
| Shared LO | Clock and local oscillator |
| Shared clock | Sample clock |
| None | Nothing, not an array |
On one Shared LO radio the lanes start lined up in time. Otherwise the Array syncs them first.
It also measures clock drift between radios. When the tier ends up below what you declared,
Tier reads capped.
| Radio | Tier | Noise source |
|---|---|---|
| KrakenSDR, five Devices | Shared clock, reported by the lanes | Built in, antennas cut off |
| KerberosSDR, four Devices | Shared clock, reported by the lanes | Built in, antennas stay on |
| RSPduo dual tuner | Shared clock | None |
| LimeSDR, USRP B210, bladeRF 2, AD936x boards | Shared LO | None |
| CR-8 | Shared LO | None |
| Other multi-channel SoapySDR radios | Shared clock | None |
| Several radios in one Array | What you declare, capped by any radio or bank that gives more than one lane | None |
| Array recording | As recorded | Recorded noise windows |
Lane tuning
Pick it under Tuning.
| Mode | Lanes | For |
|---|---|---|
| Together | All on one frequency | Every processor except Stitch |
| Spread | Side by side, Span shows the band | Stitch |
Lay out the antennas
Open Geometry. Angles run clockwise from the array's forward direction.
| Shape | Set |
|---|---|
| UCA | A circle: Radius, First (where lane 1 sits, 0 is forward), Winding |
| ULA | A line: Spacing, Axis (from lane 1 to the last lane) |
| Custom | Each lane's x, y and z in metres |
Max f is the highest frequency before the spacing aliases.
Point it
Open Forward and pick a Mode.
- Fixed: type the Azimuth of the array's forward direction, from true north.
- Heading: wire a GPS with heading, such as a phone, to the Array's
position. Mount is the array's forward direction relative to that heading.
Wire a GPS to position in either mode. Direction finders need it to send bearings.
Calibrate
Pick the Cal source:
| Source | Uses |
|---|---|
| Noise | The radio's built-in noise source |
| Pilot | A carrier every antenna hears, at a known Offset and Width |
| Emitter | A transmitter at a known true Bearing, Offset and Width. Needs Forward set. |
| Off | Nothing |
The Array calibrates delay, phase and gain on start and after every retune or gain change. Calibrate runs it now. Processors that need phase wait until it is done.
Open Calibration for the rest. Check rechecks the phase on a timer. EQ flattens each lane across the band. Warm start begins from the last calibration stored for this radio and band.
The Gain row sets one gain for every lane. Auto steps it from the lane levels and needs a cal source.
Read the face
| Row | Shows |
|---|---|
| Sync | Idle, Syncing, Locked, Drifting or Lost |
| Tier | What the lanes share |
| Cal | No cal, Waiting, Calibrating, Calibrated, Warm, Stale or Cal failed |
| Heading | Array forward, true north |
| Gaps, Realigns, Drops, Lost | Lost or resynced samples, shown when not zero |
Calibrated, Warm and Stale show their age. Each lane row shows its quality, gain and phase.
When the array stops, the face says why, such as Noise clips, lower gain.
A processor that cannot run says why on its own face: Syncing, Calibrating, Needs cal,
Retuning, Not coherent or Wrong tuning.
Every processor has the same face: its plot, its readouts, any table, then its settings. Chips at
the foot flag what needs a look; Stale means no reading for three report periods. A processor
that needs the Array tuned another way offers a button that retunes it.
Rec records every lane into one SigMF collection. Play it back into an Array like a radio.
Beamformer
Combines the lanes into one beam lane. Channels, scopes and recorders use it like a radio lane.
| Mode | Does |
|---|---|
| MRC | Best SNR from every lane |
| Beam | Steers with equal weights |
| MVDR | Steers and suppresses the rest |
| Nulls | Steers with nulls on the azimuths you list, or that Auto finds |
| GSC | Steers and cancels the rest as it moves |
| Cancel | Subtracts what the Refs lanes hear from the Main lane |
| CMA | Locks onto a signal with a steady envelope |
Steer picks DF, a Direction finder wired to steer, or a Fixed azimuth from array
forward. The plot shows the beam pattern. Gain is the gain over one lane.
Stitch
Joins spread lanes into one wide lane at the lane rate times the lane count. It needs the Array
on Spread: Spread array on its face sets it. Blend handles overlaps: SNR favours
the cleaner lane, Equal averages.
Spatial spectrum
Shows bearing over frequency. Map draws bearing against frequency. Trail draws frequency over time, coloured by bearing. Pick Rel or True bearings.
Correlator
Correlates every pair of lanes. For the chosen Baseline it plots amplitude and phase over frequency and reads the Delay from the phase slope and the coherence Coh.
Polarimeter
Needs two crossed antennas: pick the H and V lanes. It reads the Stokes values, the
polarised share Pol, Tilt, Ellip and Hand. Output sets what beam carries:
Matched to the wave or Cross to it.
Direction finding
A Direction finder turns an Array into bearings. Triangulation crosses bearings from several places, or from one moving array, into a position.
Set up
- Build and calibrate an Array, and wire a GPS to its
position. - Add Direction finder and wire the Array's
arrayto it. - Set Offset and Width to cover the signal.
- Pick a Method.
| Method | Use |
|---|---|
| Bartlett | Plain beam scan, robust |
| Capon | Sharper beam scan |
| MUSIC | Splits close sources |
| Root-MUSIC, ESPRIT | Grid free, need a line or a circle |
Sources counts transmitters with Auto, or takes a fixed number. Squelch drops weak peaks and Station names the bearings this finder sends. More holds the rest, such as Smooth against reflections and Yaw gate.
The rose
The rose shows the response around the array, each bearing, and its spread. Rel is relative to the array's forward direction. True is from true north and needs a heading or a fixed azimuth on the Array.
| Row | Shows |
|---|---|
| Bearing, ± | The strongest bearing and its one sigma |
| Fit | Share of the signal the bearings explain |
| SNR | Signal to noise |
| Src | Sources found |
| Chip | Means |
|---|---|
| Squelch | Peak below Squelch |
| No heading | Wire a GPS with heading to the Array |
| No position | Wire a GPS to the Array |
| Aliased | Antennas more than half a wavelength apart |
| Mode alias | Too high for phase modes on this circle |
| Mirror | A line cannot tell front from back. Side picks one. |
| Rotating | Turning faster than Yaw gate, blocks skipped |
| Singular | Covariance could not be inverted |
| Table off | Outside the calibration table |
| Stale | No reading for three report periods |
Listen in one direction
Wire the finder's bearing to a Beamformer steer input, and beam to
a channel.
Triangulate
- Add Triangulation and wire each finder's
eventsto it. - Give each finder's Array its own position. A moving array sends bearings from wherever it was.
A Signal hunt with a phone sends bearings too.
| Row | Shows |
|---|---|
| Estimate | The most likely position |
| Spread | The one sigma error ellipse |
| Guidance | Where to drive next: Drive across or Drive at it |
| Bearings | Bearings in use |
The table lists each station's last bearing, its spread, its learned heading offset and age. Align learns a constant heading offset per station, such as a turned array mount, once a loop or crossing pins it down. Fade sets how fast old bearings lose weight: Auto picks Fixed or Moving from the stations, Set takes your half life. Min conf refuses weak bearings and Emitters caps how many transmitters it finds. Clear throws away every bearing.
Wire a vehicle's GPS to Triangulation position for guidance. Guide picks Auto, which
crosses the bearings first and then drives at the fix, Direct or Off. Probe sets how
far to drive across a single bearing.
On the map
Wire finder and Triangulation events to a Map. It draws bearing rays, a heat layer of
likely positions, the estimate and its ellipse. The first fix is an event that an
Event output can forward.
In a car
Mount the array on the car, pair a phone and wire its GPS to the Array with Heading. The phone's DF drive mission shows the bearing and guidance and starts navigation to the target.
Bearings pause while the car turns fast; the phone shows Turning. Bearings taken when the signal suddenly drops, as behind a ridge where only an echo arrives, count less. Echoes still fool a single stretch of road: drive past from another side to confirm a fix.
Passive radar
Passive radar compares a broadcast transmitter's direct signal with its echoes off aircraft. It measures how much further each echo travelled and its Doppler shift. Two lanes on a shared clock are enough. Phase calibration is only needed for echo bearings.
Wire it
- Build an Array with at least two lanes, tuned Together.
- Add Passive radar and wire the Array's
arrayto it. - Point one antenna at the transmitter and pick its lane under Ref. Listen picks the lanes that hear echoes: Others or Pick.
- For the map, wire a GPS to the Array, and a GPS set to Fixed at the transmitter to
tx. - Optional: wire an ADS-B channel's
eventstotruth. Tracks that match an aircraft show its call sign.
Illuminators
Pick one under Illum.
| Illuminator | Notes |
|---|---|
| FM | Strongest echoes. Clean with CMA removes the programme's multipath. |
| DAB | Wider band, finer range. Clean with DAB remod rebuilds a clean reference. |
| DVB-T | Uses the slice of the channel the lanes cover, set by Bandwidth |
| Custom | Any signal, with your own Bandwidth |
Offset is the transmitter's frequency minus the Array's centre.
Read the view
The plot shows bistatic range against Doppler. Bright cells are echoes; detections are marked. An echo seen over several looks becomes a track in the table: range, speed, bearing, SNR and the matched ADS-B aircraft. A single flash is often noise.
| Readout | Shows |
|---|---|
| Targets | Confirmed tracks |
| Clutter | Direct path and clutter removed |
| Load | Share of real time spent |
| Ref | Reference quality: Raw, Lost, or CMA or DAB with its dB |
| CPI | Integration time in use |
| Drops | Lost samples or looks, shown when not zero |
Clear forgets every track. The phone's Radar mission shows the same view.
The range is bistatic: the extra distance the echo travelled compared with the direct path. One echo gives an ellipse on the map, not a point. A tracked echo with a bearing, or an ADS-B match, gets a position.
Settings
| Setting | Does |
|---|---|
| Range | How far out and how fast (Speed) to search |
| CPI | Integration time. Longer finds weaker echoes but blurs fast ones. Overlap reuses part of each. |
| AoA | Bearing per echo. Needs a calibrated array. |
| GPU | Auto uses the GPU when it is faster |
| Clutter | How the direct path and ground clutter are removed: ECA-B, ECA-S, NLMS, Block NLMS or Off |
| Pfa | False alarm chance per cell |
| Detect | The CFAR detector (CA, OS or GO) and Min SNR |
| Track | Hits over looks that start a track (Start hits, Start looks), and misses that end it (Coast) |
Limits
- The lanes must see the transmitter and the sky. A strong direct path in the surveillance antennas limits range.
No transmitterorNo array position: wire the GPS nodes from step 4.Overloadedmeans the host cannot keep up: shorten Range or CPI, or set GPU to Auto.Outside cal tablemeans the Array's measured table does not cover the carrier. Bearings then use the ideal geometry.- A lane rate below the illuminator's bandwidth cuts range resolution.
Phones
An iPhone or Android phone is a remote head for field work. It runs missions from the active workspace and sends its position and heading to the server. It does not edit the graph.
Get the app
Neither app is in a store. Build it from source.
| Phone | Needs | Install |
|---|---|---|
| iPhone | iOS 27 | Build with Xcode, see Phone apps |
| Android | Android 10 | Build the APK, see Phone apps, then adb install it or open the file on the phone |
To look around without a server, pick Try without a server under Demo.
Allow phones
Phones need a direct HTTPS endpoint whose key they pin at pairing.
- Open Library → Phones and turn on Allow phones. This opens a phone Port,
8443by default, with the server's own self-signed certificate. - A server on a LAN address with its own self-signed certificate, the default, also takes phones on its main port.
The status line reads Ready on 8443 when phones can reach the server, and Discovery on when
nearby phones can find it. Your firewall must let the port in.
Reach the phone port over your LAN or a Tailscale IP. Tunnels that end HTTPS, such as Tailscale Serve or a Cloudflare Tunnel, carry only the browser.
Pair
- Press Pair phone. A QR code, an 8-digit Code and a Key appear for five minutes.
- On the phone, Scan QR. Or pick the server under Nearby and type the code, or type
host:portand the code under Manual. - Check the phone shows the same Key, then Trust.
Allow local network access when the phone asks, or Nearby stays empty. On a server without a
browser, sdrmm phone prints the QR code in the terminal, see
Configuration.
Five wrong codes end the offer with Too many tries. Each phone gets its own token: rename or
revoke the phone in the list. Revoking disconnects it at once.
Missions
The phone lists what the active workspace offers. Its top bar shows the link: Online,
Connecting, Offline or Refused.
| Mission | Needs | Shows | Controls |
|---|---|---|---|
| Hunt | A Signal hunt wired to a channel | Level, Listening, Warmer, Colder or On top | Start, Tune, Sweep, Mark, Clicks, Haptics |
| DF drive | A Direction finder, a Triangulation, or both | Bearing rose, guidance, map with rays, heat, ellipse | Navigate, Auto or Direct, Calibrate, Clear, Tune, Fit |
| Radar | A Passive radar | Range Doppler image and tracks | |
| Survey | A Signal survey with a GPS on position | A trail coloured by level | Record, Stop, Clear, Fit |
A mission that cannot run is dimmed and says why, such as Not running. The phone can also switch
the active workspace, for every client.
Navigate routes to the target and follows it when it moves, with a New target alert. The
iPhone guides turn by turn itself; Android hands the target to Google Maps or another map app.
Direct guides straight to the fix instead of crossing the bearings first.
Position and heading
Add a GPS position node, pick the Phone tab and the phone. Wire it where it is needed:
| Wire to | For |
|---|---|
Array position | Where the array stands and which way it points |
Signal hunt position | Sweep bearings with a handheld antenna |
Signal survey position | Where each level was measured |
Triangulation position | Guidance to the target |
Passive radar tx | Where the transmitter stands |
The phone sends its pose only while a GPS node uses it. Set it up under Settings → Heading:
| Setting | Does |
|---|---|
| Source | Auto fuses compass, gyro and GPS course. Compass or GPS course force one. |
| Mount | Flat on a seat or dash, Upright in a holder |
| Offset | A fixed angle between phone and car |
| Align with car | Drive straight when asked; the app learns the Offset |
During a mission the app keeps sending its position with the screen off. On iPhone, press
Allow always in Settings → Location. On Android, a Background off chip means Android
refused: keep the app on screen.
In the car
CarPlay and Android Auto show the DF drive map, a Missions list, and a DF panel with the bearing, guidance, Calibrate and Clear. Navigate starts navigation.
Android Auto hides sideloaded apps: in Android Auto, tap Version ten times, then turn on Unknown sources in Developer settings.
Android chips
| Chip | Means |
|---|---|
| Online, Connecting, Offline | Link to the server, or why it refused |
| Sharing position | The server uses this phone's position |
| Fused ±4° | Heading source and accuracy |
| No GPS fix | No position yet |
| Approx. location | Precise location is off |
| Location off | Location permission or service is off |
| No heading, No compass | No usable heading |
| Calibrate compass | Wave the phone in a figure eight |
| Background off | Android refused to run in the background |
| Alerts off | Notifications are off |
| No map tiles | The map is offline |
Tools
Library → Tools opens instruments and calculators that are not part of a receiver.
| Tool | Does |
|---|---|
| Antenna calculator | Dimensions for dipoles, folded and inverted-V dipoles, end-fed half-waves, ground planes, 5/8 verticals, J-poles, quad loops, and Yagis, with simulated SWR, gain, and radiation pattern |
| NanoVNA | Sweeps an antenna over USB and shows SWR, impedance, a Smith chart, and cable faults. Keeps sweeps per antenna to compare. |
| Radio programmer | Reads, merges, and writes codeplugs, and copies them between radios |
Antenna calculator
Pick a design and a band or frequency. Every design is also simulated: the wires are solved with the method of moments, over the ground you pick.
- Design: the cut list and a drawing. Current shades each wire by how much current it carries. Hover a part to find it in the drawing.
- Trim finds the element factor where the feedpoint is resonant.
- Performance: SWR, return loss and impedance over the band, a Smith chart, and the azimuth and elevation patterns. Matching compares baluns, ununs, a quarter-wave line, and L networks; pick one to see its SWR. The dashed trace is the bare feedpoint.
- Resonances: a sweep from 0.4 to 3.2 times the frequency. ▼ marks a series resonance, ▽ a parallel one. Bands shows which amateur bands the antenna also works on.
- Save to NanoVNA keeps the simulated curve in the NanoVNA antenna library, to overlay on
a real sweep. Export writes a NEC model for 4nec2 or EZNEC, or a Touchstone
.s1p.
The model ignores nearby metal, trees, and the feedline. Cut 2 to 5% long and trim on site.
NanoVNA
Pick a band or type a range, then Sweep. Live sweeps again as soon as each sweep ends, for trimming an antenna while you watch.
- Chart: SWR, return loss, impedance, phase, S21, group delay, and distance. Amateur bands of your region are shaded. Drag across the chart to zoom.
- Markers: the lowest SWR is marked on its own. Click the chart for up to four more; arrow keys move the selected one.
- Bands: best and worst SWR in each amateur band the sweep covers.
- Match to 50 Ω: L networks that match the selected marker.
- Distance: reflections along a cable. Set the velocity factor of the cable. Low-pass grid moves the start so the impedance step can be shown too.
- Antennas: name an antenna, then Save sweeps to it. Show a saved sweep as the main trace, or overlay up to four. Open .s1p or .s2p loads files from other tools.
- Export: Touchstone
.s1p,.s2p, or a CSV table.
Calibrate walks through open, short, load, and the optional isolation and thru steps over the current range, and saves the result in a slot on the device. Device sets IF bandwidth and drive.
Radio programmer
Supported radios: AnyTone AT-D890UV and Radtel RT-4D.
It handles what both radios share: channels, zones, contacts, group lists, scan lists, and radio IDs. Write to radio reads the radio first and writes back only the blocks that differ, so the rest of the codeplug stays as it was.
Copy for that radio fits a codeplug to the model picked under Copy to and stores it as a new one. Merge in takes entries from another stored codeplug.
The NanoVNA and the radio programmer need the device plugged into the server, not the client.
Keyboard controls
Shortcuts act on the selected node and pause while you type in a field. Press ? or the help
button in the top bar to list them.
| Keys | Action |
|---|---|
| Left / Right | Tune down or up one step |
| Shift + Left / Right | Tune ten steps |
[ / ] | Choose a smaller or larger tuning step |
f | Focus the selected node's dial; press Enter to type a frequency |
, / . | Select the previous or next channel |
m / M | Cycle the selected channel's analog mode forward or backward |
- / + or = | Lower or raise squelch by 2 dB |
s | Toggle squelch |
1–9 | Select the nth node in the patch |
p | Pin or unpin the selected node on the rack |
v | Switch between Patch and Rack |
z | Fill the window with the selected node; Escape returns |
Ctrl/⌘ Z | Undo the last workspace change, for every connected client |
Ctrl/⌘ Shift Z or Ctrl/⌘ Y | Redo |
Ctrl/⌘ C | Copy the selected nodes and the wires between them |
Ctrl/⌘ V | Paste them beside the originals |
| Backspace or Delete | Delete the selected node or wire |
| Space | Hold to talk: keys the selected Microphone, or every Microphone when none is selected |
? | Open help |
| Escape | Close an overlay or menu, or deselect |
Dial controls
Once the frequency dial has focus:
- Left and Right choose a digit.
- Up and Down change the selected digit.
- Page Up and Page Down change the next larger place.
- Home and End jump to the first or last place.
0–9set the selected digit and move to the next.- Enter opens direct frequency entry.
Select a node before scrolling its dial. This keeps canvas scrolling from changing the frequency.
With Ctrl/⌘ held, scrolling never tunes.
Troubleshooting
Start with Check hardware on an empty Device node, or run:
sdrmm --doctor
It checks drivers, libraries, radio discovery, USB permissions, and storage paths.
The page does not open
- Check the port in the
SDRmm readyline of the server log. - On the server itself, try http://127.0.0.1:8080.
- The desktop app only accepts its own computer. For other machines, run the
sdrmmserver with--bind 0.0.0.0:8080and a token; it stays on127.0.0.1:8080otherwise. - Check the firewall and any container port mapping.
- Behind a reverse proxy, serve SDRmm at the root. Path prefixes do not work.
- A server on a network bind serves
https://and sendshttp://there. Accept the certificate warning once. Host not allowed: a server without a token answers only to the names under Token. Open it by IP address, or set a token.
The window is blank or the waterfall is missing
Some graphics drivers break hardware rendering: a blank window, frozen panels, or
waterfall unavailable: no WebGL2 context. Start the app with GPU rendering off:
sdrmm-app --disable-gpu # Linux
open -a SDRmm --args --disable-gpu # macOS
The waterfall then runs in software and may be slower. If the window still fails, run the
portable server with --bind 127.0.0.1:8080 and use
a browser.
The token is rejected
The browser forgets a rejected token and asks again. Enter the one the server is using now. API
clients send Authorization: Bearer <token>. URLs can carry ?token=... instead.
A radio is missing
- Check that the operating system sees it.
- Run
sdrmm --doctorand fix any library or permission error. - Close other SDR programs that may hold it.
- For SoapySDR radios, run
SoapySDRUtil --findand check the module matches the core's version, 0.8 or 0.9. - On Linux, install the udev rules. In a container, set
group_add.
SDRplay: install the SDRplay API and start
sdrplay_apiService. An RSPduo in use elsewhere only lists its free modes.
Aaronia SPECTRAN: install the RTSA-Suite PRO and close it before opening the radio. On Linux
run its setup_udev.sh.
See Radios for each radio's requirements.
A radio is plugged in but its node stays disconnected
The node waits for the exact radio it saved: by serial, or by address for a network radio. If it shows Open radio, press it.
A node that says stopped was stopped by hand: press Start. To use a different radio, press
Forget radio and pick the new one.
Spectrum works but audio is silent
- Wire channel
audioto a Speaker and press play. - If the Speaker shows Resume audio, press it. Browsers block audio until you click.
- Drag the squelch handle left. Far left turns it off.
- Check the channel sits on the signal and inside the Device's window.
- Check tab mute, system volume, and the output device.
Audio that stutters on a plain http:// LAN address improves on HTTPS or localhost. Without
them the browser falls back to a slower audio path.
A decoder shows nothing
- Check frequency, mode, and any baud rate or variant setting.
- Check the Scope shows a signal inside the channel.
- Wire
eventsto the right place: Decoder log for messages, Readout for current state, Map for positions. - Adjust gain. Watch for clipping and drops.
- Check the mode's maturity.
Drops and gaps
The drop counter on a Device counts samples lost anywhere between the radio and the decoders. Drops damage audio, spectrum, recordings, and decoding.
- Lower the sample rate and close channels and displays you do not need.
- From source, build with
--release. - On small computers, check for CPU throttling and heat.
- On Linux, allow a higher thread priority: add
@audio - nice -10to/etc/security/limits.confand join theaudiogroup. - Use wired Ethernet for network radios.
- Give fast USB radios their own USB bus. A HackRF at 20 MS/s nearly fills USB 2, and a shared hub can lose samples before any counter sees it.
Developers can measure loss on real radios with the capture health tests.
Recordings do not appear
- Check the server can write to
--recordings-dir. - In Docker, check
/data/recordingsis on the persisted volume. - Stop the recording. Metadata is written on stop.
- Check each capture has both
.sigmf-metaand.sigmf-data.
Configuration and security
sdrmm runs the interface, the receiver, and the REST, WebSocket, and MCP APIs in one process.
Out of the box it listens on 127.0.0.1:8080 with no authentication, so only this machine
reaches it. A bind other machines can reach always needs a token and serves
HTTPS.
Options
| Option | Default | Sets |
|---|---|---|
--bind <ADDRESS> | 127.0.0.1:8080 | Listen address; a non-loopback one gets a token |
--db <PATH> | sdrmm/sdrmm.db in the platform data folder | SQLite database |
--recordings-dir <PATH> | sdrmm/recordings in the platform data folder | Recording folder |
--plugins-dir <PATH> | sdrmm/plugins in the platform data folder | Decoder plugins |
--token <TOKEN> | Generated on a network bind | Shared access token |
--no-auth | Off | Run a network bind without a token |
--tls-cert <PATH>, --tls-key <PATH> | None | HTTPS certificate chain and key, PEM; always together |
--tls-self-signed | On a network bind | HTTPS with a self-signed certificate |
--tls-name <NAME> | Found addresses | Name the self-signed certificate must cover; repeatable or comma-separated |
--no-tls | Off | Plain HTTP on a network bind, for a proxy or tunnel that adds HTTPS |
--dev-cors | Off | Allow a separate frontend origin, for development only |
--doctor | Print diagnostics and exit | |
--doctor-rates | Probe connected radios' sample rates and exit |
SDRMM_TOKEN, SDRMM_NO_AUTH, SDRMM_TLS_NAMES and SDRMM_NO_TLS set --token, --no-auth,
--tls-name and --no-tls. The platform data folder is ~/.local/share on Linux,
~/Library/Application Support on macOS and %APPDATA% on Windows. For a service, use absolute
paths.
Subcommand: sdrmm phone pairs a phone.
Data
The database holds workspaces, presets, bookmarks, saved radios, the recording index, the decoder
log, paired phones and Event output credentials. The tls folder beside it holds the self-signed certificate, and
access-token the generated token. Recording files hold the signals. Back up all of them. Stop
the server before copying the database, or use SQLite's backup, and finish recordings first.
Logs
The default level is info,sdrmm=debug. Change it with RUST_LOG:
RUST_LOG=info sdrmm
RUST_LOG=sdrmm=trace,info sdrmm
Trace logging is very verbose. Use it briefly.
Token
Set a long random token before untrusted devices can reach the server:
export SDRMM_TOKEN='replace-with-a-long-random-secret'
sdrmm
The environment variable keeps the token out of the process list; --token works too. A network
bind without one makes a token on first start, keeps it in access-token beside the database and
logs a sign-in link. --no-auth runs it open instead, for a port only trusted machines reach. An
open server answers only to IP addresses, localhost, --tls-name names and, off loopback, its
own <host>.local. The browser asks for the token once and remembers it. API clients send
Authorization: Bearer <token>. WebSocket and download URLs can use ?token=....
Everything needs the token except the page itself, /api/auth, /api/status, /api/about,
/api/openapi.json and /api/docs. Every client with the token can do everything. There are no
user accounts or read-only roles. Browsers tell themselves apart with an x-sdrmm-author key,
which only scopes undo and names who switched workspaces.
HTTPS
Loopback stays on plain HTTP. Any other bind serves HTTPS with a self-signed certificate, so the
browser warns once and you accept it. A plain http:// request to that port is sent on to
https://. --no-tls keeps plain HTTP for a tunnel or
reverse proxy that adds HTTPS itself.
With your own certificate:
sdrmm --tls-cert /etc/sdrmm/fullchain.pem --tls-key /etc/sdrmm/privkey.pem
Both files are PEM, leaf certificate first. A missing or mismatched file stops startup.
The self-signed certificate covers localhost, 127.0.0.1, ::1, the server's LAN IPv4 addresses and
<host>.local. It is stored in tls beside the database, valid for 397 days and renewed at
startup after a year. Its key stays the same across renewals and name changes. Compare the
SHA-256 fingerprint in the log the first time a client trusts it.
In containers, behind NAT, or with a DNS name, list the names clients use. They replace the found addresses:
sdrmm --bind 0.0.0.0:8080 --tls-name radio.example --tls-name 192.168.1.20
SDRMM_TLS_NAMES takes the same names, comma-separated. Changing the names creates a new
certificate.
WebTransport
A server that serves HTTPS itself also takes WebTransport on the UDP port with the same number. Chromium browsers use it and fall back to the WebSocket; other browsers, tunnels and proxies stay on the WebSocket. Let that UDP port through your firewall. Audio then survives packet loss, and a stalled picture no longer holds back the rest.
Phones
Phones pin the server's key when they pair. A server with
a self-signed certificate on a LAN address takes phones on its main port. Any other server needs
Allow phones, which opens a phone port, 8443 by default, with the self-signed key from
tls. A server with its own certificate needs it too, because a renewed certificate would break
every pin. The phone port takes paired phones only, and phones never get the shared token.
Pair from a terminal while the server runs:
sdrmm phone
sdrmm phone --name van --db /var/lib/sdrmm/sdrmm.db
It prints a QR code, the code, the key, the hosts and when the offer ends. --db must match the
running server, --name names the phone, and --plain or NO_COLOR drops the colours.
While phones can connect, the server announces itself as _sdrmm._tcp over mDNS, so the apps
list it under Nearby. Let the phone port through your firewall.
Reverse proxy
- Bind SDRmm to loopback, or pass
--no-tlsand firewall its port. - Set a token. Without one, the server answers only to the names under Token.
- Serve it at the root of the origin.
- Pass the original
Hostheader. Browser requests whoseOrigindiffers are refused. - Forward WebSocket upgrades on
/api/ws, and/mcpfor MCP clients.
HTTPS with a tunnel
A tunnel gives SDRmm an HTTPS address without port forwarding or certificate work.
| Tunnel | Reachable from |
|---|---|
| Tailscale | Your own devices only |
| Cloudflare Tunnel | Any browser, behind a login |
Tunnels do not carry phones. Phones pin the server's own key, so they need its direct HTTPS endpoint: the phone port on your LAN, or over the server's Tailscale IP.
Prepare SDRmm
Run the tunnel on the same machine as SDRmm. SDRmm stays on plain HTTP on loopback; the tunnel adds HTTPS:
export SDRMM_TOKEN='replace-with-a-long-random-secret'
sdrmm --bind 127.0.0.1:8080
Leave out the --tls-* options. Check http://127.0.0.1:8080 works on the server.
With Docker Compose, change the published port to loopback only and add the token file:
services:
sdrmm:
ports:
- "127.0.0.1:8080:8080"
env_file: .env
Inside the container SDRmm stays on 0.0.0.0:8080, so add SDRMM_NO_TLS=1 to .env to keep it
on plain HTTP. Run the tunnel on the host, not in another container.
Tailscale
-
Install Tailscale on the server and on each client, all in the same tailnet.
-
In the admin console's DNS page, turn on MagicDNS and HTTPS Certificates. Machine names become public in certificate transparency logs.
-
On the server:
tailscale serve --bg --https=443 http://127.0.0.1:8080 tailscale serve status -
Open the printed
https://<machine>.<tailnet>.ts.netaddress and enter the SDRmm token. Use the full name; a short name or IP does not match the certificate.
The setting survives reboots. Your tailnet access rules must allow TCP 443 to the server. Turn it
off with tailscale serve --bg --https=443 off. See
Tailscale Serve.
Cloudflare Tunnel
You need a domain on Cloudflare, such as example.com.
- Add the login first. In Zero Trust → Access controls → Applications, create a
Self-hosted and private app for
radio.example.comwith no path. Add an Allow policy for your email or group and save. - In Networking → Tunnels → Create Tunnel, name it
sdrmmand follow Install and Run to installcloudflaredas a service on the SDRmm machine. Keep its tunnel token private. - When the tunnel is Healthy, add a Published application route: hostname
radio.example.com, no path, servicehttp://127.0.0.1:8080. - Open
https://radio.example.com, log in, then enter the SDRmm token.
The tunnel alone lets anyone in; the Access policy is what adds the login. API and MCP clients must pass Access too. Make sure Cloudflare's WebSockets setting is on. See Tunnel setup.
Check it
Open the HTTPS address, start a receiver, and check that spectrum and audio move.
- Page loads, nothing moves: check the
/api/wsconnection in the browser's developer tools. - Bad gateway: check
http://127.0.0.1:8080works on the tunnel machine. - Cloudflare: open a private window and check the login appears before SDRmm does.
Deployment
Put the server next to the antenna and connect from a browser anywhere. Only audio, decoded data, and display frames cross the network; raw IQ stays on the server unless you export it.
Docker Compose
On Linux:
git clone https://github.com/Newspicel/sdrmm.git
cd sdrmm
docker compose pull
docker compose up -d
Open https://<host>:8080 and sign in with the token. The service restarts
automatically and keeps sdrmm.db, recordings, access-token and the self-signed certificate
in tls under /data, in the sdrmm-data volume. Back up that volume.
The image is ghcr.io/newspicel/sdrmm. :latest is the newest release; version tags such as
:2.0 pin one. Use :nightly only to test unreleased changes.
USB devices
The supplied service already contains:
devices:
- /dev/bus/usb:/dev/bus/usb
device_cgroup_rules:
- "c 189:* rmw"
group_add: ["46"]
The cgroup rule lets radios reconnect. Set group_add to the group that owns your radio on the
host. 46 is usually plugdev on Debian and Ubuntu. Check with:
stat -c '%g %G %a' /dev/bus/usb/*/*
If the radio belongs to group 0, install its udev rules on the host, or add "0" to
group_add. Check hardware shows ownership from inside the container.
SoapySDR modules
The image has bladeRF, LimeSDR, and SoapyRemote modules. Add others with a derived image:
FROM ghcr.io/newspicel/sdrmm:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends soapysdr-module-audio \
&& rm -rf /var/lib/apt/lists/*
USER sdrmm
SDRplay receivers
Install the SDRplay API on the host and keep sdrplay_apiService running. Then share the library
and the host's IPC:
volumes:
- sdrmm-data:/data
- /usr/local/lib/libsdrplay_api.so.3:/usr/local/lib/libsdrplay_api.so.3:ro
ipc: host
ipc: host exposes the host's shared memory to the container. Only use it with a trusted image.
Token
Without a token, the container makes one on first start, keeps it in /data/access-token and
logs a sign-in link:
docker compose logs sdrmm | grep token
docker compose exec sdrmm cat /data/access-token
To choose your own, put it in a .env file beside docker-compose.yml, kept out of version
control:
SDRMM_TOKEN=replace-with-a-long-random-secret
HTTPS
The container serves HTTPS with a self-signed certificate kept in /data/tls. A
tunnel needs none: set SDRMM_NO_TLS=1. To use your own, mount it read-only:
volumes:
- sdrmm-data:/data
- /srv/sdrmm/certs:/certs:ro
command: ["--bind", "0.0.0.0:8080", "--tls-cert", "/certs/fullchain.pem", "--tls-key", "/certs/privkey.pem"]
The container runs as UID 10001 and must be able to read both files, including symlink
targets. Phones need the self-signed certificate to name the host clients use:
command: ["--bind", "0.0.0.0:8080", "--tls-name", "radio.example"]
Publish the UDP port as well for WebTransport:
ports:
- "8080:8080"
- "8080:8080/udp"
Phones
Allow phones opens port 8443 inside the container. Publish it too:
ports:
- "8080:8080"
- "8443:8443"
The container sees only its own addresses, so the QR code lists hosts the phone cannot reach. Add the phone with Manual and the host's address instead.
As a system service
Run the portable server under its own user with USB access and fixed paths:
/usr/local/bin/sdrmm \
--bind 0.0.0.0:8080 \
--db /var/lib/sdrmm/sdrmm.db \
--recordings-dir /var/lib/sdrmm/recordings
Stop it with a normal termination signal so recordings can finish. A bind other machines can reach gets a token and HTTPS.
Before leaving it unattended
Test the packaged build with your radio:
- Save the
sdrmm --doctorreport. - Stream for 30 minutes and check Drops, audio, and spectrum.
- Try tuning, gain, rate, and every control you plan to use.
- Unplug and replug the radio. The workspace should pick it up again.
- Record a short capture and play it back.
API
REST, WebSocket, and MCP drive the same live receiver as the interface. Changes reach every connected client.
| Endpoint | Serves |
|---|---|
/api/docs | Swagger UI |
/api/openapi.json | OpenAPI schema |
/api/ws | WebSocket |
/api/wt | WebTransport |
/mcp | MCP over streamable HTTP |
The OpenAPI schema is also in the repository, for generating clients without a running server.
With a token set, send it on every request:
curl -H "Authorization: Bearer $SDRMM_TOKEN" http://receiver.local:8080/api/state
REST
| Area | Routes |
|---|---|
| State and discovery | /api/state, /api/devices, /api/saved-radios, /api/channeltypes, /api/clients, /api/patch/catalog, /api/serial/ports, /api/position/nmea-devices |
| Nodes | /api/workspaces/active/nodes/{node}: channel and device settings, actions (tune, retype, scan, hunt, playback, network export, transmit, send); /api/devices/network and /api/devices/serial add a radio; /api/patch/wiring lists where a wire fits |
| Workspaces | /api/workspaces: activate, apply, undo, redo, export, import; /api/workspaces/active/resolved |
| Saved setups | /api/templates, /api/examples, /api/presets, /api/bookmarks |
| Data | /api/decoderlog, /api/recordings, /api/audiorecordings, /api/calls, /api/images, /api/occupancy, /api/sinks |
| Processing nodes | /api/arrays, /api/processors, /api/fusion, /api/survey, /api/missions |
| Tools | /api/tools, /api/nanovna, /api/cps (radio programmer), /api/denoise-models |
| Reference | /api/bandplan, /api/satellites, /api/ionosonde |
| Access | /api/auth, /api/phones |
| Credentials | /api/secrets: POST stores an Event output credential and returns its id, PUT /api/secrets/{id} sets one a workspace names. Values are never read back |
| Server | /api/status, /api/about, /api/doctor, /api/diagnostics, /api/transport |
Errors are JSON with error, an optional detail, and an optional stable code such as
not_found or auth.
WebSocket
Clients subscribe to streams over /api/ws. The server sends decoded records, scanner and hunt
progress, levels, node updates, state-change notices, and binary spectrum, audio, video, IQ,
symbol and surface frames. When it says some state changed, fetch that state again through REST.
Stream IDs belong to one connection. A client that sends Present joins the list of people
here and can send Point to share its pointer, selection, and drags. PipelineHealth reports
the queues and, over WebTransport, the round trip and packet loss of the client's own link.
WebTransport
GET /api/transport names the UDP port and the SHA-256 of the session certificate when the server
offers WebTransport. Connect to https://<server>:<port>/api/wt with that hash in
serverCertificateHashes, and ?token=... if a token is set. Read the hash before every
connection; the certificate rotates.
The session carries the same messages as the WebSocket. Each stream message follows its length
as a little-endian u32:
| Carrier | Holds |
|---|---|
| The bidirectional stream the client opens | ClientCommand up, ServerEvent down, as JSON |
| Datagrams | Audio frames, one each; drop any that arrive behind a newer one |
| Server streams | The binary frames of one stream ID each, newest first when the link is slow |
A workspace PUT sent against an older revision is merged with what others wrote since. A channel
PATCH with a base lands only the fields changed from it. Undo and redo act on the caller's own
steps, named by the x-sdrmm-author header. The messages are ClientCommand and ServerEvent in the
OpenAPI schema; the web client in web/src is the reference implementation.
MCP
Point an MCP client at /mcp on the server, with the bearer header if a token is set. Its
tools edit the open canvas the way a user does: add, change and remove nodes, draw and cut wires,
tune radios, set channels, scan, and undo. Every change shows up on the canvas. It also reads the
decoder log and spectrum, and runs the tools. Tool arguments use the
same types as the OpenAPI schema.
Build and test
CI and local development run the same cargo xtask commands.
Prerequisites
| Tool | Version |
|---|---|
| Rust | 1.99.1, pinned in rust-toolchain.toml; rustup installs it |
| Node | 26 |
| pnpm | 12, exact version in web/package.json |
| FFmpeg | 9, built by scripts/build-media.py |
| Native | C/C++ compiler, Clang/libclang, CMake, GNU Make, NASM, Python 3.12+ |
sudo apt-get install -y build-essential cmake clang libclang-dev python3 nasm # Debian, Ubuntu
brew install cmake python nasm # macOS
Fuzzing and cargo xtask sanitize also need a nightly toolchain. SoapySDR loads at runtime, so no
development package is needed.
Build and run
git clone https://github.com/Newspicel/sdrmm.git
cd sdrmm
python3 scripts/build-media.py
pnpm --dir web install --frozen-lockfile
pnpm --dir web build
cargo run -p sdrmm
Open http://localhost:8080.
The media script builds the few FFmpeg 9.0.2 codecs SDRmm needs, plus fdk-aac 2.0.3 for xHE-AAC,
from checksummed source into .media/<target> as shared libraries. For the host target it also
writes .media/cargo.toml, which .cargo/config.toml includes, so every Cargo command uses that
FFmpeg (FFMPEG_DIR, plus LD_LIBRARY_PATH on Linux or DYLD_LIBRARY_PATH on macOS). On
Windows, also add $FFMPEG_DIR/bin to PATH. A system FFmpeg older than 9 does not compile. For cross builds,
pass --target <triple> to the script. Nix uses its own FFmpeg with fdk-aac.
The server embeds web/dist, so build the frontend first. Without it, the server shows a
placeholder page.
For hot reload of the frontend and automatic backend restarts:
cargo xtask dev --watch
Open http://localhost:5173. Vite forwards API and WebSocket traffic to port 8080. Without
--watch, the backend runs once.
Windows
Build from a Visual Studio developer shell with LLVM and MSYS2 Make installed; the CI Rust action
in .github/actions/rust shows the setup. Set MEDIA_SHELL_BIN to the folders holding bash,
make, and clang-cl, and LIBCLANG_PATH to libclang. The script adds those folders only for
the tools it runs, so MSYS2's link.exe never hides the MSVC linker.
ARM64 builds the codecs without assembly, because FFmpeg's ARM assembler tools do not ship with
the toolchain. xtask retries Cargo up to three times on Windows, since the
ffmpeg-sys-the-third build script sometimes crashes loading libclang
(issue 145).
Feature flags
The server enables soapy, sdrplay, aaronia, cr8, rtlsdr, hackrf, airspy, airspyhf,
funcube, espsdr, ad936x, net-client, and gpu-fft by default. Packaged releases leave out
cr8. dvsi adds AMBE vocoder chips and is off by default.
cargo run -p sdrmm --no-default-features # no radio drivers
cargo run -p sdrmm --no-default-features --features net-client # network radios only
Recording playback and the signal generator work in every build.
Test without a radio
Add a Signal generator node, pick a signal, and wire it to a matching channel and a Speaker.
Debug builds also offer synthetic radios on the Device node.
| Radio | Key | Is |
|---|---|---|
| Kraken bench ×5 | kraken5 | Five lanes on one clock with a noise source, like a KrakenSDR |
| Coherent Array ×4 | array4 | Four lanes on one clock and LO, with a pilot tone |
| Dongle 1, Dongle 2 | dongle1, dongle2 | One lane each, two elements of a line array; Dongle 1 holds the noise source |
| Test band | band | A band of test signals on one lane |
| Receiver ×4, Transceiver 2×2, Half-duplex 1×1 | quad, transceiver, halfduplex | Lane and transmit shapes |
The bench radios hear one FM emitter at 137° and its echo at 250°. Hardware tests cover real radios.
Checks
| Command | Runs |
|---|---|
cargo xtask check | Format, Clippy, web and site lint and type-check, crate rules, feature-set builds, generated-file drift |
cargo xtask test | Generated fixtures, then Rust, web and site tests, with SoapySDR hidden |
cargo xtask smoke | Playwright against a real sdrmm process |
cargo xtask perf | DSP, channel and engine throughput and allocation gates |
cargo xtask audit | RustSec advisories through cargo-deny |
cargo xtask desktop | Clippy and tests on the desktop app, no installers |
cargo xtask sanitize | Channel tests, with the vendored C, under AddressSanitizer and UBSan |
cargo xtask fuzz | libFuzzer on decoders, parsers and importers |
cargo install --locked cargo-nextest cargo-deny cargo-fuzz
pnpm --dir web exec playwright install chromium
test needs cargo-nextest, audit cargo-deny, fuzz cargo-fuzz, and sanitize clang.
Automated tests never need real hardware.
fuzz runs every target in fuzz/fuzz_targets/, or the one --target names, for --seconds
(default 60) on --jobs workers. --minimize shrinks the corpus instead.
| Target | Feeds |
|---|---|
channel_chain | IQ into every decoder |
channel_settings | Random settings into every decoder |
dv_voice | IQ into the digital voice decoders |
media_bitstream | Audio codec bitstreams |
net_radio_proto | rtl_tcp, SpyServer, KiwiSDR and SDRconnect messages |
iqlink_header | iqlink packets |
recorder_import | SigMF archives |
workspace_import | Workspace exports |
cps_image | Codeplug images |
CI runs check, perf, smoke, test, GPU parity, SIMD kernels, the ETSI table check and
cargo-deny's licenses, bans and sources on every push and pull request. test builds once per
architecture (test archive) and runs the archive in four slices (test shard). The nightly
workflow runs the rest: macOS tests, array realtime, desktop, mobile, iPhone, Android, container
image, Nix, advisories, sanitizers, four hours of fuzzing per target, and measurement suites.
Performance baselines
Throughput gates compare a measurement with a committed baseline:
| Gate | Baseline |
|---|---|
ber::perf::tests::compare_perf_baseline in sdrmm-modem-test-support | crates/modem/baselines/perf_phase0.json, rows tagged by host |
compare_*perf_baseline* in sdrmm-modem --test integration | crates/modem/baselines/*/*perf*.json, rows tagged by host |
survey_every_channel_search_path in sdrmm-channels | crates/channels/baselines/channel_perf/<arch>-<os>.json |
Every row carries the host it was measured on, <arch>-<os>. Gates compare only the rows of the
host they run on and fail when they find none, so a new runner architecture cannot quietly switch
them off.
Record on a quiet machine, in release, and commit the files:
export SDRMM_WRITE_BASELINES=1
cargo test -p sdrmm-modem-test-support --release -- --ignored --exact ber::perf::tests::write_perf_baseline
cargo test -p sdrmm-modem --release --test integration -- --ignored --test-threads=1 --skip compare_ perf_baseline
cargo test -p sdrmm-channels --release --test integration -- --ignored --exact channel_perf::write_perf_baseline
For a CI host, dispatch the Perf baseline workflow (or push to the perf-baseline branch),
download its artifacts, and commit them. Recording replaces the rows of the host that measured them
and keeps every other host's.
Each baseline also stores a reference kernel measurement, so a faster or slower machine of the same host shifts the expectation instead of failing the gate. Run a gate on a machine that is otherwise idle: the kernel is cache resident and barely notices a busy host, while a decoder reading tens of megabytes does.
The modem gate allows 10% below the expectation. The channel survey allows 35%: it compares
every channel at once, and ft8, ft4, wspr and stanag are timed over one pass whose
deferred decode may or may not land inside the window.
Generated files
Regenerate and commit these with the change that caused them:
| When you change | Run | Updates |
|---|---|---|
| REST routes or wire types | cargo xtask codegen | openapi.json, web/src/generated/ |
| Dependencies | cargo xtask licenses | THIRD_PARTY_NOTICES.md, crates/server/data/notices.json, phone notices |
| Band-plan sources | cargo xtask bandplan | crates/server/data/bandplan/ |
assets/icon.svg | cargo xtask icons | Desktop, iPhone, web, site, and docs icons |
Demo scenes in web/e2e/scenes.ts | pnpm --dir web demo:record | site/public/demo/ |
| README screenshots | cargo xtask screenshots | assets/screenshots/ |
cargo xtask check catches stale codegen and notices.
cargo xtask fixtures writes the synthesized decoder SigMF pairs into fixtures/. Git ignores
them, and cargo xtask test regenerates them.
Other tasks
| Command | Does |
|---|---|
cargo xtask excerpt | Trims a capture into a fixture; see fixtures/README.md |
cargo xtask net-capture | Records a public KiwiSDR or SpyServer to SigMF |
cargo xtask replay | Runs a capture through one channel; --center sets the RF centre of a raw file, --images saves pictures |
cargo xtask ber <entry> | Bit error rate curves into target/ber |
cargo xtask ident-matrix | Signal identifier against the fixtures |
cargo xtask compare <dsp|decoders|apps> | Compares with other SDR software; --ours measures SDRmm alone, decoders --signal <id> one signal |
Desktop app
The desktop app embeds Chromium through cef-rs. It is a
workspace member but not a default member. Its build downloads CEF into .cef and needs
CMake and Ninja. On Linux, Chromium also needs its runtime libraries:
sudo apt-get install -y ninja-build libnss3 libatk-bridge2.0-0t64 libcups2t64 libgbm1 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libasound2t64
cargo xtask desktop lints and tests it, cargo xtask desktop --run starts it, and
Releases builds installers.
Before a commit
Format, lint, check, and test what you changed. For docs, run mdbook build docs and check links.
The full gates are cargo xtask check and cargo xtask test.
Architecture
The desktop app and the headless server run the same Rust server and receiver engine, and serve the same React interface.
React client ↔ REST / WebSocket / MCP ↔ Server control plane
↓ commands
Radio / network / recording → DSP engine → audio, events, spectrum, IQ
Crates
| Crate | Responsibility |
|---|---|
sdrmm-dsp | Allocation-free signal-processing primitives; no I/O or internal project dependencies |
sdrmm-modem | Reusable modem algorithms and waveform synthesis, depending only on DSP |
sdrmm-modem-test-support | Modem measurement catalogs, simulations, and baseline tooling; tests and developer tools only |
sdrmm-wire | Shared settings, DTOs, events, patch graph, and OpenAPI schemas |
sdrmm-device | Hardware-independent device traits, capabilities, settings, and registry |
sdrmm-device-recording | SigMF playback behind a Device node |
sdrmm-device-virtual | Synthetic radios, the bench scenes and the signal generator |
sdrmm-usb-stream | Bulk USB streaming shared by the native drivers |
sdrmm-serial | Serial port enumeration, one filtering policy, and the serial library every crate uses |
sdrmm-device-rtlsdr | Native RTL-SDR driver |
sdrmm-device-airspy, sdrmm-device-airspyhf | Native Airspy drivers |
sdrmm-device-hackrf | Native HackRF driver |
sdrmm-device-funcube | FUNcube Dongle Pro+, over USB audio and HID |
sdrmm-device-espsdr | ESP32 running ESP-SDR firmware, over serial |
sdrmm-device-ad936x | AntSDR, PlutoSDR and other AD936x boards, speaking iiod over Ethernet or USB |
sdrmm-iqlink | UDP sample protocol shared by sdrmm-iqlinkd and sdrmm-device-ad936x |
sdrmm-iqlinkd | Daemon on AD936x boards that streams samples over UDP |
sdrmm-device-soapy | Local hardware through SoapySDR |
sdrmm-device-sdrplay | SDRplay RSP receivers through the vendor API, loaded at runtime |
sdrmm-device-rtltcp | Direct rtl_tcp client |
sdrmm-device-spyserver | Direct SpyServer client |
sdrmm-device-sdrconnect | SDRplay SDRconnect over its WebSocket API |
sdrmm-device-kiwisdr | KiwiSDR over its WebSocket API |
sdrmm-device-cr8 | Dragon Labs CR-8 through the vendor SDK, loaded at runtime |
sdrmm-device-aaronia | Aaronia SPECTRAN V6 and V6 ECO through the RTSA API, loaded at runtime |
sdrmm-channels | Every built-in decoder, each a module with its settings, events and registration, plus the decoder registry and signal synthesis |
sdrmm-dvsi | DVSI AMBE vocoder chips over USB serial |
sdrmm-plugins | Loads WASM plugins into the registries |
sdrmm-plugin | SDK for writing a WASM plugin |
sdrmm-plugin-example | Plugin template |
sdrmm-recorder | SigMF writing, reading, scanning, and export |
sdrmm-orbit | SGP4, pass prediction, and Doppler |
sdrmm-tools | Antenna calculator and NanoVNA |
sdrmm-cps | Codeplug reading, writing, and conversion |
sdrmm-test-support | Allocation and timing helpers for tests |
sdrmm-engine | Device supervision, channelization, scanning, streams, recording, and state snapshots |
sdrmm-server | REST, WebSocket, MCP, persistence, band plans, auth, phones, and embedded assets |
sdrmm-mobile-core | Pairing, pinned TLS client, missions, and pose fusion for the phone apps |
Nothing outside sdrmm-channels names a decoder. The engine, server and frontend read the decoder
registry: descriptors, settings forms, event kinds with their presenters, feeds and trunking. A
shared behavior is a capability or facet a decoder declares.
apps/sdrmm is the CLI and owns the process. apps/desktop starts the same server on a random
loopback port and opens it in a Chromium (CEF) window. Both probe SoapySDR in a short-lived child process.
apps/ios and apps/android are native phone apps on sdrmm-mobile-core.
The dependency rules:
dspdoes no I/O and depends on no project crate.modembuilds reusable modulation algorithms ondsponly.channelspulls no internal crates beyonddsp,modemandwire.- Device crates never depend on
channels,engineorserver. mobile-coredepends onwireonly. Phone builds pull no engine, server, DSP, USB, FFmpeg, codec2, or SQLite crate.test-supportandmodem-test-supportnever reach thesdrmmdependency graph.
cargo xtask check enforces the dependency rules.
One source of truth for wire types
REST bodies, WebSocket messages, settings, and the patch graph are defined once in crates/wire.
OpenAPI derives from them, and cargo xtask codegen generates the TypeScript types.
The client builds its controls from what the server reports: device capabilities, channel descriptors, and the node palette. A control never exists in the UI that the running build does not support.
Control plane and DSP plane
The DSP path takes settings through command queues and publishes through bounded snapshots and buffers. It never does I/O, takes a lock, allocates, or awaits.
The control plane owns HTTP, SQLite, workspace reconciliation, subscriptions, and serialization. It may block and allocate.
Media and recording data leave DSP through preallocated single-producer, single-consumer buffer pools. Workers turn them into network payloads. A full queue never blocks DSP: lost media is reported and recordings fail loudly. Some decoders allocate for variable-size results.
Spectrum, audio, IQ and video travel as binary frames: audio is Opus, video is JPEG and IQ is 16-bit against each block's peak. Clients that ask for the same spectrum size and rate share one reduction, and clients watching one picture share one encoding. Decoder events are typed JSON. Over WebTransport, events keep their order on one stream, audio goes in datagrams and every other stream of frames has a QUIC stream to itself, so one lost packet delays one picture. The WebSocket carries the same messages in one ordered queue. After an invalidation, clients fetch durable state over REST.
cargo xtask perf measures DSP throughput, allocation, decoder searches, and publication.
Arrays
Every capture block carries the index and time of its first sample, so hardware gaps are visible. Each radio lane has a dormant tap. An Array node wakes the taps of its lanes; the engine's array runtime aligns them in time, solves delay, phase and gain on the noise source or a pilot, and corrects each lane with one filter. A lane that slips is realigned and reported.
Array processors run on the aligned block, inline, on worker threads, or dedicated on threads and backends the engine gives them, such as the radar's GPU. Each declares its execution, actions, GPU use and room for events in its descriptor. Their lane outputs, such as a beam or a stitched band, are virtual lanes of the first member radio, so channels, recorders and scopes use them like any radio lane.
The Array never opens hardware. Device nodes own the radios; the Array holds the tuning and gain of its lanes while it exists.
Workspaces and the live engine
The workspace graph is the desired state. Applying it binds saved Device references to found radios, restores their settings, and reconciles channels and engine objects.
Saved references identify a radio by backend, serial, key, and variant. Engine IDs are temporary and never saved. A disconnected radio keeps its node and settings until it returns.
Placing channels on radios
When Devices tune themselves, the control plane searches for tuning windows that cover the most channels, using branch-and-bound. Each independently tunable stream gets one window. The search respects wires, bandwidths, tuning ranges, manual settings, and pinned channels.
It stops after 50 ms or 100,000 search nodes and keeps the best answer found. Apply reports
include placement.heard and placement.upper_bound. When they are equal, coverage is proven
optimal for that snapshot. Ties favour existing placements.
Tests compare the search with an exhaustive oracle. For the larger comparison:
cargo test -p sdrmm-engine --lib compares_realistic_sizes -- --ignored --nocapture
Failure and backpressure
Every queue is bounded. Drops, recording faults, truncated exports, WebSocket lag, and reconnects are reported to clients. A slow consumer can never block capture or grow memory without limit.
Tests
| Layer | Tested with |
|---|---|
| DSP | Analytic and golden vectors, allocation and throughput gates |
| Decoders | Recorded IQ with expected output, generated vectors |
| Engine | End-to-end runs on virtual devices |
| Server | Handlers, persistence, streams, auth, OpenAPI, codegen drift |
| Client | Unit tests and browser smoke flows |
Test at the narrowest layer that proves the behaviour. Add end-to-end coverage when a change crosses layers. CI never touches real radios.
Tables from standards
Some decoder constants are copied from the standards:
| Constants | File |
|---|---|
| DAB puncturing and protection profiles | crates/channels/src/dab/protection.rs |
| DAB phase reference | crates/channels/src/dab/ofdm.rs |
| DVB-S puncturing and Reed-Solomon parameters | crates/channels/src/datv/dvbs.rs |
| DVB-S2 LDPC accumulator addresses | crates/channels/src/datv/dvbs2/tables/ |
| DVB-S2X LDPC addresses, constellations and interleavers | crates/channels/src/datv/dvbs2/s2x/ |
| VL-SNR header sequence | crates/channels/src/datv/dvbs2/vlsnr.rs |
| DVB-T continual pilot and TPS carriers | crates/channels/src/datv/dvbt/en300744.rs |
| DVB-T2 pilots, reserved carriers, P1, L1 and LDPC tables | crates/channels/src/datv/dvbt/t2/en302755/ |
Sources: ETSI EN 300 401 (DAB), TS 102 563 (DAB+), EN 300 421 (DVB-S), EN 302 307-1 and -2 (DVB-S2/S2X), EN 300 744 (DVB-T), EN 302 755 (DVB-T2), TS 102 606 (GSE), and ES 201 980 (DRM).
s2x_tables.py and dvbs2_spec_check.py in crates/modem-test-support/scripts/ generate the
DVB-S2/S2X tables from the ETSI PDF text and check them against it. dvbt_tables.py does the same
for DVB-T and DVB-T2. The CI etsi job runs all three. The VL-SNR seed and Walsh-Hadamard rows
are typed from the standard.
Tests catch transcription errors by checking independent properties: puncturing density, polynomial roots, published CRC values, and parity of encoded words.
Decoder plugins
A plugin is a decoder compiled to WebAssembly. Drop the .wasm into the plugins folder
(--plugins-dir, default sdrmm/plugins in the platform data folder) and it appears next to the
built-in decoders: same palette entry, same settings form, same logs, maps and wires. The folder is
watched, so a rebuilt plugin is loaded again on its own, and its running channels reopen on the new
build with their settings and wires. A channel the new build refuses keeps the old one and the
reason shows as an error. A plugin whose type id a built-in or another plugin already has is
refused with an error; the others still load.
Compiled plugins are cached in sdrmm/plugins in the user cache folder, so a start only compiles
what changed.
Write one
This section is for Rust. For C, Zig, Go, AssemblyScript and others, see Plugins in any language.
Start a new plugin from crates/plugin-example, a carrier detector. Pick the tag of the server
you run:
cargo install cargo-generate
cargo generate --git https://github.com/newspicel/sdrmm --tag v2.4.3 crates/plugin-example \
--name my-plugin --define sdrmm_tag=v2.4.3
cd my-plugin
cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/my_plugin.wasm ~/Library/Application\ Support/sdrmm/plugins/
An existing crate adds the SDK as a git dependency:
sdrmm-plugin = { git = "https://github.com/newspicel/sdrmm", tag = "v2.4.3" }
A plugin crate depends on sdrmm-plugin, builds as a cdylib, implements Plugin and calls
export! once:
Plugin item | Gives |
|---|---|
Settings | Your settings struct, read from JSON with defaults for absent fields |
manifest() | Type id, name, family, input rate, settings form and event kinds |
layout() | Input rate and the band the settings occupy |
open(), apply() | A running channel, and new settings for it |
process() | One block of IQ in; audio, events and node readouts out |
present() | How one of your events reads in logs and on maps |
The settings form is what the channel node shows: choices, numbers with ranges and units, toggles, text, conditional fields and lists. The frontend draws it from the manifest, so a plugin ships no UI code. Event kinds carry view hints (table, meter, trend, picture) and a map symbol; a status carries the health footer (lock, SNR, frequency error, errors per second). A family or symbol the server does not know gets its own palette group or a dot on the map.
Transmit
Set can_transmit on the descriptor, implement Transmitter and call export_transmit! once.
submit() takes audio or a frame, generate() fills baseband at the input rate. The example's
Beacon sends frames as two-tone FSK.
A Modulator node feeds it. Set transmits_audio to take an audio wire. List fields in the
descriptor's message to get a form with a Send button: each filled form arrives as UTF-8 JSON
in a frame. The example's Beacon declares one text field and keys what is typed into it.
Array processors
A plugin can also bring an array processor: it runs on every lane of an Array node at once, like
the direction finder. Declare it as the manifest's processor (type id, name, lane range, needs,
settings form, readouts, emitted kinds), implement Processor and call export_processor! once.
process() gets one block of every lane and sets readouts by their declared order, the health
footer and events. Its node gets the face every built-in processor has, drawn from what its type
declares. The example's LaneMeter reports each lane's power and phase against the first.
ABI
The server runs plugins built for its own ABI version and refuses any other.
Speed
sdrmm_plugin::dsp and sdrmm_plugin::modem are the building blocks the built-in decoders use.
The heavy kernels in sdrmm_plugin::kernels (FIR, FFT, resampler) run natively in the server on
the plugin's own memory. Under cargo test they run in-process, so a plugin tests like any crate.
Safety
Each call has a time budget and each instance at most 256 MiB of memory. A plugin stuck in a loop stops its own channel, which then says so on its node, and never holds the receiver. One that grows past the cap fails its own allocation. A plugin is code you chose to install: it is isolated from crashing the server, not vetted.
Plugins in any language
A plugin is a core WebAssembly module. Rust gets the SDK; any other language that builds
wasm32 without WASI implements the ABI below. The contract lives in
crates/wire/src/plugin.rs; this page follows it.
Toolchains
| Language | Build | Notes |
|---|---|---|
| C, C++ | zig cc -target wasm32-freestanding -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic -o p.wasm p.c | Or clang --target=wasm32 with the same flags and wasm-ld installed |
| Zig | zig build-exe p.zig -target wasm32-freestanding -fno-entry -rdynamic -O ReleaseSmall | export fn for each export |
| AssemblyScript | asc p.ts -o p.wasm -O --use abort= | Hand out buffers from heap.alloc, which the GC never moves or frees |
| Go | tinygo build -target=wasm-unknown -o p.wasm . | //export name on each function. Standard Go needs WASI or JS imports and is refused |
Module rules
- Export the linear memory as
memory, your only one. It grows to at most 256 MiB; a largermemory.growfails. - Import nothing but the
sdrmmmodule below: no WASI, noenv. An unknown import fails the load. - Numbers in memory are little-endian. Strings are UTF-8 JSON.
- The host opens one instance for control calls and one more per channel, so per-channel state can be global.
- A call that runs past half a second stops its channel.
Exports
| Export | Signature | Does |
|---|---|---|
sdrmm_abi | () -> u32 | ABI version, currently 1 |
sdrmm_alloc | (len: u32) -> ptr | A buffer for the host to write len bytes into |
sdrmm_manifest | () -> i64 | Packed manifest JSON |
sdrmm_call | (op: i32, ptr, len) -> i64 | A control call, JSON in, packed JSON reply out |
sdrmm_open | (rate: f64, ptr, len) -> i32 | Opens a channel at rate Hz with settings JSON; a handle, or -1 |
sdrmm_apply | (handle, ptr, len) -> i32 | New settings JSON; 0, or -1 |
sdrmm_input | (handle, samples: u32) -> ptr | Where the host writes the next block |
sdrmm_process | (handle, samples: u32) -> ptr | Runs the block; where the output header is |
sdrmm_retuned | (handle) | Optional. The channel moved; drop what you accumulated |
sdrmm_close | (handle) | The channel is gone |
sdrmm_error | () -> i64 | Packed message for the last -1 |
ptr, len and handle are u32, u32 and i32. A packed i64 is ptr << 32 | len. Every
buffer you return stays yours and must stay valid until the host's next call into the instance.
A buffer from sdrmm_alloc is yours again once the call it was written for returns.
Control calls
sdrmm_call answers {"ok": value} or {"err": "message"}.
op | Input | ok value |
|---|---|---|
1 layout | Settings | {"input_rate_hz": 48000.0, "band": [-6000.0, 6000.0], "audio_channels": 1} |
2 normalize | Settings, {} for the defaults | The settings with every default filled in |
3 present | {"kind": "burst", "data": {...}} | A Presentation: {"summary": "Burst", "fields": [...]} |
Settings are the JSON object your form produces. Treat absent fields as defaults.
Manifest
{
"abi": 1,
"descriptor": {
"type_id": "burst", "name": "Burst", "summary": "Strong bursts",
"family": "utility", "bandwidth_hz": 12000.0, "input_rate_hz": 48000.0,
"emits": ["burst"]
},
"form": [
{ "key": "threshold_db", "label": "Threshold",
"control": { "type": "number", "min": -60.0, "max": 0.0, "step": 1.0, "unit": "dB" } }
],
"kinds": [
{ "kind": "burst", "name": "Burst", "noun": "Bursts", "caps": ["log"],
"hue": "amber", "lifetime": { "type": "instant" } }
]
}
descriptor, form entries and kinds entries are the ChannelDescriptor, SettingField and
EventKindEntry schemas in openapi.json.
Optional: home_hz, the frequency a new channel starts on, and processor, a ProcessorType.
One block
sdrmm_input(handle, n)returns a pointer; the host writesnsamples there, each anf32I then anf32Q.sdrmm_process(handle, n)returns a pointer to nineu32words:
| Word | Holds |
|---|---|
| 0, 1 | Audio pointer and length in f32 samples, interleaved by audio_channels |
| 2 | Audio rate in Hz |
| 3, 4 | Events: pointer and byte length of a JSON array, 0 for none |
| 5, 6 | Status: pointer and byte length of a JSON object, 0 when unchanged |
| 7 | Frames heard but failing their checks, counted over the channel's life |
| 8 | Blocks dropped, counted over the channel's life |
[{"kind": "burst", "data": {"db": -12.5}}]
{"health": {"lock": true, "snr_db": 21.5, "freq_error_hz": 30.0, "errors_per_s": 0.0}}
A status may also carry readouts, a list of fields like those a presentation has.
Host imports
The sdrmm module runs the heavy kernels natively on your memory. Samples are the same
interleaved f32 pairs. A negative return is a refusal.
| Import | Signature | Does |
|---|---|---|
log | (level: u32, ptr, len) | Writes a UTF-8 line to the server log |
fir_new | (taps_ptr, taps_len, decimation: u32) -> i32 | A complex FIR over real f32 taps |
fir_process | (handle, in_ptr, in_len, out_ptr, out_cap) -> i32 | Samples written |
fft_new | (len: u32, inverse: u32) -> i32 | An FFT of len points |
fft_run | (handle, ptr, len) -> i32 | Transforms len samples in place |
resampler_new | (ratio: f64) -> i32 | A fractional resampler, output rate over input rate |
resampler_process | (handle, in_ptr, in_len, out_ptr, out_cap) -> i32 | Samples written |
kernel_drop | (handle) | Frees a kernel |
Example
A complete plugin in C without libc. It builds as in the table and is tested against the real
host by crates/plugins/tests/c_plugin.rs.
/*
* Burst: logs each block whose power rises above -20 dBFS.
* The sdr-- plugin ABI in plain C, no libc. Build:
* zig cc -target wasm32-freestanding -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic \
* -o burst.wasm burst.c
*/
#include <stddef.h>
#include <stdint.h>
#define EXPORT __attribute__((visibility("default")))
#define PAGE 65536
#define THRESHOLD 0.01f
enum { OP_LAYOUT = 1, OP_NORMALIZE = 2, OP_PRESENT = 3 };
static const char MANIFEST[] =
"{\"abi\":1,"
"\"descriptor\":{\"type_id\":\"burst\",\"name\":\"Burst\",\"summary\":\"Strong bursts\","
"\"family\":\"utility\",\"bandwidth_hz\":12000.0,\"input_rate_hz\":48000.0,"
"\"emits\":[\"burst\"]},"
"\"kinds\":[{\"kind\":\"burst\",\"name\":\"Burst\",\"noun\":\"Bursts\",\"caps\":[\"log\"],"
"\"hue\":\"amber\",\"lifetime\":{\"type\":\"instant\"}}]}";
static const char LAYOUT[] =
"{\"ok\":{\"input_rate_hz\":48000.0,\"band\":[-6000.0,6000.0]}}";
static const char NORMALIZED[] = "{\"ok\":{}}";
static const char PRESENTED[] = "{\"ok\":{\"summary\":\"Burst\"}}";
static const char UNKNOWN[] = "{\"err\":\"unknown op\"}";
static const char EVENT[] = "[{\"kind\":\"burst\",\"data\":{}}]";
static const char LOCKED[] = "{\"health\":{\"lock\":true}}";
static const char UNLOCKED[] = "{\"health\":{\"lock\":false}}";
static const char NO_ERROR[] = "";
extern unsigned char __heap_base;
static uintptr_t heap_top = (uintptr_t)&__heap_base;
typedef struct {
void *ptr;
size_t cap;
} Region;
static Region inbox;
static Region input;
static uint32_t header[9];
static int loud;
static void *grab(size_t bytes) {
uintptr_t at = (heap_top + 7) & ~(uintptr_t)7;
uintptr_t end = at + bytes;
size_t have = __builtin_wasm_memory_size(0) * PAGE;
if (end > have && __builtin_wasm_memory_grow(0, (end - have + PAGE - 1) / PAGE) < 0)
__builtin_trap();
heap_top = end;
return (void *)at;
}
static void *fit(Region *region, size_t bytes) {
if (bytes > region->cap) {
region->ptr = grab(bytes);
region->cap = bytes;
}
return region->ptr;
}
static int64_t pack(const void *ptr, size_t len) {
return ((int64_t)(uintptr_t)ptr << 32) | (int64_t)len;
}
EXPORT uint32_t sdrmm_abi(void) { return 1; }
EXPORT uint32_t sdrmm_alloc(uint32_t len) { return (uint32_t)(uintptr_t)fit(&inbox, len); }
EXPORT int64_t sdrmm_manifest(void) { return pack(MANIFEST, sizeof MANIFEST - 1); }
EXPORT int64_t sdrmm_call(int32_t op, uint32_t ptr, uint32_t len) {
switch (op) {
case OP_LAYOUT: return pack(LAYOUT, sizeof LAYOUT - 1);
case OP_NORMALIZE: return pack(NORMALIZED, sizeof NORMALIZED - 1);
case OP_PRESENT: return pack(PRESENTED, sizeof PRESENTED - 1);
default: return pack(UNKNOWN, sizeof UNKNOWN - 1);
}
}
EXPORT int32_t sdrmm_open(double rate, uint32_t ptr, uint32_t len) {
loud = 0;
return 0;
}
EXPORT int32_t sdrmm_apply(int32_t handle, uint32_t ptr, uint32_t len) { return 0; }
EXPORT uint32_t sdrmm_input(int32_t handle, uint32_t samples) {
return (uint32_t)(uintptr_t)fit(&input, (size_t)samples * 2 * sizeof(float));
}
EXPORT uint32_t sdrmm_process(int32_t handle, uint32_t samples) {
const float *iq = input.ptr;
float power = 0.0f;
for (uint32_t n = 0; n < 2 * samples; n++)
power += iq[n] * iq[n];
int now = samples > 0 && power / (float)samples > THRESHOLD;
for (int word = 0; word < 9; word++)
header[word] = 0;
if (now && !loud) {
header[3] = (uint32_t)(uintptr_t)EVENT;
header[4] = sizeof EVENT - 1;
}
if (now != loud) {
const char *status = now ? LOCKED : UNLOCKED;
header[5] = (uint32_t)(uintptr_t)status;
header[6] = now ? sizeof LOCKED - 1 : sizeof UNLOCKED - 1;
}
loud = now;
return (uint32_t)(uintptr_t)header;
}
EXPORT void sdrmm_close(int32_t handle) {}
EXPORT int64_t sdrmm_error(void) { return pack(NO_ERROR, 0); }
Transmit and arrays
A transmitter sets can_transmit and adds the exports in crates/wire/src/plugin/transmit.rs:
| Export | Signature |
|---|---|
sdrmm_tx_open | (rate: f64, ptr, len) -> handle |
sdrmm_tx_apply | (handle, ptr, len) -> i32 |
sdrmm_tx_submit | (handle, kind: u32, ptr, len) -> i32, kind 0 audio as f32, 1 frame bytes |
sdrmm_tx_generate | (handle, max_samples: u32) -> i64, packed pointer and sample count of I/Q |
sdrmm_tx_close | (handle) |
An array processor declares processor in the manifest and adds the exports in
crates/wire/src/plugin/processor.rs. sdrmm_array_input takes every lane's block one after the
other; sdrmm_array_process returns a ten-word header laid out in ArrayHeader there.
Phone apps
Both apps sit on crates/mobile-core, a Rust core bound to Swift and Kotlin with UniFFI. It
handles pairing, the pinned TLS client, missions, and pose fusion.
| Command | Runs |
|---|---|
cargo xtask mobile bindings | Swift and Kotlin bindings |
cargo xtask mobile check | Clippy for the iOS and Android targets; --ios or --android picks one |
cargo xtask mobile ios | The core as an XCFramework |
cargo xtask mobile android | The core for arm64-v8a and x86_64; --abi limits it |
cargo xtask check fails if mobile-core pulls the engine, server, DSP, or native libraries into a
phone build.
iPhone
The app lives in apps/ios and needs Xcode 27.
| Command | Runs |
|---|---|
cargo xtask ios generate | The core and the Xcode project, through XcodeGen |
cargo xtask ios build | The app for the simulator |
cargo xtask ios test | Unit tests; --ui adds UI tests, --only <test> a subset |
cargo xtask ios lint | swift-format and the comment check |
cargo xtask ios e2e | The app against a real sdrmm |
cargo xtask ios archive | A Release archive in target/ios/, after a privacy manifest check |
cargo xtask ios upload | Upload the archive to App Store Connect |
Tests run on an iPhone 17 simulator with iOS 27; SDRMM_IOS_SIMULATOR names another one.
To run it on your own iPhone, run cargo xtask ios generate, open apps/ios/SDRmm.xcodeproj, and
set your team in apps/ios/Config/Local.xcconfig as DEVELOPMENT_TEAM = <team>.
Tagged releases upload iOS when the app, mobile core, wire types, dependencies, or build setup changed since the previous tag. The version comes from the shared release stamp. The build number uses the workflow run and attempt. Uploads appear in TestFlight after Apple processes them; App Store review is separate.
CI needs APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, and
APP_STORE_CONNECT_PRIVATE_KEY in GitHub secrets. Cloud signing requires an Admin team key.
Locally, use the signed-in Xcode account or set
APP_STORE_CONNECT_KEY_PATH, APP_STORE_CONNECT_KEY_ID, and APP_STORE_CONNECT_ISSUER_ID.
SDRMM_IOS_BUILD_NUMBER overrides the default build number before archiving.
Android
The app lives in apps/android: Kotlin, Jetpack Compose, Gradle 9.8, API 29 and up. Gradle
builds the Rust core through cargo xtask mobile android, so no cargo-ndk is needed.
Toolchain
Install JDK 21, the Android command line tools, and then:
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
yes | sdkmanager --licenses
sdkmanager "platform-tools" "emulator" \
"platforms;android-37.0" "build-tools;37.0.0" "ndk;30.0.16248370" \
"system-images;android-36;google_apis_playstore;arm64-v8a" "extras;google;auto"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.16248370"
avdmanager create avd -n sdrmm-api36 -k "system-images;android-36;google_apis_playstore;arm64-v8a" -d pixel_9
Keep the export lines in your shell profile. cargo xtask mobile android adds the Rust targets.
The Gradle daemon picks JDK 21 whatever java your shell runs. Add -Psdrmm.abis=arm64-v8a to a
Gradle command to build the core for one ABI only.
Build and test
Run these in apps/android:
./gradlew spotlessApply
./gradlew spotlessCheck checkSourceRules lintDebug testDebugUnitTest assembleDebug
./gradlew :app:testDebugUnitTest --tests "dev.newspicel.sdrmm.sensors.*"
./gradlew -p build-logic test
The debug APK lands in app/build/outputs/apk/debug/app-debug.apk. Check its 16 KB page
alignment:
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 -v 4 app/build/outputs/apk/debug/app-debug.apk
Device tests run on the emulator:
emulator -avd sdrmm-api36 -no-snapshot -no-audio &
adb wait-for-device
./gradlew connectedDebugAndroidTest
adb install -r app/build/outputs/apk/debug/app-debug.apk
./gradlew assembleRelease bundleRelease needs SDRMM_KEYSTORE, SDRMM_KEYSTORE_PASSWORD,
SDRMM_KEY_ALIAS, and SDRMM_KEY_PASSWORD, and fails without them rather than writing an
unsigned APK.
On the emulator, the server on your computer is 10.0.2.2. Move the phone with
adb emu geo fix 13.4050 52.5200.
Android Auto
-
Use a phone with Android Auto, or the Play Store emulator signed in with Android Auto installed.
-
In Android Auto, tap Version ten times, open Developer settings, turn on Unknown sources, then Start head unit server.
-
Run the head unit:
apps/android/gradlew -p apps/android installDebug adb forward tcp:5277 tcp:5277 "$ANDROID_HOME/extras/google/auto/desktop-head-unit" -
Open SDRmm from the head unit's launcher. Type
dayornightin the head unit console to switch the map style.
Check that Missions lists over the map, the DF panel updates about once a second,
Navigate opens the car's navigation app, and a moved target posts a New target alert.
CI
All phone jobs run nightly. The mobile job runs mobile check --android and mobile bindings.
The ios job runs mobile check --ios, ios lint, and ios test. The android job runs the
build-logic tests, the Gradle checks, unit tests, assembleDebug, and the alignment check.
ios-ui runs ios test --ui, ios-e2e runs ios e2e, and android-device runs
connectedDebugAndroidTest on an API 36 emulator.
Hardware tests
These tests need real radios, so they are ignored by default and CI never runs them. Run them on an idle radio that no other program holds.
Capture health
Measures loss on real radios, from USB through DSP to publication:
SDRMM_CAPTURE_DRIVER=hackrf SDRMM_CAPTURE_RATE=8000000 SDRMM_CAPTURE_SECONDS=30 \
cargo test -p sdrmm-engine --lib --no-default-features --features rtlsdr,hackrf \
connected_radio_capture_health -- --ignored --nocapture
SDRMM_CAPTURE_DRIVER is hackrf (default), rtlsdr, or both. Default rates are 20 MS/s for
HackRF and 2.4 MS/s for RTL-SDR; the default length is 10 s. The test fails on any loss unless told
otherwise.
| Variable | Does |
|---|---|
SDRMM_CAPTURE_CHANNELS=8 | Channels per radio, default 4 |
SDRMM_CAPTURE_SPREAD_HZ=2000000 | Spread channels over this span instead of 25 kHz steps |
SDRMM_CAPTURE_MIXED=1 | Cycle NFM, WFM, AM, and SSB |
SDRMM_CAPTURE_RETUNE=1 | Retune channels every 5 s |
SDRMM_CAPTURE_DEVICE_RETUNE=1 | Retune radios every 5 s |
SDRMM_CAPTURE_ALTERNATE_RATE=10000000 | With device retunes, alternate to this rate |
SDRMM_CAPTURE_RTL_RATE=3200000 | Override the RTL-SDR rate only |
SDRMM_CAPTURE_CPU_THREADS=4 | Add CPU load threads |
SDRMM_CAPTURE_RECORD=1 | Record IQ and verify sample counts |
SDRMM_CAPTURE_HISTORY=1 | Capture history, then record live, and verify |
SDRMM_CAPTURE_HISTORY_SECONDS=6 | History length, default 1 s |
SDRMM_CAPTURE_TRANSPORT_SECONDS=5 | Raw USB test length per radio, default up to 30 s; 0 skips it |
SDRMM_CAPTURE_ALLOW_DROPS=1 | Measure overload instead of failing |
Software counters miss some USB losses. For RTL-SDR, also check the hardware byte counter:
SDRMM_RTL_TEST_RATE=3200000 SDRMM_RTL_TEST_SECONDS=60 \
cargo test -p sdrmm-device-rtlsdr --lib hardware_counter_stream_is_continuous -- --ignored --nocapture
Defaults are 2.4 MS/s for 30 s. SDRMM_RTL_TEST_SERIAL picks a dongle; otherwise the first one
outside a KrakenSDR is used. The 8-bit counter cannot see losses of exact multiples of 256 bytes.
KrakenSDR
cargo test -p sdrmm-device-rtlsdr kraken_ -- --ignored --nocapture --test-threads=1
cargo test -p sdrmm-engine --test integration -- --ignored --nocapture --test-threads=1 array_hardware::
SDRMM_BENCH_ASSERT=mac cargo test -p sdrmm-engine --release \
--test integration radar_hardware::benchmark_radar -- --ignored --nocapture
SDRMM_RADAR_FM_HZ=<local FM in Hz> cargo test -p sdrmm-engine --release \
--test integration radar_hardware::kraken_fm_live -- --ignored --nocapture
Add --features probe to the array tests to check that no processor sees the noise source.
The radar benchmarks use synthetic FM and DAB signals and need no radio. SDRMM_BENCH_ASSERT=pi5
checks the Raspberry Pi 5 budget. Raw numbers land in target/hardware/*.csv.
kraken_df_known_bearing needs antennas and a transmitter:
| Variable | Value |
|---|---|
SDRMM_DF_TX_HZ | Transmitter frequency |
SDRMM_DF_BEARINGS | Bearings relative to element 1, at least 3, none within 10° of its axis or mirrored about it, like 40,130,250 |
SDRMM_DF_RADIUS_M | Array radius, default 0.35 |
SDRMM_DF_WINDING | clockwise (default) or counter_clockwise |
SDRMM_DF_GAIN_DB, SDRMM_DF_BANDWIDTH_HZ | Gain, default 30; band, default 20 kHz |
It asks you to move the transmitter, takes 60 reports per bearing, and passes when every error is under 5° or 2 sigma, the RMS error lies within 0.5 to 2 times the mean sigma, and at least 90% of reports count one source.
Measured
KrakenSDR 1000 to 1004, no antennas, 2.4 MS/s, Apple M4 Max.
Share of I/Q values at full scale with the noise source on, lane 1:
| MHz | 0 dB | 8.7 dB | 19.7 dB | 29.7 dB | 49.6 dB |
|---|---|---|---|---|---|
| 30 | 0 | 0.032 | 0.133 | 0.164 | 0.171 |
| 100 | 0 | 0.046 | 0.137 | 0.164 | 0.171 |
| 433.92 | 0 | 0.045 | 0.129 | 0.159 | 0.168 |
| 868 | 0 | 0.044 | 0.129 | 0.157 | 0.160 |
| 1090 | 0 | 0 | 0.022 | 0.098 | 0.116 |
| 1300 | 0 | 0 | 0.006 | 0.055 | 0.071 |
| 1700 | 0 | 0 | 0 | 0.004 | 0.007 |
Noise source solves over all 29 gain steps, lanes 2 to 5 against lane 1:
| MHz | Lowest coherence | Lowest pair coherence | Lowest purity | Most clipped samples | Phase from 0 dB |
|---|---|---|---|---|---|
| 100 | 0.83 | 0.82 | 0.95 | 0.58 | −8.9° to +3.8° |
| 433.92 | 0.84 | 0.83 | 0.95 | 0.36 | −9.2° to +2.1° |
| 868 | 0.74 | 0.71 | 0.92 | 0.34 | −9.4° to +2.4° |
| 1090 | 0.91 | 0.91 | 0.97 | 0.28 | −9.3° to +2.5° |
| 1300 | 0.75 | 0.76 | 0.91 | 0.22 | −9.2° to +2.4° |
| 1700 | 0.68 | 0.67 | 0.89 | 0.06 | −9.8° to +1.9° |
Noise solves therefore accept lane coherence from 0.5, pair coherence from 0.5, and bin purity from 0.8. They accept up to 75% clipped samples and add 2° to the calibration sigma.
| Check | Result |
|---|---|
| Start spread, 20 starts at 1.024, 2.4, 2.56 MS/s | One start per lane, printed by the test. Coarse search reaches 256 to 437 ms. |
| 2.88 MS/s | Refused: sample_rate 2880000 outside the windows 225001-300000 and 900001-2560000 Hz |
| Noise source off | Back at the noise floor within 1,024 samples |
| Noise source on exit | Off after a drop, a drop while streaming, a panic, and a server stop |
| Array, 433.92 MHz, 30 dB | Locked and solved in 2.3 s. 18 checks in 3 minutes: delay within 0.007 samples, phase within 0.9°. |
| Retune 100 to 433.92 to 868 MHz, gain 20 to 40 dB | Stale after 0.44 s, solved after 0.69 to 0.71 s |
| Processors during noise | None saw a noise block |
| Direction finder on noise only | 357 reports, 0 sources |
Source count on real tuners, 0 to 29.7 dB. Receiver noise alone: the largest eigenvalue sits at most 1 dB over the rest, dominance counts 0, MDL up to 4. Noise source in a 37.5 or 300 kHz band: the second eigenvalue is 14 to 24 dB down, dominance counts 1, MDL 4. Over the full uncalibrated 2.4 MHz it is 5 to 11 dB down and dominance counts 2. Source counting therefore uses 6 dB and 12 dB thresholds.
Passive radar, median of 10 CPIs, M4 Max shared with other builds:
| FM | Target | DAB | Target | |
|---|---|---|---|---|
| Front, share of a core | 0.05 | 0.10 | 0.11 | 0.15 |
| CPI, one thread | 6.3 ms | 25 ms | 62 ms | 120 ms |
| CPI, crew of 3 | 5.2 ms | 40 ms | 50 ms | |
| CPI, Auto | 5.3 ms on the CPU | 23 ms on the GPU | 30 ms |
The DAB CPI on the GPU stays under 30 ms at a load average of 20, but other apps busy on the GPU can still push it past. Its GPU CAF alone takes 9 to 10 ms.
Not measured yet: bearings against a transmitter at known bearings, radar on a live FM station (both need antennas), and a Raspberry Pi 5.
GPU measurements
Where the GPU pays off, and where it does not. Measured on one Apple M4 Max (40 GPU cores, 48 GB) with Metal and wgpu 30.0.1, release build, Rust nightly 2026-08-01. Each case ran 8 warmups and 101 timed iterations. Times include upload, execution, and readback, but not setup. Other chips will differ.
| Work | CPU median | GPU median | Runs on |
|---|---|---|---|
| Spectrum frame, 64 × 4,096 | 0.48 ms | 0.33 ms | CPU, cheap either way |
| Spectrum frame, 64 × 32,768 | 4.89 ms | 1.23 ms | GPU |
| Spectrum frame, 64 × 65,536 | 11.3 ms | 2.29 ms | GPU |
| Spectrum frame, 64 × 262,144 | 56.0 ms | 7.89 ms | GPU |
| Filter bank, 13 bands, 8,192 samples | 79.5 µs | 177.3 µs | CPU |
| Filter bank, 13 bands, 32,768 samples | 395.4 µs | 222.6 µs | Larger than any real block |
Hardware tests has passive radar timings on a KrakenSDR.
How it runs
The gpu-fft feature turns on GPU spectra and radar. Shaders are portable WGSL with 32-bit
floats. Metal is verified on hardware; the CI gpu job checks Vulkan parity on Lavapipe. Software
adapters are refused in production.
The radar correlation uses at most 512 MiB of GPU memory. If it needs more, has no usable GPU, or hits a GPU error, it runs on the CPU instead and logs a warning. The capture thread hands off preallocated jobs without waiting. Overload is counted; retunes and gaps discard stale work.
A spectrum frame averages up to 64 overlapping transforms in linear power. Spectra below 32,768 points stay on the CPU, which spends at most 128 M transformed samples per second: full overlap up to 61.44 MS/s, 16 transforms per frame at 245.76 MS/s. Larger spectra use the GPU if a startup benchmark shows it at least 20% faster. The GPU takes contiguous samples, cuts the windows itself, and reads back one spectrum per frame. The same benchmark caps it at a quarter of the GPU's time: on the M4 Max, 63 transforms per frame with 50% overlap at 245.76 MS/s, for 4% of the DSP thread. The filter-bank shader is a benchmark only.
Reproduce
With access to the physical GPU:
cargo test -p sdrmm-engine --no-default-features --features gpu-fft --release --lib -- --ignored --nocapture --test-threads=1 gpu::benchmarks
Correctness and CPU fallback on the hardware:
cargo test -p sdrmm-engine --no-default-features --features gpu-fft --release --lib -- --include-ignored --nocapture --test-threads=1 spectrum::tests gpu::caf::tests array::radar::gpu
Rendering
The waterfall and audio spectrogram share one WebGL2 renderer. In Chrome 153 on the same M4 Max (ANGLE Metal), compared with SwiftShader software rendering:
| Waterfall, CSS pixels at 2× | Metal draw | Software draw |
|---|---|---|
| 640 × 240 | 0.076 ms | 2.236 ms |
| 1280 × 720 | 0.246 ms | 12.242 ms |
WebGL2 stays. Plots repaint only when data, settings, size, or visibility change: at 30 rows/s on a 60 Hz display that is half the frames, and idle plots draw nothing.
Also tried, and not adopted:
- Canvas 2D waterfall: held 60 fps at 0.1 to 0.2 ms per frame, but skipped retuning and recolouring history and interpolated colours instead of intensities. Not a fair win.
- MapLibre with 10,000 points: about 60 fps on both Metal and SwiftShader. MapLibre already stops drawing when idle; nothing to change.
Each browser case ran 30 warmup, 180 measured, and 61 idle frames with 1, 4, or 8 views. GPU timer queries measure shader time; frame intervals catch missed deadlines. WebKit 26.5 also passed with zero idle waterfall draws and 16 ms p95 frames, but has no GPU timer queries. These are browser numbers, not packaged desktop app measurements.
Raw results: repaint every frame, repaint on change, WebKit.
From web, with Chrome installed:
node scripts/benchmark-gpu.mjs
pnpm exec playwright install webkit
node scripts/benchmark-gpu.mjs --webkit
Releases
Tagged releases publish portable servers, a source tarball, desktop installers, signed update
bundles, and container images. The nightly workflow rebuilds the rolling nightly release at
03:07 UTC when main has changed.
Changesets
Every user-visible change adds a file to .changeset/:
cargo xtask changeset patch "RTL-SDR: keep gain after reconnect"
To release, on a clean main:
cargo xtask release --dry-run
cargo xtask release
git push origin v1.2.3
release picks the next version from the largest bump among changesets added since the last tag
and tags HEAD. It never commits. The release workflow turns those changesets into the GitHub
release notes (cargo xtask release-notes 1.2.3) and fails on a tag without any. sdrmm.com reads
its changelog from the GitHub releases. Released changesets can be deleted any time.
Versioning
The root workspace version is the source of truth. Set it with:
cargo xtask set-version 1.2.3
Stable tags use v<major>.<minor>.<patch>. Nightlies use the UTC date as YY.M.D.
Windows MSI requires major and minor to fit in eight bits and patch in sixteen bits; prerelease
suffixes are unsupported. The task validates these limits.
Portable archives
cargo xtask-release dist
cargo xtask-release dist --target aarch64-unknown-linux-gnu
The task installs a missing Rust target, builds the frontend and release binary, verifies embedded
assets, and writes a .tar.gz or .zip under dist/ with the binary, the FFmpeg libraries,
README.md, LICENSE, and THIRD_PARTY_NOTICES.md. cargo xtask-release source-dist writes the source
tarball with the built frontend. cargo xtask-release link-check <dir> checks that macOS and Windows
binaries link only bundled and system libraries.
Archives load SoapySDR at runtime without linking or bundling it. Verify startup on a clean machine both with and without a system SoapySDR installation.
Desktop bundles
Run the compile gate:
cargo xtask desktop
To create installers, install cargo-packager:
cargo install --locked cargo-packager
cargo xtask desktop --bundles app,dmg
Use deb,rpm,appimage on Linux and wix,nsis on Windows. rpm is repackaged from the deb and
needs dpkg-deb and rpmbuild. Installers use system SoapySDR at runtime. Linux and Windows
bundles are built on x86-64 and ARM64, each on a native machine. Windows ARM64 builds nsis alone,
because WiX 3 emits no arm64 package.
The build fetches Chromium (CEF) into .cef and needs CMake and Ninja.
cargo xtask desktop --run builds the app and starts it.
Desktop updates
Release builds check downloads.sdrmm.com for the latest stable release at startup, then GitHub.
Local builds never check. On Linux only the AppImage updates itself. On Windows, MSI and NSIS
installs each update through their own installer kind. Update archives carry a minisign
signature separate from platform code signing. Preserve the private updater key; installed
clients trust its compiled public key. The manifest keeps the keys older Tauri builds read.
Without CARGO_PACKAGER_SIGN_PRIVATE_KEY, packages carry no updater signature and cannot serve as
application updates. Release CI requires signatures and builds the update manifest:
cargo xtask updater-manifest \
--version 1.2.3 \
--dir dist/release \
--base-url https://github.com/Newspicel/sdrmm/releases/download/v1.2.3
Containers
Releases publish Linux amd64 and arm64 images, also mirrored as
ghcr.io/newspicel/sdrminusminus:
ghcr.io/newspicel/sdrmm:<version>
ghcr.io/newspicel/sdrmm:<major>.<minor>
ghcr.io/newspicel/sdrmm:latest
Nightlies update :nightly and add a sha-<commit> tag. The nightly workflow builds both architectures and checks
the binary, SoapySDR modules, server startup, and embedded frontend.
Homebrew
The sdrmm formula lives in homebrew-core and the sdrmm-app cask in homebrew-cask. Homebrew
bumps both after a release.
AUR and WinGet
The release workflow pushes sdrmm-bin and sdrmm-app-bin to the AUR, written by
cargo xtask aur --version 1.2.3 --sums SHA256SUMS --out <dir>. It submits
Newspicel.SDRmm to WinGet once the package is listed in winget-pkgs.
downloads.sdrmm.com
The R2 bucket sdrmm serves downloads.sdrmm.com, the primary download location. A stable release
uploads to releases/<tag>/ first, then to GitHub Releases as the fallback. Once both hold it,
releases/latest, releases/latest.json (updater manifest) and releases/release.json (download
page) move to the new tag. AUR packages and the WinGet manifest point at the mirror; the Homebrew
cask stays on GitHub. Nightlies stay on GitHub only.
After a release, the linux-repo workflow publishes the signed APT and RPM repository under
packages/. Dispatch it by hand to rebuild the repository from a tag.
scripts/r2-upload.sh <prefix> <file>... uploads through wrangler. Denoise models live under
denoise/v1/. cargo xtask denoise-model builds them into target/denoise-model/ and fails until
the catalog in crates/wire/src/audio.rs matches. Then upload:
scripts/r2-upload.sh denoise/v1 target/denoise-model/*.sdrmmnn
Uploads are cached as immutable. Never replace a file; publish a new prefix instead.
Secrets
| Secret | Needed for | Without it |
|---|---|---|
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID | R2 uploads | A tagged release fails |
TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD | Updater signatures, passed to cargo-packager | The release fails |
APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID | macOS signing and notarization | The release fails |
APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_PRIVATE_KEY | iPhone uploads | The iPhone job fails |
AUR_SSH_KEY | Pushing to the AUR | The AUR job is skipped |
WINGET_TOKEN | WinGet submission | The WinGet job is skipped |
PACKAGES_GPG_KEY | Signing the APT and RPM repository | The repository job is skipped |
DISCORD_WEBHOOK | Posting release notes to Discord | The Discord job is skipped |
A tag release warns for every skipped job. Other release jobs continue.
Release checklist
- Run
cargo xtask check,test,smoke, andaudit. - Run
cargo xtask desktopand build the container. - Check generated API, license, icon, and band-plan outputs.
- Validate hardware with the candidate package, including reconnect and recording.
- Confirm updater and platform signing credentials.
- Run
cargo xtask release, push the tag and check every artifact job. - Install a published artifact and run
sdrmm --versionandsdrmm --doctor.
Manual workflow dispatch rehearses the artifact matrix without publishing a GitHub release.