Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SDRmm logo

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

  1. Install SDRmm.
  2. Build your first receiver.
  3. Learn how nodes and wires fit together.

Find a guide

TaskGuide
Connect a radioRadios
Listen to a signalChannels
Decode dataDecoders
Save and replay signalsRecording and playback
Find where a signal comes fromDirection finding
Use a phone in the fieldPhones
Save and switch setupsWorkspaces and presets
Program a radio or measure an antennaTools
Run the radio somewhere elseDeployment
Fix a problemTroubleshooting
Work on SDRmmBuild 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.

InstallationBest for
Desktop appA radio on your computer
Portable serverA Raspberry Pi, home server, or remote receiver
HomebrewmacOS or Linux with Homebrew
APTDebian and Ubuntu
DNFFedora
NixLinux managed with Nix
ContainerDocker

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.

PlatformPackage
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

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

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:

PortCarriesTypical wire
iqRaw radio samplesDevice → channel, Scope, Recorder
audioSoundChannel → Audio FX, Speaker, Audio recorder
eventsDecoded messagesChannel → Readout, Decoder log, Map
basebandOne channel's filtered IQChannel → Baseband scope, Baseband recorder, Network IQ
videoPictures and videoChannel → Video
controlTune, point and run commandsScanner, Satellite → channel; Schedule → Device, recorder
positionLocation and headingGPS position → Map, Array, Satellite, channel
arrayAligned lanes of an antenna arrayArray → Direction finder, Beamformer, Passive radar
bearingA live bearingDirection finder → Beamformer
transmitWhat a radio sendsTransmitter → 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

TypeCarries
IQOne radio lane, retuned by its decoders unless locked
BasebandOne channel, filtered and decimated
AudioLive audio
EventsDecoded events. Calls and transmissions bring their clip
VideoDecoded video frames and pictures
ControlCommands that retune a decoder, point a rotator or run a radio
PositionA live fix and the heading it comes with
ArrayOne lane per antenna element
BearingA live bearing that steers an array
TransmitWhat a radio puts on the air

Event kinds

KindNameShows asFacts
acarsACARSAircraftlog, station, geo
adsbADS-BAircraftlog, station, geo, track
aisAISVesselslog, station, geo
aprsAPRSStationslog, station, geo
aptNOAA APTPictureslog, picture
bearing_fixBearing fixFixeslog, station, geo
bleBLEDeviceslog, station
broadcastBroadcastServices
broadcast_dataBroadcast dataServiceslog
callCallCallslog, station, voice, duration, clip
cw_skimmerCW skimmerCW spotslog
dectDECTBase stationslog, station
dfBearingBearingslog, station, geo, bearing
df_fixFixFixeslog, station, geo
dscDSCVesselslog, station, geo
dvDigital voiceRadioslog, station, geo, voice
eotEnd-of-TrainTrainslog, station
ermesERMESPagerslog, station
flexFLEXPagerslog, station
ft4FT4Stationslog, station, spot
ft8FT8Stationslog, station, spot
gnssGNSS labSatelliteslog, station
gsmGSMCellslog, station
hfdlHFDLAircraftlog, station, geo
identSignal IDSignalslog
ilsILSILSlog
inmarsat_aeroInmarsat AeroAircraftlog, station, geo
inmarsat_stdcInmarsat STD-CVesselslog, station, geo
iridiumIridiumBurstslog, station, geo
ismISM sensorSensorslog, station
ism_survey2.4 GHz2.4 GHzlog
loraLoRaNodeslog, station, geo
lrptMeteor LRPTPictureslog, picture
morseMorseMorselog, text
navGuidanceTargetsstation, geo
navtexNAVTEXStationslog, station
noise_figureNoise figureNoise figurelog
perPERPERlog
pocsagPOCSAGPagerslog, station
pskPSKPSKlog, text
radarRadarTrackslog, station, geo
radar_sitesRadar sitesSitesstation, geo
radio_clockRadio clockClockslog, station
radiosondeRadiosondeSondeslog, station, geo
rdsRDSStationslog, station
remote_idRemote IDDroneslog, station, geo
rttyRTTYRTTYlog, text
sameSAMEAlertslog, station
sarsat406 MHz beaconBeaconslog, station, geo
satelliteSatelliteSatellitesstation, geo
scramblerScramblerScramblerslog
selcallSelcallSelcalllog
sidSIDSIDlog
sstvSSTVPictureslog, picture
stanagSTANAGSTANAGlog
tetrapolTetrapolRadioslog, station
toneToneToneslog
transmissionTransmissionTransmissionslog, duration, clip
vdl2VDL Mode 2Aircraftlog, station, geo
vorVORBeaconslog, station, geo, bearing
wefaxWEFAXPictureslog, picture
wifi_occupancyWi-Fi loadWi-Fi loadlog
wmbuswM-BusMeterslog, station
wsprWSPRStationslog, station, spot

Sources

Device

A radio, a recording or a generator: where a patch starts

PortWayTypeCarriesWires
iqoutIQradiomany, one per lane
controlinControlrunmany. Opens the radio only while a schedule or satellite says so
transmitinTransmitWhat a radio puts on the airone. The transmitter this radio sends

GPS position

Your station location, live or fixed

PortWayTypeCarriesWires
positionoutPositionA live fix and the heading it comes withmany

Microphone

Your voice while you hold the key

PortWayTypeCarriesWires
audiooutAudioLive audiomany

Channels

Channel

Tunes and decodes one signal

Ports follow the decoder the node runs.

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedmany lanes. every radio that may carry this decoder; it runs on the one that hears it
controlinControltune, doppler, sweepone. a scanner, signal hunt or satellite drives this decoder; its radio follows
positioninPositionA live fix and the heading it comes withone
basebandoutBasebandOne channel, filtered and decimatedmany
audiooutAudioLive audiomany
eventsoutEventsany kindmany. Decoded messages and calls. Analog calls need squelch
videooutVideoDecoded video frames and picturesmany

Modulator

Turns audio or a message into a signal

PortWayTypeCarriesWires
audioinAudioLive audiomany. Voice for an analog mode
basebandoutBasebandOne channel, filtered and decimatedone

Tools

Array

Radio lanes as one antenna array

PortWayTypeCarriesWires
laneinIQradioone, one per array lane. One radio lane per antenna, in antenna order
positioninPositionA live fix and the heading it comes withone. Array place and heading
arrayoutArrayOne lane per antenna elementmany

Spectrum monitor

Catches and decodes everything in view

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone
eventsoutEventsany kindmany

Trunk system

Follows calls across a trunked radio system

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone. the radio the control channel sits on; the system runs its own decoders
eventsoutEventsCall, Digital voicemany
audiooutAudioLive audiomany

Event filter

Passes only matching events

PortWayTypeCarriesWires
eventsinEventsany kindmany
eventsoutEventsany kindmany

Audio FX

Filters, denoise and AGC

PortWayTypeCarriesWires
audioinAudioLive audiomany
audiooutAudioLive audiomany

Scanner

Steps through frequencies, stops on activity

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone. The lane it watches
controloutControlsweepone

Signal hunt

Walks you towards a transmitter

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone. The lane it watches
controloutControlsweepone
positioninPositionA live fix and the heading it comes withone. Where you stand and which way you point
eventsoutEventsBearingmany. Sweep and mark bearings

Schedule

Runs radios and recorders by the clock

PortWayTypeCarriesWires
controloutControlrunmany. Radios and recorders to run in its windows

Satellite

Predicts passes and follows Doppler

PortWayTypeCarriesWires
positioninPositionA live fix and the heading it comes withone
controloutControltune, doppler, point, runmany. decoders to tune and Doppler correct, rotators to point, radios to run
eventsoutEventsSatellitemany. Position and ground track for a Map

Direction finder

Bearings from an array

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
eventsoutEventsBearingmany. True bearings
bearingoutBearingA live bearing that steers an arraymany. Live bearing to steer a beam

Beamformer

Steer, null or combine an array

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
steerinBearingA live bearing that steers an arrayone. A direction finder to steer at
beamoutIQderivedmany. The combined lane

Passive radar

Aircraft echoes of FM, DAB or DVB-T

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
txinPositionA live fix and the heading it comes withone. GPS node fixed at the transmitter
truthinEventsADS-Bmany. Tracks with altitude used as truth
eventsoutEventsRadar, Radar sitesmany. Track events

Stitch

Spread lanes joined into one wide band

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
wideoutIQderivedmany. Every lane joined

Spatial spectrum

Bearing over frequency

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone

Correlator

Baseline visibilities

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone

Polarimeter

Polarisation of two crossed antennas

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
beamoutIQderivedmany. Matched to the wave

Processor

An array processor from a plugin

PortWayTypeCarriesWires
arrayinArrayOne lane per antenna elementone
eventsoutEventsany kindmany

Triangulation

Crosses bearings into a position

PortWayTypeCarriesWires
eventsinEventsBearingmany. Bearings
positioninPositionA live fix and the heading it comes withone. Vehicle to guide
eventsoutEventsFix, Guidancemany

Bearing fix

Live fix from each station's newest bearing

PortWayTypeCarriesWires
eventsinEventsBearing, VORmany. Bearings with a known origin
eventsoutEventsBearing fixmany

Note

Markdown you leave on the canvas

Outputs

Scope

Spectrum and waterfall

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone

Baseband scope

Constellation and eye of one channel

PortWayTypeCarriesWires
basebandinBasebandOne channel, filtered and decimatedone

Speaker

Plays channel audio

PortWayTypeCarriesWires
audioinAudioLive audiomany

Map

Decoded positions on a map

PortWayTypeCarriesWires
eventsinEvents23 kindsmany
positioninPositionA live fix and the heading it comes withmany

Signal survey

Maps signal strength while you move

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone
positioninPositionA live fix and the heading it comes withone

Propagation map

Where FT8, FT4 and WSPR signals came from

PortWayTypeCarriesWires
eventsinEventsFT4, FT8, WSPRmany, keeps history
positioninPositionA live fix and the heading it comes withone

Readout

Current decoder state

PortWayTypeCarriesWires
eventsinEventsany kindmany

Decoder log

Every decoded message in a table

PortWayTypeCarriesWires
eventsinEvents60 kindsmany, keeps history, clips

Event output

Sends events out, saves call audio

PortWayTypeCarriesWires
eventsinEvents60 kindsmany

Video

ATV frames and decoded pictures

PortWayTypeCarriesWires
videoinVideoDecoded video frames and picturesmany, keeps pictures

Recorder

Records a radio's full IQ, with an optional pre-roll

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone
positioninPositionA live fix and the heading it comes withone
controlinControlrunmany. Armed, it records only while a schedule or satellite says so

Audio recorder

Records channel audio to WAV

PortWayTypeCarriesWires
audioinAudioLive audiomany
controlinControlrunmany. Armed, it records only while a schedule or satellite says so

Baseband recorder

Records one channel's IQ

PortWayTypeCarriesWires
basebandinBasebandOne channel, filtered and decimatedmany
controlinControlrunmany. Armed, it records only while a schedule or satellite says so

Network IQ

Streams IQ to other programs

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone
basebandinBasebandOne channel, filtered and decimatedone

Export

Saves logged rows as CSV or JSON

PortWayTypeCarriesWires
eventsinEvents60 kindsmany, keeps history

Rotator

Points an antenna at a satellite

PortWayTypeCarriesWires
controlinControlpointone. The satellite to follow

Transmitter

Puts a modulator or a lane on the air

PortWayTypeCarriesWires
iqinIQradio, synthetic, derivedone. A lane to send as it is
basebandinBasebandOne channel, filtered and decimatedone
transmitoutTransmitWhat a radio puts on the airone

Workspaces and presets

HoldsUse it to
WorkspaceNodes, wires, rack, radio settings, band-plan regionKeep a whole receiver
TemplateA ready-made receiver, built inStart a common setup
ExampleA whole working workspace, built inLearn which nodes a job needs
PresetSettings and channels of every open radioGet back to a known state
BookmarkA frequency, a name, and an optional modeRetune 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:

RadioConnects overNeeds
RTL-SDRUSBNothing
KrakenSDR, KerberosSDRUSBNothing
HackRFUSBNothing
Airspy R2, Mini, HF+, HF+ DiscoveryUSBNothing
FUNcube Dongle Pro+USBWinUSB on Windows
ESP32 with ESP-SDRUSB serial, Ethernet (S31)ESP-SDR firmware
AntSDREthernet or USBNothing
ADALM-Pluto, other AD936x boardsUSB or EthernetNothing
SDRplay RSP1, RSP1A, RSP1B, RSP2, RSPduo, RSPdx, RSPdx-R2USBSDRplay API 3.15+
SDRplay on another machineNetworkSDRconnect there
Aaronia SPECTRAN V6, V6 ECOUSBRTSA-Suite PRO, Windows or Linux
KiwiSDRNetworkNothing
rtl_tcp and SpyServer serversNetworkNothing
Dragon Labs CR-8USBVendor library, a build with cr8
bladeRF, LimeSDR, USRP, othersUSBA SoapySDR module

Making a radio? Write to hi@jhaag.me to get it supported and tested.

Compare radios

RadioTunesMax rateBandwidthBitsRXTXDC spike
RTL-SDR24 MHz to 1.766 GHz3.2 MS/s3.2 MHz810Small
RTL-SDR Blog V4500 kHz to 1.766 GHz3.2 MS/s3.2 MHz810Small
KrakenSDR lane24 MHz to 1.766 GHz2.56 MS/s2.56 MHz810Small
HackRF One1 MHz to 6 GHz20 MS/s20 MHz811Yes
Airspy R224 MHz to 1.8 GHz10 MS/s9 MHz1210No
Airspy Mini24 MHz to 1.8 GHz10 MS/s6 MHz1210No
Airspy HF+ Discovery1 kHz to 31 MHz, 60 to 260 MHz768 kS/s660 kHz1610Some rates
FUNcube Dongle Pro+150 kHz to 260 MHz, 410 MHz to 2.05 GHz192 kS/s192 kHz1610Yes
ESP32 with ESP-SDR2.4 GHz, 5 GHz on C580 MS/s, burstsPer chip, up to 69 MHz8 or 1010Yes
AntSDR E200, E31070 MHz to 6 GHz61.44 MS/s56 MHz1222Yes
ADALM-Pluto325 MHz to 3.8 GHz61.44 MS/s20 MHz1211Yes
SDRplay RSP1, RSP210 kHz to 2 GHz10.66 MS/s8 MHz1210Corrected
SDRplay RSP1A, RSP1B, RSPdx, RSPdx-R21 kHz to 2 GHz10.66 MS/s8 MHz1410Corrected
SDRplay RSPduo1 kHz to 2 GHz10.66 MS/s8 MHz1420Corrected
Aaronia SPECTRAN V6Per modelPer model and clockRate / 1.51611Unknown
Aaronia SPECTRAN V6 ECOPer model61.44 MS/s40 MHz1611Unknown
KiwiSDR0 to 30 MHz20 kS/s20 kHz1410No
Dragon Labs CR-824 MHz to 1.766 GHz12.5 MS/s8 MHz1280Unknown
bladeRF 2.0 micro70 MHz to 6 GHz61.44 MS/s56 MHz1222Yes
LimeSDR USB100 kHz to 3.8 GHz61.44 MS/s61.44 MHz1222Yes
LimeSDR Mini 2.010 MHz to 3.5 GHz30.72 MS/s30.72 MHz1211Yes
USRP B200, B205mini70 MHz to 6 GHz61.44 MS/s56 MHz1211Yes
USRP B21070 MHz to 6 GHz61.44 MS/s56 MHz1222Yes
  • 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:

ProtocolDefault port
rtl_tcp1234
SpyServer5555
SDRconnect5454
KiwiSDR8073
AntSDR / Pluto30431
ESP-SDR80

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:

ControlSets
RateSample rate
BW or FilterAnalog bandwidth before sampling. BW has an auto setting.
AntennaInput port, when there is a choice
AutoAGC, on the gain row. The slider shows the gain the radio chose, where it reports it.
AGCAGC mode, on radios that have several
LNA, Mixer, VGA, IF, RF, Tuner, AttenuatorOne gain stage each, in dB or firmware steps
AmpA switchable preamp
Bias teePower on the antenna port for an active antenna or LNA
PPMCrystal correction
ConvLocal oscillator of an up- or downconverter, in MHz
DC blockRemoves 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.

ControlDoes
TunerGain, in the tuner's own steps: 20 dB on an R820T becomes 19.7 dB
AutoTuner AGC
Bias teeAntenna-port power
Direct samplingoff, 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

ControlDoes
LNAGain in 8 dB steps
VGAGain in 2 dB steps
Amp+14 dB RF amplifier
BWBaseband filter, or auto
Bias teeAntenna-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.

ControlDoes
Frequency100 MHz to 6 GHz in 1 MHz steps; only 2.4 GHz (and 5 GHz on C5) is reliable
Rate80, 40 or 16 MS/s on ESP32, per chip otherwise
TunerGain index, not dB. Auto is the hardware AGC.
BWAnalog bandwidth, or auto for widest
Bits8 or 10 bits per sample; 10 is slower over serial
BurstSamples 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:

SystemWhere
macOSSystem Settings, Network, the Ethernet adapter, Details, TCP/IP. Configure IPv4 Manually, IP 192.168.1.100, subnet mask 255.255.255.0
WindowsSettings, Network & internet, Ethernet, IP assignment, Edit. Manual, IPv4 on, IP 192.168.1.100, subnet mask 255.255.255.0
Linuxnmcli 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.

ControlDoes
−/+1 or 2 lanes on a 2×2 board. Starts at 1, which gets the whole link
TunerReceive gain per lane. The range follows the band
TXTransmit attenuation per lane
AGCPer lane: fast attack, slow attack, or hybrid
FilterAnalog bandwidth
TrackingQuadrature, RF DC and baseband DC corrections
ToneTwo DDS test tones on every TX lane, with offset and level. Transmits without a stream
AGCHigh and low thresholds of the gain loop, in dBFS
ChipRate governor, calibration (auto or manual), and state: run, alert, wait or sleep
LOExternal RX or TX LO
Antenna, TX portShown 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:

SliderSets
RFLNA gain. The steps depend on frequency, port, and HDR mode.
IF0 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:

SettingDoes
LNARF gain over the LNA states; lower means more gain. There is no IF gain.
Device VFOSDRconnect's VFO inside the sampled window
FilterSDRconnect's channel filter
ReceiverWhich radio: name, slot, or serial
Network modeStream quality
Device profileLoad a saved SDRconnect profile
RecordingRecord 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.

ControlSets
GAINThe reference level, reversed: 20 dB gain is a -20 dBm reference, which reads as full scale
AntennaRx1 or Rx2, V6 only
Amplifier, RX filter, USB compressionAs 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:

RadioModule
bladeRFSoapyBladeRF
LimeSDRSoapyLMS7
USRPSoapyUHD
Remote SoapySDR serverSoapyRemote
SystemCoreExample module
Debian, Ubuntu, Raspberry Pi OSsudo apt install libsoapysdr0.8soapysdr-module-bladerf
Fedorasudo dnf install SoapySDRsoapy-uhd
Archsudo pacman -S soapysdrsoapybladerf
macOSbrew install soapysdrpothosware/pothos/soapybladerf
WindowsPothosSDR, default install folderIncluded
NixOSsoapyPlugins

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:

VariableValue
SDRMM_SOAPY_LIBRARYFull path to the core library
SOAPY_SDR_ROOTSoapySDR install prefix
SOAPY_SDR_PLUGIN_PATHExtra 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.

TabGives
RecordingPlays a SigMF recording
VirtualThe 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

  1. Press + Add and pick a mode.
  2. Wire Device iq to the channel's iq.
  3. Set the channel frequency.
  4. Wire the outputs you need:
OutputWire toYou get
audioSpeakerLive sound
audioAudio recorderA WAV file
audioAudio FXFiltered, denoised or levelled sound
eventsReadoutCurrent state: station text, aircraft table
eventsDecoder logMessage history
eventsMapPositions
eventsExportCSV or JSON of logged rows
eventsEvent outputEvents sent out, call audio saved
videoVideoATV, DVB, SSTV, WEFAX and weather satellite pictures
basebandBaseband scope, Baseband recorder, or Network IQThe 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.

ModeOpensSet with
OffAlwaysSlider at far left
ManualAbove the slider levelThe slider
AutoMargin dB above the measured noise floor, 2 to 40The 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:

SettingBehaviour
DetectShows the CTCSS tone or DCS code, never mutes
CTCSSOpens only for the chosen tone
DCSOpens 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:

StageDoes
De-clickRemoves short clicks
PassbandCuts audio below and above two frequencies
NotchesRemoves up to four chosen tones, each with its own width
Auto notchFinds and removes steady tones
DenoiseSpectral 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.
AGCLevels 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:

SystemVirtual device
Linuxpactl load-module module-null-sink sink_name=js8, then read js8.monitor in the program
WindowsVB-Cable
macOSBlackHole

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.

ModePasses
KeepOnly matching events
DropEverything 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.

GroupTested on airReference fixtureOwn modulatorExperimental
Analog voiceAM, NFM, SSB, WFM (broadcast)
Digital voiceDMR, FreeDV 1600, TETRA, TetrapolD-STAR, System Fusion, NXDN, P25 Phase 1, dPMR, M17
AviationADS-B (1090ES), ACARS, Inmarsat Classic AeroDrone Remote IDVDL Mode 2, High Frequency Data LinkVOR, ILS localizer / glideslope
MarineDigital Selective Calling, Inmarsat STD-C / EGCAIS, NAVTEX
Amateur and HFFT8CW skimmer, FT4, WSPR, RTTY, Morse (CW), PSK, APRS / AX.25 with weather
Paging and telemetryFLEX pager, Radio clock (DCF77 / WWVB / MSF / JJY)POCSAG, Selcall (CCIR/ZVEI), ERMES pager
Pictures and videoSSTV, ATV
Weather and satellitesMeteor LRPTWEFAX, NOAA APT, Radiosonde, SAME alerts
Broadcast digitalDAB / DAB+, DRM30, DRM+DVB-T/T2, DATV (DVB-S / S2)
UtilitySignal identifier, End-of-Train, GSM downlink, LoRa, BLE advertisements, 2.4 GHz survey, Wi-Fi occupancy, HF modems, Wireless M-Bus, ISM sensorsIridium burstsDECT, 406 MHz beacons, LoRaWAN, Meshtastic, MeshCore, Noise figure, PER tester, SID monitorGNSS lab (GPS L1 C/A)
LabelMeans
Tested on airVerified on a real off-air signal, live or recorded
Reference fixtureVerified on another implementation's waveform or published vectors
Own modulatorVerified only against this project's own modulator
ExperimentalWorks 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

ModeWorksMissing
DVB-T/T2DVB-T HP and LP, T2-Base and Lite, SISO and MISO, 1K to 32K, PLP choice, audio, videoGSE, multi-RF TFS
GNSS labGPS L1 C/A acquisition, tracking and navigation data, one PRN per channelPosition 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.

ProtocolFinds its channels by
DMR Tier III / Capacity MaxReading the channel plan from the control channel
DMR Capacity PlusSearch: carriers that follow the same rest channel
Hytera XPTSame as Capacity Plus
P25 trunkingReading 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:

DecoderHex digits
DMR10 ARC4, 32 AES-128, 64 AES-256
P25 Phase 110 ADP, 16 DES-OFB, 32 AES-128, 64 AES-256
NXDNup 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.

FamilyModels
AcuriteTower, 5n1, 606TX, 609TXC
LaCrosseTX141 / TX141TH, TX29IT, TX35DTH-IT
Fine Offset, Ambient WeatherWH2, WH5, WHx080, WH24, WH65B, WH31E, WH31B, WH40, F007TH
Oregon ScientificTHGR122N, THGR228N, THGR968, THN132N, WGR800, THGR810
Bresser5in1, 6in1
OthersNexus, 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.

SettingDoes
ModeFollow VIS reads the mode from the transmission. Pick one when the header was missed.
SlantStraightens pictures from a sender whose clock runs off. Leave it on.
PartialKeeps 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.

SettingDoes
IOC576 or 288, used when a chart starts without a start tone
LPMLines per minute: 60, 90, 120 or 240. Most stations use 120.
PartialKeeps 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.

SettingChoice
BandEU 1880 to 1900 MHz, or US 1920 to 1930 MHz
SpanCarrier: one carrier at 2.304 MS/s. Band: every carrier at once
SideBase, 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.

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.

SettingChoice
PagingShow paging, assignments and rejects
FollowDecode 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.

LoggedFrom
CellMCC, MNC, colour code, location area, carrier and duplex frequencies
CallsD-SETUP, D-CONNECT, D-TX GRANTED, D-RELEASE with group, caller and talker
TrafficA voice record when a timeslot turns to traffic
SDSStatus codes, text, LIP positions on the map, other data as hex
MobilityLocation 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.

SettingDoes
PresetSets BW, SF, IQ and Protocol for a Meshtastic modem preset, MeshCore EU or US, or LoRaWAN. The frequency stays as tuned.
BW7.8 to 500 kHz
SFOne spreading factor, or All for SF7 to SF12 at once
IQInverted hears LoRaWAN downlinks, Both hears both directions
ProtocolAuto picks by sync word. LoRa shows bytes only.
KeysExtra channel and session keys
ImplicitPayload 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:

KeyFields
MeshtasticChannel name and base64 PSK. An empty PSK is an open channel.
MeshCoreChannel name and base64 or hex secret. A #tag name needs no secret.
ABPLoRaWAN DevAddr, NwkSKey and AppSKey: checks the MIC and decrypts the payload
OTAALoRaWAN 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.

LinkHearsRate
BluetoothBluetooth 4 adverts on one channel4 MS/s
BT bandEvery Bluetooth channel in reach, including long range data20 MS/s
Wi-FiBeacons and NAN frames on one channel, 802.11b and 802.11g20 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.

LinkHearsRate
ChannelOne Bluetooth channel4 MS/s
BandEvery Bluetooth channel in reach, including long range on the data channels20 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.

SpanRateSees
2020 MS/sOne Wi-Fi channel
4040 MS/sFive channels at 2.4 GHz
8080 MS/sAll 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

NodeUse it to
ScannerStep through frequencies and stop on activity
Signal identifierName an unknown signal
Spectrum monitorCatch and decode everything in the Device's window
Signal huntWalk towards a transmitter by signal strength
Signal surveyMap 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.

  1. Add a channel in the mode you want to hear and wire it to a Speaker.
  2. Add Scanner, wire its control to the channel's control and the channel's Device iq to its iq. A scanner watching a different lane from its channel is refused. Removing the control wire stops the scan.
  3. Pick a Band, or set each range's From, To and Step. Add range adds another.
  4. Pick what to Find and press Start scan.
FindDoes
ListedStops on the first busy frequency until it goes quiet
StrongestStops on the strongest signal anywhere in the span
AllVisits 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:

  1. Wire Scanner → DAB channel → Decoder log.
  2. Pick the Band with the DAB blocks, Find All, Hold 5 s.
  3. 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:

GroupOne row per
ListDecoded frame, newest first
FrequencyFrequency, with hits, airtime and the latest decode
StationStation: 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.

SettingDoes
WidthSpan to search, up to 192 kHz
EveryHow long it listens per report. Default 1,000 ms.
DetectHow 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.

SettingDoes
MinSkips 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:

  1. Wire a GPS node with the Phone source to the hunt's position.
  2. Hold the antenna and the phone together. Mount sets the antenna's direction relative to the phone.
  3. Press Sweep and turn slowly all the way round. The rose fills in and Peak shows the strongest direction.
  4. When Sweep reads Done, the bearing is sent on events.

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

  1. Add Signal survey and wire Device iq and GPS position to it.
  2. Set Offset inside the Device's window and a measurement Width.
  3. Wait for a level and a GPS fix, then press Start survey.
  4. 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

NodeRecordsWire fromFormat
RecorderThe Device's full IQ, with an optional pre-rollDevice iqSigMF
Baseband recorderOne channel's filtered IQChannel basebandSigMF
Audio recorderOne channel's audioChannel audio48 kHz 16-bit WAV
Event output, RecordingsOne WAV per callChannel events8 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:

  1. Turn Pre-roll on and set Keep, 1 to 120 seconds (default 10).
  2. The recorder holds that much IQ in memory from then on.
  3. Record writes the held seconds first, then keeps going live.
  4. 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:

ChannelA call is
Digital voice, trunk systemOne call, from header to end
AM, NFM, SSB, DECTOne 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.

NodeModeFor
Network IQUDP or TCPGNU Radio and other raw IQ tools
Network IQrtl_tcp serverrtl_433 and other rtl_tcp clients
Event outputFeed (ADS-B Beast)Flight tracking feeders
Event outputWebhook, Matrix, MQTTChat, automation and alerts
Event outputNotificationAlerts in the desktop app
Event outputPostgreSQL, InfluxDBDatabases
Event outputCSV fileSpreadsheets and logs
Event outputNetwork interfaceIP 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

  1. Add Network IQ and wire one Device iq lane or one channel baseband.
  2. Pick Transport, Samples, and the destination host:port in To.
  3. Start the receiving program, then press Start export.
  4. 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:

SamplesEncodingBytes per I/Q pairGNU Radio type
Complex float 32 LEcf32_le, 32-bit float8Complex
Complex int 16 LEci16_le, signed 16-bit4Short, then Interleaved Short to Complex
Complex unsigned 8cu8, unsigned 8-bit, zero at 127.52RTL-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

  1. Tune the Device to 433.92 MHz.
  2. Wire Device iq into Network IQ and pick rtl_tcp server (rtl_433). It listens on 127.0.0.1:1234.
  3. 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.

  1. Wire the ADS-B channel's events into Event output.
  2. Pick Feed, format Beast, keep Listen at 127.0.0.1:30005, and press Open server.
  3. 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.

ServiceSends
Webhook, JSONAn HTTP POST with output, kind, text, and the full record
Webhook, DiscordThe text as a message, with any audio attached
MatrixThe text to a room, with any audio uploaded
MQTTThe JSON payload to a topic, at least once. Use mqtt:// or mqtts://.
NotificationThe 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.

SystemNeeds
LinuxCAP_NET_ADMIN for the server
macOSPermission to create a utun interface; name it like utun8
WindowsAdministrator 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

RadioDuplexNotes
HackRF OneHalfReceive pauses for each burst. The 14 dB amp is shared by receive and transmit
ADALM-Pluto, AntSDRFullTX attenuation per lane on the Device
Aaronia SPECTRAN V6HalfIf the unit opens as a transmitter. Receive pauses while it sends
bladeRF, LimeSDR, USRP (Soapy)Per boardOne TX gain per element the board has, set alike on every transmit channel
Transceiver, Half-duplex (virtual)Full, halfDebug 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

  1. Add a Modulator and pick a mode that sends messages, such as APRS.
  2. Add a Transmitter. Wire Modulator baseband to it, and its transmit to the Device's transmit.
  3. Set the Transmitter's frequency. Empty means the Device's centre.
  4. Press Arm.
  5. Fill in the message on the Modulator and press Send.

The burst goes out once and the Transmitter returns to idle, still armed.

Talk

  1. Add a Microphone, a Modulator on NFM, AM or SSB, and a Transmitter.
  2. Wire Microphone audio to the Modulator, then on to the Transmitter and the Device as above.
  3. 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

SettingMeaning
LevelDigital drive below full scale. Leave a few dB of headroom
TimeoutThe 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

TabSource
ReceiverA serial or USB NMEA GPS on the server. Set Port, Baud and Rate.
Networkgpsd, default 127.0.0.1:2947
FixedLatitude and longitude you type in
PhoneA 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.

SourceHeading from
PhoneCompass, gyro and GPS course, fused on the phone
NMEAHDT and THS sentences. Magnetic headings are ignored.
gpsdATT reports

An NMEA or gpsd heading older than two seconds is dropped. An Array set to Heading turns with it.

What uses it

NodeUses position for
ADS-B channelDecoding aircraft positions
ArrayWhere the array stands and which way it points
MapYour station and its GPS trail
Passive radar txWhere the transmitter stands, from a Fixed source
Propagation mapYour end of every path
RecorderLocation in the recording's metadata
SatellitePass and Doppler prediction
Signal huntWhere you stand and which way you point, for sweeps
Signal surveyWhere each measurement was taken
TriangulationThe 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

  1. Add GPS position. A fixed position is fine for a station that never moves.
  2. Add Satellite and wire GPS position to its position.
  3. Search by name or NORAD number, or paste element lines.
  4. Pick a transmitter in Signal, or tune the downlink on the dial.
  5. Wire Satellite control to each decoder's control.

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

  1. 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:4533 by default).
  2. Wire Satellite control to the Rotator's control.

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.

SettingDoes
ModeTrack the satellite, point by hand (Manual), Park, or Stop both axes
AxesAzimuth and elevation, or azimuth only
Range360°, 450° or 540° of azimuth. Past 360° a pass crosses north without unwinding
HeadingCompass bearing the mount's 0° azimuth faces, for a mount not set up facing north. Manual and Park take compass bearings
FlipElevation reaches 180°. Passes over the top, or across the azimuth stop, are followed with the antenna flipped instead of swinging round
StepMoves 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

ReadoutShows
AzimuthCompass bearing
ElevationAngle above the horizon; green while visible
RangeDistance to the satellite
DopplerThe shift being corrected, and how fast it changes
Send onThe Doppler-corrected uplink, if the transmitter has one
Next passTime until rise, or until set during a pass, with peak elevation
ElementsAge 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

  1. Add FT8, FT4, or WSPR channels and a Propagation map.
  2. Wire each channel's events to the map.
  3. Wire GPS position to position. A fixed position works.

On opening, the map loads the last six hours of logged decodes.

Layers

SettingShows
Map, ActivityHeard stations per grid square, weighted by count and age
Map, MUFEstimated MUF lower bound at each reflection point
PathsGreat-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:

  1. Add a Device for each radio.
  2. Wire each Device iq to the Array in antenna order: lane, lane2, and so on.
  3. 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

TierLanes share
Shared LOClock and local oscillator
Shared clockSample clock
NoneNothing, 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.

RadioTierNoise source
KrakenSDR, five DevicesShared clock, reported by the lanesBuilt in, antennas cut off
KerberosSDR, four DevicesShared clock, reported by the lanesBuilt in, antennas stay on
RSPduo dual tunerShared clockNone
LimeSDR, USRP B210, bladeRF 2, AD936x boardsShared LONone
CR-8Shared LONone
Other multi-channel SoapySDR radiosShared clockNone
Several radios in one ArrayWhat you declare, capped by any radio or bank that gives more than one laneNone
Array recordingAs recordedRecorded noise windows

Lane tuning

Pick it under Tuning.

ModeLanesFor
TogetherAll on one frequencyEvery processor except Stitch
SpreadSide by side, Span shows the bandStitch

Lay out the antennas

Open Geometry. Angles run clockwise from the array's forward direction.

ShapeSet
UCAA circle: Radius, First (where lane 1 sits, 0 is forward), Winding
ULAA line: Spacing, Axis (from lane 1 to the last lane)
CustomEach 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:

SourceUses
NoiseThe radio's built-in noise source
PilotA carrier every antenna hears, at a known Offset and Width
EmitterA transmitter at a known true Bearing, Offset and Width. Needs Forward set.
OffNothing

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

RowShows
SyncIdle, Syncing, Locked, Drifting or Lost
TierWhat the lanes share
CalNo cal, Waiting, Calibrating, Calibrated, Warm, Stale or Cal failed
HeadingArray forward, true north
Gaps, Realigns, Drops, LostLost 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.

ModeDoes
MRCBest SNR from every lane
BeamSteers with equal weights
MVDRSteers and suppresses the rest
NullsSteers with nulls on the azimuths you list, or that Auto finds
GSCSteers and cancels the rest as it moves
CancelSubtracts what the Refs lanes hear from the Main lane
CMALocks 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

  1. Build and calibrate an Array, and wire a GPS to its position.
  2. Add Direction finder and wire the Array's array to it.
  3. Set Offset and Width to cover the signal.
  4. Pick a Method.
MethodUse
BartlettPlain beam scan, robust
CaponSharper beam scan
MUSICSplits close sources
Root-MUSIC, ESPRITGrid 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.

RowShows
Bearing, ±The strongest bearing and its one sigma
FitShare of the signal the bearings explain
SNRSignal to noise
SrcSources found
ChipMeans
SquelchPeak below Squelch
No headingWire a GPS with heading to the Array
No positionWire a GPS to the Array
AliasedAntennas more than half a wavelength apart
Mode aliasToo high for phase modes on this circle
MirrorA line cannot tell front from back. Side picks one.
RotatingTurning faster than Yaw gate, blocks skipped
SingularCovariance could not be inverted
Table offOutside the calibration table
StaleNo 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

  1. Add Triangulation and wire each finder's events to it.
  2. 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.

RowShows
EstimateThe most likely position
SpreadThe one sigma error ellipse
GuidanceWhere to drive next: Drive across or Drive at it
BearingsBearings 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

  1. Build an Array with at least two lanes, tuned Together.
  2. Add Passive radar and wire the Array's array to it.
  3. Point one antenna at the transmitter and pick its lane under Ref. Listen picks the lanes that hear echoes: Others or Pick.
  4. For the map, wire a GPS to the Array, and a GPS set to Fixed at the transmitter to tx.
  5. Optional: wire an ADS-B channel's events to truth. Tracks that match an aircraft show its call sign.

Illuminators

Pick one under Illum.

IlluminatorNotes
FMStrongest echoes. Clean with CMA removes the programme's multipath.
DABWider band, finer range. Clean with DAB remod rebuilds a clean reference.
DVB-TUses the slice of the channel the lanes cover, set by Bandwidth
CustomAny 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.

ReadoutShows
TargetsConfirmed tracks
ClutterDirect path and clutter removed
LoadShare of real time spent
RefReference quality: Raw, Lost, or CMA or DAB with its dB
CPIIntegration time in use
DropsLost 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

SettingDoes
RangeHow far out and how fast (Speed) to search
CPIIntegration time. Longer finds weaker echoes but blurs fast ones. Overlap reuses part of each.
AoABearing per echo. Needs a calibrated array.
GPUAuto uses the GPU when it is faster
ClutterHow the direct path and ground clutter are removed: ECA-B, ECA-S, NLMS, Block NLMS or Off
PfaFalse alarm chance per cell
DetectThe CFAR detector (CA, OS or GO) and Min SNR
TrackHits 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 transmitter or No array position: wire the GPS nodes from step 4.
  • Overloaded means the host cannot keep up: shorten Range or CPI, or set GPU to Auto.
  • Outside cal table means 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.

PhoneNeedsInstall
iPhoneiOS 27Build with Xcode, see Phone apps
AndroidAndroid 10Build 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, 8443 by 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

  1. Press Pair phone. A QR code, an 8-digit Code and a Key appear for five minutes.
  2. On the phone, Scan QR. Or pick the server under Nearby and type the code, or type host:port and the code under Manual.
  3. 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.

MissionNeedsShowsControls
HuntA Signal hunt wired to a channelLevel, Listening, Warmer, Colder or On topStart, Tune, Sweep, Mark, Clicks, Haptics
DF driveA Direction finder, a Triangulation, or bothBearing rose, guidance, map with rays, heat, ellipseNavigate, Auto or Direct, Calibrate, Clear, Tune, Fit
RadarA Passive radarRange Doppler image and tracks
SurveyA Signal survey with a GPS on positionA trail coloured by levelRecord, 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 toFor
Array positionWhere the array stands and which way it points
Signal hunt positionSweep bearings with a handheld antenna
Signal survey positionWhere each level was measured
Triangulation positionGuidance to the target
Passive radar txWhere the transmitter stands

The phone sends its pose only while a GPS node uses it. Set it up under Settings → Heading:

SettingDoes
SourceAuto fuses compass, gyro and GPS course. Compass or GPS course force one.
MountFlat on a seat or dash, Upright in a holder
OffsetA fixed angle between phone and car
Align with carDrive 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

ChipMeans
Online, Connecting, OfflineLink to the server, or why it refused
Sharing positionThe server uses this phone's position
Fused ±4°Heading source and accuracy
No GPS fixNo position yet
Approx. locationPrecise location is off
Location offLocation permission or service is off
No heading, No compassNo usable heading
Calibrate compassWave the phone in a figure eight
Background offAndroid refused to run in the background
Alerts offNotifications are off
No map tilesThe map is offline

Tools

Library → Tools opens instruments and calculators that are not part of a receiver.

ToolDoes
Antenna calculatorDimensions 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
NanoVNASweeps an antenna over USB and shows SWR, impedance, a Smith chart, and cable faults. Keeps sweeps per antenna to compare.
Radio programmerReads, 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.

KeysAction
Left / RightTune down or up one step
Shift + Left / RightTune ten steps
[ / ]Choose a smaller or larger tuning step
fFocus the selected node's dial; press Enter to type a frequency
, / .Select the previous or next channel
m / MCycle the selected channel's analog mode forward or backward
- / + or =Lower or raise squelch by 2 dB
sToggle squelch
1–9Select the nth node in the patch
pPin or unpin the selected node on the rack
vSwitch between Patch and Rack
zFill the window with the selected node; Escape returns
Ctrl/⌘ ZUndo the last workspace change, for every connected client
Ctrl/⌘ Shift Z or Ctrl/⌘ YRedo
Ctrl/⌘ CCopy the selected nodes and the wires between them
Ctrl/⌘ VPaste them beside the originals
Backspace or DeleteDelete the selected node or wire
SpaceHold to talk: keys the selected Microphone, or every Microphone when none is selected
?Open help
EscapeClose 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–9 set 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 ready line 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 sdrmm server with --bind 0.0.0.0:8080 and a token; it stays on 127.0.0.1:8080 otherwise.
  • 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 sends http:// 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

  1. Check that the operating system sees it.
  2. Run sdrmm --doctor and fix any library or permission error.
  3. Close other SDR programs that may hold it.
  4. For SoapySDR radios, run SoapySDRUtil --find and check the module matches the core's version, 0.8 or 0.9.
  5. 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 audio to 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 events to 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 -10 to /etc/security/limits.conf and join the audio group.
  • 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/recordings is on the persisted volume.
  • Stop the recording. Metadata is written on stop.
  • Check each capture has both .sigmf-meta and .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

OptionDefaultSets
--bind <ADDRESS>127.0.0.1:8080Listen address; a non-loopback one gets a token
--db <PATH>sdrmm/sdrmm.db in the platform data folderSQLite database
--recordings-dir <PATH>sdrmm/recordings in the platform data folderRecording folder
--plugins-dir <PATH>sdrmm/plugins in the platform data folderDecoder plugins
--token <TOKEN>Generated on a network bindShared access token
--no-authOffRun a network bind without a token
--tls-cert <PATH>, --tls-key <PATH>NoneHTTPS certificate chain and key, PEM; always together
--tls-self-signedOn a network bindHTTPS with a self-signed certificate
--tls-name <NAME>Found addressesName the self-signed certificate must cover; repeatable or comma-separated
--no-tlsOffPlain HTTP on a network bind, for a proxy or tunnel that adds HTTPS
--dev-corsOffAllow a separate frontend origin, for development only
--doctorPrint diagnostics and exit
--doctor-ratesProbe 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-tls and 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 Host header. Browser requests whose Origin differs are refused.
  • Forward WebSocket upgrades on /api/ws, and /mcp for MCP clients.

HTTPS with a tunnel

A tunnel gives SDRmm an HTTPS address without port forwarding or certificate work.

TunnelReachable from
TailscaleYour own devices only
Cloudflare TunnelAny 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

  1. Install Tailscale on the server and on each client, all in the same tailnet.

  2. In the admin console's DNS page, turn on MagicDNS and HTTPS Certificates. Machine names become public in certificate transparency logs.

  3. On the server:

    tailscale serve --bg --https=443 http://127.0.0.1:8080
    tailscale serve status
    
  4. Open the printed https://<machine>.<tailnet>.ts.net address 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.

  1. Add the login first. In Zero Trust → Access controls → Applications, create a Self-hosted and private app for radio.example.com with no path. Add an Allow policy for your email or group and save.
  2. In Networking → Tunnels → Create Tunnel, name it sdrmm and follow Install and Run to install cloudflared as a service on the SDRmm machine. Keep its tunnel token private.
  3. When the tunnel is Healthy, add a Published application route: hostname radio.example.com, no path, service http://127.0.0.1:8080.
  4. 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/ws connection in the browser's developer tools.
  • Bad gateway: check http://127.0.0.1:8080 works 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:

  1. Save the sdrmm --doctor report.
  2. Stream for 30 minutes and check Drops, audio, and spectrum.
  3. Try tuning, gain, rate, and every control you plan to use.
  4. Unplug and replug the radio. The workspace should pick it up again.
  5. 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.

EndpointServes
/api/docsSwagger UI
/api/openapi.jsonOpenAPI schema
/api/wsWebSocket
/api/wtWebTransport
/mcpMCP 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

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

CarrierHolds
The bidirectional stream the client opensClientCommand up, ServerEvent down, as JSON
DatagramsAudio frames, one each; drop any that arrive behind a newer one
Server streamsThe 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

ToolVersion
Rust1.99.1, pinned in rust-toolchain.toml; rustup installs it
Node26
pnpm12, exact version in web/package.json
FFmpeg9, built by scripts/build-media.py
NativeC/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.

RadioKeyIs
Kraken bench ×5kraken5Five lanes on one clock with a noise source, like a KrakenSDR
Coherent Array ×4array4Four lanes on one clock and LO, with a pilot tone
Dongle 1, Dongle 2dongle1, dongle2One lane each, two elements of a line array; Dongle 1 holds the noise source
Test bandbandA band of test signals on one lane
Receiver ×4, Transceiver 2×2, Half-duplex 1×1quad, transceiver, halfduplexLane and transmit shapes

The bench radios hear one FM emitter at 137° and its echo at 250°. Hardware tests cover real radios.

Checks

CommandRuns
cargo xtask checkFormat, Clippy, web and site lint and type-check, crate rules, feature-set builds, generated-file drift
cargo xtask testGenerated fixtures, then Rust, web and site tests, with SoapySDR hidden
cargo xtask smokePlaywright against a real sdrmm process
cargo xtask perfDSP, channel and engine throughput and allocation gates
cargo xtask auditRustSec advisories through cargo-deny
cargo xtask desktopClippy and tests on the desktop app, no installers
cargo xtask sanitizeChannel tests, with the vendored C, under AddressSanitizer and UBSan
cargo xtask fuzzlibFuzzer 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.

TargetFeeds
channel_chainIQ into every decoder
channel_settingsRandom settings into every decoder
dv_voiceIQ into the digital voice decoders
media_bitstreamAudio codec bitstreams
net_radio_protortl_tcp, SpyServer, KiwiSDR and SDRconnect messages
iqlink_headeriqlink packets
recorder_importSigMF archives
workspace_importWorkspace exports
cps_imageCodeplug 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:

GateBaseline
ber::perf::tests::compare_perf_baseline in sdrmm-modem-test-supportcrates/modem/baselines/perf_phase0.json, rows tagged by host
compare_*perf_baseline* in sdrmm-modem --test integrationcrates/modem/baselines/*/*perf*.json, rows tagged by host
survey_every_channel_search_path in sdrmm-channelscrates/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 changeRunUpdates
REST routes or wire typescargo xtask codegenopenapi.json, web/src/generated/
Dependenciescargo xtask licensesTHIRD_PARTY_NOTICES.md, crates/server/data/notices.json, phone notices
Band-plan sourcescargo xtask bandplancrates/server/data/bandplan/
assets/icon.svgcargo xtask iconsDesktop, iPhone, web, site, and docs icons
Demo scenes in web/e2e/scenes.tspnpm --dir web demo:recordsite/public/demo/
README screenshotscargo xtask screenshotsassets/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

CommandDoes
cargo xtask excerptTrims a capture into a fixture; see fixtures/README.md
cargo xtask net-captureRecords a public KiwiSDR or SpyServer to SigMF
cargo xtask replayRuns 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-matrixSignal 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

CrateResponsibility
sdrmm-dspAllocation-free signal-processing primitives; no I/O or internal project dependencies
sdrmm-modemReusable modem algorithms and waveform synthesis, depending only on DSP
sdrmm-modem-test-supportModem measurement catalogs, simulations, and baseline tooling; tests and developer tools only
sdrmm-wireShared settings, DTOs, events, patch graph, and OpenAPI schemas
sdrmm-deviceHardware-independent device traits, capabilities, settings, and registry
sdrmm-device-recordingSigMF playback behind a Device node
sdrmm-device-virtualSynthetic radios, the bench scenes and the signal generator
sdrmm-usb-streamBulk USB streaming shared by the native drivers
sdrmm-serialSerial port enumeration, one filtering policy, and the serial library every crate uses
sdrmm-device-rtlsdrNative RTL-SDR driver
sdrmm-device-airspy, sdrmm-device-airspyhfNative Airspy drivers
sdrmm-device-hackrfNative HackRF driver
sdrmm-device-funcubeFUNcube Dongle Pro+, over USB audio and HID
sdrmm-device-espsdrESP32 running ESP-SDR firmware, over serial
sdrmm-device-ad936xAntSDR, PlutoSDR and other AD936x boards, speaking iiod over Ethernet or USB
sdrmm-iqlinkUDP sample protocol shared by sdrmm-iqlinkd and sdrmm-device-ad936x
sdrmm-iqlinkdDaemon on AD936x boards that streams samples over UDP
sdrmm-device-soapyLocal hardware through SoapySDR
sdrmm-device-sdrplaySDRplay RSP receivers through the vendor API, loaded at runtime
sdrmm-device-rtltcpDirect rtl_tcp client
sdrmm-device-spyserverDirect SpyServer client
sdrmm-device-sdrconnectSDRplay SDRconnect over its WebSocket API
sdrmm-device-kiwisdrKiwiSDR over its WebSocket API
sdrmm-device-cr8Dragon Labs CR-8 through the vendor SDK, loaded at runtime
sdrmm-device-aaroniaAaronia SPECTRAN V6 and V6 ECO through the RTSA API, loaded at runtime
sdrmm-channelsEvery built-in decoder, each a module with its settings, events and registration, plus the decoder registry and signal synthesis
sdrmm-dvsiDVSI AMBE vocoder chips over USB serial
sdrmm-pluginsLoads WASM plugins into the registries
sdrmm-pluginSDK for writing a WASM plugin
sdrmm-plugin-examplePlugin template
sdrmm-recorderSigMF writing, reading, scanning, and export
sdrmm-orbitSGP4, pass prediction, and Doppler
sdrmm-toolsAntenna calculator and NanoVNA
sdrmm-cpsCodeplug reading, writing, and conversion
sdrmm-test-supportAllocation and timing helpers for tests
sdrmm-engineDevice supervision, channelization, scanning, streams, recording, and state snapshots
sdrmm-serverREST, WebSocket, MCP, persistence, band plans, auth, phones, and embedded assets
sdrmm-mobile-corePairing, 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:

  • dsp does no I/O and depends on no project crate.
  • modem builds reusable modulation algorithms on dsp only.
  • channels pulls no internal crates beyond dsp, modem and wire.
  • Device crates never depend on channels, engine or server.
  • mobile-core depends on wire only. Phone builds pull no engine, server, DSP, USB, FFmpeg, codec2, or SQLite crate.
  • test-support and modem-test-support never reach the sdrmm dependency 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

LayerTested with
DSPAnalytic and golden vectors, allocation and throughput gates
DecodersRecorded IQ with expected output, generated vectors
EngineEnd-to-end runs on virtual devices
ServerHandlers, persistence, streams, auth, OpenAPI, codegen drift
ClientUnit 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:

ConstantsFile
DAB puncturing and protection profilescrates/channels/src/dab/protection.rs
DAB phase referencecrates/channels/src/dab/ofdm.rs
DVB-S puncturing and Reed-Solomon parameterscrates/channels/src/datv/dvbs.rs
DVB-S2 LDPC accumulator addressescrates/channels/src/datv/dvbs2/tables/
DVB-S2X LDPC addresses, constellations and interleaverscrates/channels/src/datv/dvbs2/s2x/
VL-SNR header sequencecrates/channels/src/datv/dvbs2/vlsnr.rs
DVB-T continual pilot and TPS carrierscrates/channels/src/datv/dvbt/en300744.rs
DVB-T2 pilots, reserved carriers, P1, L1 and LDPC tablescrates/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 itemGives
SettingsYour 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

LanguageBuildNotes
C, C++zig cc -target wasm32-freestanding -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic -o p.wasm p.cOr clang --target=wasm32 with the same flags and wasm-ld installed
Zigzig build-exe p.zig -target wasm32-freestanding -fno-entry -rdynamic -O ReleaseSmallexport fn for each export
AssemblyScriptasc p.ts -o p.wasm -O --use abort=Hand out buffers from heap.alloc, which the GC never moves or frees
Gotinygo 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 larger memory.grow fails.
  • Import nothing but the sdrmm module below: no WASI, no env. 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

ExportSignatureDoes
sdrmm_abi() -> u32ABI version, currently 1
sdrmm_alloc(len: u32) -> ptrA buffer for the host to write len bytes into
sdrmm_manifest() -> i64Packed manifest JSON
sdrmm_call(op: i32, ptr, len) -> i64A control call, JSON in, packed JSON reply out
sdrmm_open(rate: f64, ptr, len) -> i32Opens a channel at rate Hz with settings JSON; a handle, or -1
sdrmm_apply(handle, ptr, len) -> i32New settings JSON; 0, or -1
sdrmm_input(handle, samples: u32) -> ptrWhere the host writes the next block
sdrmm_process(handle, samples: u32) -> ptrRuns 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() -> i64Packed 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"}.

opInputok value
1 layoutSettings{"input_rate_hz": 48000.0, "band": [-6000.0, 6000.0], "audio_channels": 1}
2 normalizeSettings, {} for the defaultsThe 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

  1. sdrmm_input(handle, n) returns a pointer; the host writes n samples there, each an f32 I then an f32 Q.
  2. sdrmm_process(handle, n) returns a pointer to nine u32 words:
WordHolds
0, 1Audio pointer and length in f32 samples, interleaved by audio_channels
2Audio rate in Hz
3, 4Events: pointer and byte length of a JSON array, 0 for none
5, 6Status: pointer and byte length of a JSON object, 0 when unchanged
7Frames heard but failing their checks, counted over the channel's life
8Blocks 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.

ImportSignatureDoes
log(level: u32, ptr, len)Writes a UTF-8 line to the server log
fir_new(taps_ptr, taps_len, decimation: u32) -> i32A complex FIR over real f32 taps
fir_process(handle, in_ptr, in_len, out_ptr, out_cap) -> i32Samples written
fft_new(len: u32, inverse: u32) -> i32An FFT of len points
fft_run(handle, ptr, len) -> i32Transforms len samples in place
resampler_new(ratio: f64) -> i32A fractional resampler, output rate over input rate
resampler_process(handle, in_ptr, in_len, out_ptr, out_cap) -> i32Samples 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:

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

CommandRuns
cargo xtask mobile bindingsSwift and Kotlin bindings
cargo xtask mobile checkClippy for the iOS and Android targets; --ios or --android picks one
cargo xtask mobile iosThe core as an XCFramework
cargo xtask mobile androidThe 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.

CommandRuns
cargo xtask ios generateThe core and the Xcode project, through XcodeGen
cargo xtask ios buildThe app for the simulator
cargo xtask ios testUnit tests; --ui adds UI tests, --only <test> a subset
cargo xtask ios lintswift-format and the comment check
cargo xtask ios e2eThe app against a real sdrmm
cargo xtask ios archiveA Release archive in target/ios/, after a privacy manifest check
cargo xtask ios uploadUpload 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

  1. Use a phone with Android Auto, or the Play Store emulator signed in with Android Auto installed.

  2. In Android Auto, tap Version ten times, open Developer settings, turn on Unknown sources, then Start head unit server.

  3. 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"
    
  4. Open SDRmm from the head unit's launcher. Type day or night in 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.

VariableDoes
SDRMM_CAPTURE_CHANNELS=8Channels per radio, default 4
SDRMM_CAPTURE_SPREAD_HZ=2000000Spread channels over this span instead of 25 kHz steps
SDRMM_CAPTURE_MIXED=1Cycle NFM, WFM, AM, and SSB
SDRMM_CAPTURE_RETUNE=1Retune channels every 5 s
SDRMM_CAPTURE_DEVICE_RETUNE=1Retune radios every 5 s
SDRMM_CAPTURE_ALTERNATE_RATE=10000000With device retunes, alternate to this rate
SDRMM_CAPTURE_RTL_RATE=3200000Override the RTL-SDR rate only
SDRMM_CAPTURE_CPU_THREADS=4Add CPU load threads
SDRMM_CAPTURE_RECORD=1Record IQ and verify sample counts
SDRMM_CAPTURE_HISTORY=1Capture history, then record live, and verify
SDRMM_CAPTURE_HISTORY_SECONDS=6History length, default 1 s
SDRMM_CAPTURE_TRANSPORT_SECONDS=5Raw USB test length per radio, default up to 30 s; 0 skips it
SDRMM_CAPTURE_ALLOW_DROPS=1Measure 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:

VariableValue
SDRMM_DF_TX_HZTransmitter frequency
SDRMM_DF_BEARINGSBearings relative to element 1, at least 3, none within 10° of its axis or mirrored about it, like 40,130,250
SDRMM_DF_RADIUS_MArray radius, default 0.35
SDRMM_DF_WINDINGclockwise (default) or counter_clockwise
SDRMM_DF_GAIN_DB, SDRMM_DF_BANDWIDTH_HZGain, 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:

MHz0 dB8.7 dB19.7 dB29.7 dB49.6 dB
3000.0320.1330.1640.171
10000.0460.1370.1640.171
433.9200.0450.1290.1590.168
86800.0440.1290.1570.160
1090000.0220.0980.116
1300000.0060.0550.071
17000000.0040.007

Noise source solves over all 29 gain steps, lanes 2 to 5 against lane 1:

MHzLowest coherenceLowest pair coherenceLowest purityMost clipped samplesPhase from 0 dB
1000.830.820.950.58−8.9° to +3.8°
433.920.840.830.950.36−9.2° to +2.1°
8680.740.710.920.34−9.4° to +2.4°
10900.910.910.970.28−9.3° to +2.5°
13000.750.760.910.22−9.2° to +2.4°
17000.680.670.890.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.

CheckResult
Start spread, 20 starts at 1.024, 2.4, 2.56 MS/sOne start per lane, printed by the test. Coarse search reaches 256 to 437 ms.
2.88 MS/sRefused: sample_rate 2880000 outside the windows 225001-300000 and 900001-2560000 Hz
Noise source offBack at the noise floor within 1,024 samples
Noise source on exitOff after a drop, a drop while streaming, a panic, and a server stop
Array, 433.92 MHz, 30 dBLocked 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 dBStale after 0.44 s, solved after 0.69 to 0.71 s
Processors during noiseNone saw a noise block
Direction finder on noise only357 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:

FMTargetDABTarget
Front, share of a core0.050.100.110.15
CPI, one thread6.3 ms25 ms62 ms120 ms
CPI, crew of 35.2 ms40 ms50 ms
CPI, Auto5.3 ms on the CPU23 ms on the GPU30 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.

WorkCPU medianGPU medianRuns on
Spectrum frame, 64 × 4,0960.48 ms0.33 msCPU, cheap either way
Spectrum frame, 64 × 32,7684.89 ms1.23 msGPU
Spectrum frame, 64 × 65,53611.3 ms2.29 msGPU
Spectrum frame, 64 × 262,14456.0 ms7.89 msGPU
Filter bank, 13 bands, 8,192 samples79.5 µs177.3 µsCPU
Filter bank, 13 bands, 32,768 samples395.4 µs222.6 µsLarger than any real block

Hardware tests has passive radar timings on a KrakenSDR.

Raw medians and p95.

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 drawSoftware draw
640 × 2400.076 ms2.236 ms
1280 × 7200.246 ms12.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

SecretNeeded forWithout it
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_IDR2 uploadsA tagged release fails
TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORDUpdater signatures, passed to cargo-packagerThe release fails
APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_IDmacOS signing and notarizationThe release fails
APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_PRIVATE_KEYiPhone uploadsThe iPhone job fails
AUR_SSH_KEYPushing to the AURThe AUR job is skipped
WINGET_TOKENWinGet submissionThe WinGet job is skipped
PACKAGES_GPG_KEYSigning the APT and RPM repositoryThe repository job is skipped
DISCORD_WEBHOOKPosting release notes to DiscordThe Discord job is skipped

A tag release warns for every skipped job. Other release jobs continue.

Release checklist

  1. Run cargo xtask check, test, smoke, and audit.
  2. Run cargo xtask desktop and build the container.
  3. Check generated API, license, icon, and band-plan outputs.
  4. Validate hardware with the candidate package, including reconnect and recording.
  5. Confirm updater and platform signing credentials.
  6. Run cargo xtask release, push the tag and check every artifact job.
  7. Install a published artifact and run sdrmm --version and sdrmm --doctor.

Manual workflow dispatch rehearses the artifact matrix without publishing a GitHub release.