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

SDR-- logo

Welcome to SDR--

SDR-- 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 SDR--.
  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
Run the radio somewhere elseDeployment
Use a phone in the fieldField mode
Fix a problemTroubleshooting
Work on SDR--Build and test

How it runs

A server talks to the radio and does all signal processing. The desktop app and the browser show the same interface on top of it. Run both on one computer, or put the server next to the antenna and connect over the network. Every connected client sees the same workspace.

SDR-- is under active development. The decoder catalog shows how well each mode is tested.

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
WinGetWindows
APTDebian and Ubuntu
DNFFedora
NixLinux managed with Nix
ContainerDocker

Desktop app

Download the installer from the download page and open SDR--. 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, or http://<server>:8080 from another computer.

The server listens on every network interface with no password. Set up a token and HTTPS before untrusted devices can reach it.

Homebrew

brew install newspicel/tap/sdrminusminus   # macOS desktop app
brew install newspicel/tap/sdrmm           # server, macOS or Linux
brew services start sdrmm

The cask installs into /Applications. The service starts the server at login. Open http://localhost:8080.

WinGet

winget install Newspicel.SDRminusminus

APT

curl -fsSL https://newspicel.github.io/packages/key.gpg \
  | sudo tee /usr/share/keyrings/sdrminusminus.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/sdrminusminus.gpg] https://newspicel.github.io/packages/deb stable main" \
  | sudo tee /etc/apt/sources.list.d/sdrminusminus.list
sudo apt update
sudo apt install sdrminusminus

APT and DNF install the desktop app. For the sdrmm server, use the portable server.

DNF

sudo dnf config-manager addrepo \
  --from-repofile=https://newspicel.github.io/packages/rpm/sdrminusminus.repo
sudo dnf install sdrminusminus

Nix

Install and launch the desktop app on x86_64 or aarch64 Linux:

nix --extra-experimental-features 'nix-command flakes' \
  profile install github:Newspicel/sdrminusminus
sdrmm-desktop

The flake exports the package as sdrmm-desktop, sdrmm, and default. From a checkout, nix build 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 sdrminusminus:

environment.systemPackages = [
  (inputs.sdrminusminus.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/sdrminusminus.git
cd sdrminusminus
docker compose up -d

Open http://localhost:8080. 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 stable updates at startup and never moves to a nightly on its own. The nightly release follows main and may change saved data without a migration.

Next

Your first receiver

Listen to a local FM station with an RTL-SDR. You need the receiver, an antenna, and SDR-- installed.

1. Pick the radio

Attach the antenna and plug the RTL-SDR into the computer running the server. Open SDR--.

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 station you know is on air locally. The Device follows the channel on its own, so you do not need to tune the radio. See Tuning.

3. Listen

Start the Speaker. If it stays silent:

  • Click the page once. Browsers block audio until you do.
  • Turn squelch off on the channel.
  • Check the channel marker sits on the station in the Scope.

Adjust the Device gain until the station stands clearly above the noise without clipping. More help: silent audio.

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. Everything saves on its own.

Next

Nodes and wires

Everything in SDR-- 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 that carries the same kind of data:

PortCarriesTypical wire
iqRaw radio samplesDevice → channel, Scope, recorder
audioDemodulated soundChannel → Audio FX, Speaker, Audio recorder
eventsDecoded messagesChannel → Readout, Decoder log, Map
basebandOne channel's filtered IQChannel → Baseband scope, recorder, Network IQ
videoPictures and videoChannel → Video
controlTuning commandsScanner, Satellite → channel
positionStation locationGPS position → Map, Recorder, ADS-B

Node types

GroupNodes
SourcesDevice, Recording, Signal generator, GPS position
DecodersAM, NFM, WFM, ADS-B, DMR, and every other decoder
ToolsArray, Scanner, Signal hunt, Spectrum monitor, Satellite, DMR trunk system, Event filter, Audio FX, Direction finder, Triangulation, Passive radar, Combiner
OutputsScope, Baseband scope, Speaker, Readout, Decoder log, Map, Video, Signal survey, Propagation map, recorders, Network IQ, Event output, Export

+ Add lists what the running server offers. Double-click or right-click the canvas to add a node at the cursor. Hover an entry to see what it does.

How a Device and its channels share a radio

A Device opens one radio. A channel decodes one frequency from the Device's IQ.

By default a Device tunes itself. It places its window over as many wired channels as its sample rate can hold, and keeps its own DC spike off them. The Device header counts how many it covers: 5/5 is green, 3/5 yellow, 0/5 red. To cover more, raise the sample rate or move some channels to another radio.

To tune by hand, press the radar button on the Device or just turn its dial. The Device then stays put, and channels outside its window wait until it covers them again.

A channel wired to several Devices runs on whichever one hears it, and names that radio on its face. See Channels.

Radios come back

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. Forget this radio frees the node for another one.

Applying changes

Edits apply on their own. Applying opens radios, restores settings, updates channels, and closes anything the workspace no longer uses. If a node says the saved layout and the running receiver differ, press Apply patch.

Shared by everyone

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.

Radios

A Device node opens one radio: over USB, over the network, or through SoapySDR. Recordings and generated signals have nodes of their own, see Other sources.

Radio missing? Press Check hardware on an empty Device node, or run sdrmm --doctor.

Supported radios

The desktop and portable builds include these drivers:

RadioConnects overNeeds
RTL-SDRUSBNothing
KrakenSDR, KerberosSDRUSBNothing
HackRFUSBNothing
Airspy R2, Mini, HF+, HF+ DiscoveryUSBNothing
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
KiwiSDRNetworkNothing
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.

Connect a radio

USB

Plug it in: it appears on 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. SDR-- never needs root. For containers, see USB devices.

Network radios

On an empty Device node, open the Network tab and enter host:port:

ProtocolDefault port
rtl_tcp1234
SpyServer5555
SDRconnect5454
KiwiSDR8073
AD936x / iiod30431

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 on every empty Device node.

Network IQ uses a lot of bandwidth. When the link cannot keep up, the radio node shows Lost with the share of samples missing: lower the rate.

Check the installation

sdrmm --doctor

It lists compiled drivers, loaded libraries, SoapySDR modules, found radios, data paths, and Linux USB permissions. Check hardware runs the same checks from the interface.

Device controls

Controls mean the same thing on every radio:

ControlSets
RateSample rate
LanesHow many receive lanes stream, on radios that can choose
FilterAnalog bandwidth before sampling, or Auto
AntennaInput port, when there is a choice
AGCAuto on the gain row. The radio sets its own gain; the slider shows what it chose, where the radio reports it.
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
ConverterLocal 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 own controls sit under iq1, iq2, and so on, and the ones they share under All lanes. Health shows clipping, queue delay, and lost samples.

With a converter 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 below these rows. Some change the others: RTL-SDR direct sampling changes the tuning range. Transmit is not available yet.

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

ControlDoes
TunerGain, in the tuner's own steps: 20 dB on an R820T becomes 19.7 dB
AGCTuner 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. Filter: 290 kHz to 8 MHz on R82xx tuners.

Serials

Many dongles ship with the serial 00000001. Two dongles with one serial are told apart by USB port instead, shown as RTL-SDR (bus/address), and their settings and calibration can follow the wrong one after a replug. Give each its own serial, one dongle plugged in at a time:

rtl_eeprom -s 00000002

Replug it afterwards. rtl_eeprom comes with the rtl-sdr package.

KrakenSDR

One Device with five lanes; KerberosSDR has four. SDR-- groups the tuners by serial and USB hub, so the vendor Pi image is not needed. Each lane has its own dial, gain, and AGC. Lanes wired to a coherent node tune together. There is no direct sampling. SDR-- runs the noise source during calibration.

If the array shows up as separate dongles, one of its tuners is missing: check sdrmm --doctor or lsusb.

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
FilterBaseband 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: LNA, Mixer, and VGA gain use firmware steps, not dB. AGC can run the LNA, the mixer, or both. Bias tee available. Faint carriers on multiples of 10 MHz come from the radio's own clock.

HF+ and HF+ Discovery: tunes up to 31 MHz and 60 to 260 MHz. Controls are Amp, attenuation 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. Only the widest rates leave a spike at the centre for the DC blocker. Images sit about 50 dB down; the vendor's adaptive IQ balance is not used.

Tested on hardware provided by Airspy. Thank you.

AntSDR

SDR-- talks to the iiod server of the AntSDR's Pluto firmware directly, with no libiio. It is tested on the E310: an AD9361 from 70 MHz to 6 GHz with up to 56 MHz of bandwidth, and two receive and two transmit lanes on one synthesizer, so both receive lanes are phase coherent. Support for the UHD firmware is planned.

USB: connect the USB 2.0 port and the board appears on its own, with no network setup. Windows needs the PlutoSDR drivers. USB 2.0 carries a few MS/s.

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.

Search tries 192.168.1.10, ant.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.

Gigabit Ethernet carries about 60 MB/s from the E310: 15 MS/s on one lane, or 7.5 MS/s per lane on two. Set Lanes to 1 for one wide lane. The E310 locks its antenna and TX ports in firmware, so those menus are hidden. The other controls are the AD936x ones.

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 on their own. Search also tries pluto.local, 192.168.2.1, ant.local, and 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 (30.72 on a 2×2 board); 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
Lanes1 or 2 on a 2×2 board. One lane gets the whole link
TunerReceive gain per lane. The range follows the band
TXTransmit attenuation per lane
AGCPer lane: slow attack, fast attack, or hybrid
Quadrature, RF DC, baseband DC trackingHardware corrections
Antenna, TX portShown only if the board lets the port change

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. With AGC on, the IF slider sets the starting gain.

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

SDR-- receives raw IQ and does its own demodulation. Extra settings:

SettingDoes
lnaRF gain over the LNA states; lower means more gain. There is no IF gain.
device_vfo_frequencySDRconnect's VFO inside the sampled window
filter_bandwidthSDRconnect'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.

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 out of band. 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, SDR-- stops and does not reconnect. A dropped connection is retried.

Dragon Labs CR-8

Eight phase_coherent lanes on one Device, iq1 to iq8, for calibration, direction finding, beamforming, and passive radar. All lanes tune together at a fixed 12.5 MS/s, with LNA, mixer, and VGA gain per lane. The clock is internal or an external 10 MHz reference.

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 SoapySDRSoapySDR-bladeRF
Archsudo pacman -S soapysdrsoapybladerf
macOSbrew install soapysdrsoapybladerf
WindowsPothosSDR, on PATHIncluded
NixOSsoapyPlugins

Desktop and portable builds load SoapySDR at runtime and work without it. The Homebrew formula installs the core. The container ships the core with bladeRF, LimeSDR, and SoapyRemote modules. A remote SoapySDRServer shows up in the normal radio list, through SoapyRemote.

Modules must match SoapySDR 0.8. Others are rejected and logged. For unusual install locations:

VariableValue
SDRMM_SOAPY_LIBRARYFull path to the core library
SDRMM_SOAPY_MODULE_PATHExtra module folders, searched first

SoapySDRUtil --find shows what SoapySDR itself sees. It knows nothing about the built-in drivers, which SDR-- prefers when both could open a radio.

Other sources

NodeGives
RecordingPlays a SigMF recording
Signal generator44 signals, from a plain tone to DVB-T

Debug builds also list synthetic radios: a four-lane coherent array, a test band and test transceivers.

How radios are found

SDR-- looks for radios when USB devices change, and for network radios once a minute. SoapySDR probing runs in a child process, so a crashing module cannot take SDR-- down. Set SDRMM_SOAPY_PROBE=in-process to turn that off while debugging.

Channels

A channel takes a Device's IQ and turns one frequency into audio, messages, or pictures. Which channel you pick decides the mode: AM, WFM, ADS-B, and so on. The Decoders page 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
videoVideoATV frames or SSTV pictures
basebandBaseband scope, 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 the analog modes.

Which radio hears a channel

A Device on auto tuning moves its window to cover its channels. A Device tuned by hand only carries the channels inside its window. The rest stay configured and resume when the radio covers them again. See Tuning.

Wire a channel to more than one Device and it runs on whichever radio hears it. Radios on auto tuning split their channels between them so as many as possible are heard. A channel wired to only one radio stays on that radio. 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.

Tune

Use the channel dial, drag its marker on the Scope, or use the keyboard. Typed frequencies are in MHz unless you add kHz, MHz, or GHz. The step buttons move by 5 or 25 kHz.

The lock beside a dial freezes that frequency. Locking a channel does not lock its Device.

You can set channel frequencies before a radio is connected. The Device opens over them.

Sample rate

Each channel runs at its own fixed rate. The Device's IQ is resampled to match, so any Device rate works for any channel, as long as the channel's full bandwidth fits inside the Device's window. If it does not fit, retune the Device, move the channel, or raise the sample rate.

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. Data decoders use their own detection threshold.

ModeOpens
OffAlways
ManualAbove a fixed level
AutoA set number of dB above the measured noise floor

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. Switching back to Manual restores your last manual level.

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

Compander expands audio 2:1 for links that compress it. Leave it off for ordinary NFM.

Noise blanker

The channel's Blanker removes impulse noise from the IQ before filtering. A lower threshold removes more, but can damage the signal. AM and SSB level their own volume.

Audio FX

Wire a channel's audio through an Audio FX node to process what you hear. The channel's raw audio stays available on its own 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 from the audio.
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: attenuates noise by up to 20 dB, light on CPU. Neural: the DPDFNet speech model, stronger on voice, about 10% of a core per stream.
AGCLevels the volume. Slow suits SSB speech, fast suits tuning around.

Neural denoise is trained on speech. Leave it off for music, data tones and CW.

Where decoded events go

Every event carries its source, frequency, and time. Use Readout for what is happening now, Decoder log for history, Map for positions, and Export to save rows. The log keeps a bounded history.

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. Rows already in the log stay.

Tuning

Tune the decoder, not the radio. Every Device starts on Auto: set a channel's frequency and the radio moves its window to cover it.

Auto

The radar button next to the Device dial is lit while Auto is on.

On Auto, the Scope does not pan the radio. Drag a channel marker to move that decoder.

With several decoders, the radio picks a window that covers as many as it can. The count on the Device node, such as 2/3, shows how many it hears.

Manual

The radio stays where you put it. Channels outside its window stop until it covers them again.

Tuning the radio itself, from its dial, the keyboard, or the Scope, switches to Manual. SDR-- asks first. Tick Don't show again to skip the question. Press the radar button to return to Auto.

Use Manual to watch a fixed band in the Scope, or when no decoder is wired.

Lock

A lock next to a dial stops tuning by hand.

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 the modes in the running build, grouped as below.

GroupTested on airFixture onlyExperimental
Analog voiceAM, NFM, SSB, WFM with stereo and RDS
Digital voiceDMR, FreeDV 1600D-STAR, System Fusion, NXDN, P25 Phase 1, dPMR, M17
AviationADS-B (1090ES)ACARS, VDL Mode 2, HFDL, Inmarsat Classic AeroVOR, ILS localizer and glideslope
MarineAIS, NAVTEX, DSC, Inmarsat STD-C and EGC
Amateur and HFCW skimmer, FT8, FT4, WSPRAPRS / AX.25, RTTY, PSK31 to PSK250, Morse
Paging and telemetryPOCSAGFLEX, ERMES, Selcall (CCIR, ZVEI), DCF77, WWVB, MSF, JJY
Pictures and videoSSTV, ATV
Broadcast digitalDAB and DAB+DVB-T/T2, DATV (DVB-S/S2), DRM30 and DRM+
UtilitySignal identifierIridium bursts, DECT surveyGNSS lab (GPS L1 C/A)
LabelMeans
Tested on airVerified live, through a real radio and the full receiver
Fixture onlyVerified on recordings, generated IQ, or reference vectors. Not yet verified live.
ExperimentalPartly works. See the limits below.

Fixtures catch decoding bugs but say little about drift, fading, or interference. The fixture library lists where each recording came from. VDL Mode 2, HFDL, Inmarsat Classic Aero and STD-C, DSC and Iridium started as ports of xng. Inmarsat Classic Aero reads the P channel, R/T bursts from aircraft, or a C voice circuit; pick one on the node. Iridium decodes one 50 kHz channel, or bursts across 1 to 10 MHz when you set its span to the radio's sample rate; it reads the middle 80%.

Experimental limits

ModeWorksMissing
DATVDVB-S/S2/S2X, programme tables, audio, video, GSEVerified on synthetic IQ only
DVB-T/T2DVB-T HP/LP, T2-Base and Lite, SISO/MISO, 1K to 32K, PLP choice, audio, videoSynthetic IQ only. No GSE, no multi-RF TFS.
DRM30 / DRM+Lock, SNR, frequency errorNo FAC, SDC, or MSC. No services or audio.
GNSS labGPS L1 C/A acquisition and navigation dataNo position fix
VOR / ILSRadial, difference in depth of modulationTested on generated signals only

DAB and DAB+

Wire audio to a Speaker. Auto plays the first audio service. Generation limits the choice to DAB or DAB+. Transmission picks mode I to IV. All run at 2.048 MS/s. Only mode I has been received on air.

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.

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. Pick a discovered programme or enter its number.

DVB-T/T2: set Standard and Bandwidth. Everything else is read from the signal. Low priority stream picks DVB-T LP. 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 Symbol rate from 100 kBd to 4 MBd. The channel rate is twice the symbol rate, so a 2 MBd carrier needs a radio that delivers 4 MS/s. Set Roll-off to match the transmitter. DVB-S is always 0.35. DVB-S2 finds the MODCOD on its own, including VL-SNR. Input stream picks one stream on a multistream carrier.

Superframes enables DVB-S2X Annex E formats 0 and 1. Formats 2 to 7 are not supported.

IP data

DAB IP services and DVB-S2 GSE carry IP packets. To put them on your network, wire the channel's events to an Event output, choose Network interface, and set an interface name, address, and prefix. SDR-- 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.

Databases

Wire events to an Event output and choose PostgreSQL or InfluxDB.

PostgreSQL creates the table on first write: one row per event with time, kind, frequency, station, summary, and the full record as jsonb. Add ?sslmode=disable to the URL for a server without TLS.

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.

DMR trunking

Add DMR trunk system, wire Device iq, and enter the control channel in MHz. Pick the system type or leave it on auto. The node creates the DMR channels it needs.

SystemFinds its channels by
Tier III, including Capacity MaxReading channel definitions from the control channel
Capacity PlusWatching for carriers that share rest-channel changes
Hytera XPTSame as Capacity Plus, with XPT signalling

Following runs on the server with no browser open. Voice channels must fit inside the Device's window. A grant outside it is reported.

Record calls keeps finished calls and their audio in memory. Encrypted calls keep only metadata.

SSTV

Tune SSTV to the SSB carrier. A picture takes from 36 seconds to four and a half minutes.

SettingDoes
Follow VISReads the mode from the transmission
Manual modeUses the chosen mode when the header was missed
Slant correctionStraightens pictures from a slightly off clock. Leave it on.
Keep unfinished picturesSaves a picture cut short by a fade

Modes: Robot 36 and 72, Martin M1 and M2, Scottie S1, S2 and DX, PD50 to PD180, Wraase SC2-180.

Wire video to Video to watch a picture arrive. Finished pictures are saved as PNG on the server, even with no client open, and kept for 24 hours, up to 512 pictures.

DECT

The DECT channel surveys base stations: identity, capabilities, and security. It reads signalling only, never call audio.

It needs a radio that reaches 1.9 GHz at 2.304 MS/s or more. HackRF and SDRplay work, RTL-SDR does not.

SettingChoice
BandEurope 1880 to 1900 MHz, or US 1920 to 1930 MHz
SideBase, Handset, or Both

Carriers are 1.728 MHz apart. European carrier 0 is 1897.344 MHz and the numbers count down. US carriers count up from 1921.536 MHz.

Each record lists the base identity (RFPI), system information, capabilities, advertised and observed security, and handset IDs seen during encryption setup. 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.

Pager text

Some German POCSAG networks send umlauts as { | } [ \ ] ~. SDR-- converts them inside lowercase words only: M}nchen becomes München, Stra~e becomes Straße. [ALARM] and all-caps messages stay as sent.

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.

  1. Add a channel in the mode you want to hear and wire it to a Speaker.
  2. Add Scanner and wire its control to the channel's control.
  3. Enter frequency ranges and choose a mode.
  4. Set the detection level and start.
ModeStops on
TargetsA listed frequency above the threshold
Close callThe strongest carrier above the noise floor in the whole span

Match the step to the service's channel spacing. The scanner measures each target over the channel's own bandwidth, so a narrow channel scans selectively and a wide one forgivingly.

On a hit the channel parks there so you hear it. The scan resumes after the signal has been quiet for the resume delay. Skip leaves the current frequency and ignores it for the rest of this scan. The channel's dial is locked while scanning, and stays on the last frequency when you stop.

Radios that support it sweep in firmware, which pauses other channels on that radio while it runs. Otherwise the scanner steps. The Sweep readout shows which one is in use.

Identify a signal

Add Signal identifier and select a span up to 192 kHz wide. It lists each transmission, strongest first, with modulation, bandwidth, symbol rate, deviation, and burst timing.

For each one it suggests likely protocols. A suggestion marked Confirmed was proven by a real decoder finding valid frames. The others are guesses from the waveform and the band.

Interval sets how long it listens per report. Threshold sets how far above the noise a signal must be. A quiet span is reported once, not every interval.

It cannot see spread-spectrum signals below the noise, 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.

Each transmission produces one event when it ends, with frequency, bandwidth, confidence, decoder results, and optional audio. Open it in the log to play the audio.

SettingDoes
Record audioAttaches an 8 kHz WAV clip. Long signals get a clip every 30 seconds.
Min confidenceSkips weaker guesses. Default 70%, 0 accepts everything.

Limits: 32 signals at once, three decoder attempts 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 and start it. Retune the channel to retune the hunt. On a phone, use the Fox hunt mission in field mode.

Survey an area

  1. Add Signal survey and wire Device iq and GPS position to it.
  2. Pick an offset inside the Device's window and a measurement width.
  3. Wait for a level and a GPS fix, then start.
  4. Export the results as CSV when done.

Each GPS fix records the peak level in dBFS within the slice, grouped 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.

Position and GPS

The GPS position node tells other nodes where the station is. One node can feed many.

Pick a source

TabSource
ReceiverA serial NMEA GPS on the server. Set the baud rate.
Networkgpsd, default 127.0.0.1:2947
FixedLatitude and longitude you type in
This deviceThe location of the browser or desktop app showing the interface

Serial and gpsd sources must be reachable from the server, and reconnect on their own. This device needs HTTPS or localhost. Forget source picks a different one.

The node shows the fix and your Maidenhead locator. A lost fix is reported, and old coordinates stop being used.

What uses it

NodeUses position for
ADS-BDecoding aircraft positions
MapYour station, route, and a heatmap of visited places
RecorderLocation in the recording's metadata
SatellitePass and Doppler prediction
Signal surveyWhere each measurement was taken
Direction finder, Passive radar, Propagation mapPlacing results on the map

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, or type the downlink.
  5. Wire Satellite control to each decoder's control.

On auto tuning the radio follows the decoder, as it does for a scanner.

Readouts

ReadoutShows
LookAzimuth and elevation
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
ElementsAge of the orbit data. Refresh when it turns yellow.

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

LayerShows
ActivityEstimated reflection points, weighted by count and age
MUFEstimated MUF lower bound per Maidenhead square
PathsGreat-circle paths by station and band. Off by default.

Only messages with a locator add a path. Reports and 73 usually do not.

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.

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. Use 300 km 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.

Recording and playback

NodeRecordsWire fromFormat
RecorderThe Device's full IQDevice iqSigMF
Baseband recorderOne channel's filtered IQChannel basebandSigMF
Audio recorderOne channel's audioChannel audio48 kHz 16-bit WAV
Time machineIQ from before you pressed the buttonDevice iqSigMF

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.

A clean server shutdown finishes open recordings. Killing the process can leave one incomplete.

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, or removing the channel, ends the file.

Audio recorder keeps what reaches it, after squelch and any Audio FX it is wired behind. Closed squelch writes silence so timing stays intact. Mode and rate changes do not stop it. The file stays playable even if the server stops mid-recording.

Both recorders take several channels and write one file per wired input.

Time machine

Capture a signal after it happened:

  1. Wire Device iq to Time machine, and GPS position if you have one.
  2. Set how many seconds to keep and press Arm.
  3. Press Capture to save the buffer and keep recording live.
  4. Stop ends the file and stays armed. Disarm frees the memory.

The buffer uses seconds × sample rate × 8 bytes, up to 1 GiB. The node shows both. The sample rate is locked while armed. Retuning starts a new segment in the same recording.

Play a recording

In Library → Recordings, press Open as source. A Recording node appears. Wire it to channels and displays like a Device, then use play, pause, and seek to decode the same samples again with different settings.

Upload SigMF adds a recording from your computer, as a .sigmf archive or a .sigmf-meta and .sigmf-data pair.

Tags and notes

In Library → Recordings, choose Annotate to add 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 the original SigMF archive or as a stereo float WAV with I and Q as channels. WAV keeps the samples but not all metadata. 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 aircraft 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 outputADS-B Beast TCPFlight tracking feeders

None of these ports use the server's token or 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 the protocol, encoding, and destination host:port.
  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. Retuning works, but you must update the receiver yourself. The node counts bytes, writes, and errors.

Samples are interleaved I, Q, I, Q, with no header or timestamps:

EncodingSamplesBytes per I/Q pairGNU Radio type
cf32_le32-bit float8Complex
ci16_leSigned 16-bit4Short, then Interleaved Short to Complex
cu8Unsigned 8-bit, zero at 127.52RTL-SDR byte IQ

Names follow SigMF. VITA 49 and DIFI are not supported.

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

ADS-B Beast

  1. Wire the ADS-B channel's events into Event output.
  2. Pick ADS-B Beast TCP, set 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.

Workspaces and presets

HoldsUse it to
WorkspaceNodes, wires, rack, radio settings, band planKeep a whole receiver
TemplateA ready-made receiver, built inStart a common setup
PresetA saved copy of a tuned workspaceGet back to a known state
BookmarkA frequency and a nameRetune quickly

Workspaces

Create, switch, rename, and delete workspaces from the name in the top bar. A new database starts with a Device, a Scope, and a Speaker. Later workspaces start empty. Changes save on their own.

Undo

Use the top-bar arrows, Ctrl/⌘ Z, and Ctrl/⌘ Shift Z. Undo changes the running receiver for every client: undoing an added channel closes it. The server keeps 100 steps per workspace. Tuning is not part of the history.

Copy and paste

Select nodes, then Ctrl/⌘ C and Ctrl/⌘ V. Copies land beside the originals with the wires between them. Pasted Device nodes need a radio picked. The clipboard works across workspaces while the tab stays open.

Export and import

The ↓ button downloads the workspace as JSON. Import a workspace file adds it as a new workspace. Radios that are present open with the saved settings. Missing ones stay disconnected and are listed in the apply report, so you can pick replacements.

Templates

Select a Device, then open Library → Templates. A template retunes that radio, sets its rate, and adds channels and outputs. Templates the radio cannot handle are greyed out.

Undo removes the added nodes but leaves the radio's new frequency and rate.

Presets

Save a preset once a workspace is set up and tuned. Applying it restores the nodes and the radio settings. The apply report lists anything it could not restore.

Bookmarks and band plans

A bookmark saves the selected Device's or channel's frequency and tunes it back.

Bands picks your band-plan region and searches its allocations. A hit tunes the selected Device, or the selected channel, moving its radio if needed. Turn on the Scope's band ruler to browse allocations: hover for details, click to tune.

The region is picked from your location when the page is on HTTPS or localhost. You can always pick it by hand.

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
NanoVNASweeps an antenna over USB and shows SWR, impedance, and a Smith chart. Calibrates, and exports Touchstone files.
Radio programmerReads, edits, and writes codeplugs, and copies them between radios

Radio programmer

Supported radios: AnyTone AT-D890UV and Radtel RT-4D.

It edits what every radio shares: channels, contacts, group lists, zones, scan lists, and radio IDs. Everything else in the codeplug is kept byte for byte. When writing, it reads the radio first and writes back only the blocks that changed.

Copy to fits a codeplug to another radio model and stores it as a new one.

The NanoVNA and the radio programmer need the device plugged into the server, not the client.

Keyboard controls

Shortcuts act on the selected node or its connected Device. They are inactive while editing a text field or control. Press ? to open help in the app.

KeysAction
Left / RightTune down or up one step
Shift + Left / RightTune ten steps
[ / ]Choose a smaller or larger tuning step
fFocus the device 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
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
BackspaceDelete the selected node or wire
?Open help
EscapeClose an overlay or menu

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.
  • Enter opens direct frequency entry.

Select a Device before scrolling its dial. This keeps canvas scrolling from changing the frequency.

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

  • Use the address printed after SDR-- ready in the server log.
  • On the server itself, try http://127.0.0.1:8080.
  • From another machine, the server must listen on a reachable address, such as --bind 0.0.0.0:8080.
  • Check the firewall and any container port mapping.
  • Behind a reverse proxy, serve SDR-- at the root. Path prefixes do not work.
  • With TLS on, use https://.

The Linux window is blank or the waterfall is missing

Some graphics drivers break WebKitGTK: a blank window, frozen panels, or waterfall unavailable: no WebGL2 context. Try safe rendering:

SDRMM_LINUX_GRAPHICS=safe sdrmm-desktop
ValueDoes
autoDefault. Turns off DMABUF when the NVIDIA driver is loaded.
safeTurns off DMABUF and accelerated compositing. The waterfall may run slower.
offChanges nothing

Your own WEBKIT_* variables win. If the window still fails, run sdrmm --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>. WebSocket and download URLs can use ?token=....

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 is built for 0.8.
  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.

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 number. To use a different one, press Forget this radio and pick the new one.

Spectrum works but audio is silent

  • Wire channel audio to a Speaker and start it.
  • Click the page once. Browsers block audio until you do.
  • Turn squelch off, or lower it.
  • 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.
  • Use a release build.
  • On small computers, check for CPU throttling and heat.
  • 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 capture health on real radios, see hardware capture 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.

Coherent arrays

An array receives several antennas at once, from receivers that share a clock. How much they share decides what they can do:

TierSharedCan do
phase_coherentClock and local oscillatorBearings, beamforming, combining, passive radar
time_syncClock onlyPassive radar. The rest needs calibration after every retune.
noneNothingIndependent reception only

Receivers without a shared clock drift apart, even on the same frequency.

Multi-lane radios

KrakenSDR, CR-8, an RSPduo in dual-tuner mode, and multi-channel SoapySDR radios are one Device with several outputs: iq1, iq2, and so on. The driver reports the tier. Wire the outputs straight to the processing node.

KrakenSDR

KrakenSDR has five lanes, KerberosSDR four. They are time_sync: the tuner phases change on every retune.

Set Cal source to Noise. SDR-- then switches on the built-in noise source whenever it needs to calibrate, after a retune or when you press Calibrate, and switches back to the antennas. Bearings are hidden while it shows noise source in.

Use fixed gain and equal-length cables. AGC stays allowed, but its Auto turns amber on lanes a coherent node or Array uses. Calibration pauses during scans and hunts.

In Auto, the lanes a coherent node uses move as one, to the frequency that suits all their decoders. A new sample rate restarts the coherent nodes.

Build your own array

For separate receivers wired to one clock, use an Array node.

  1. Add a Device for each receiver.
  2. Give them the same sample rate, and the same frequency if they share tuning.
  3. Wire each Device iq to the Array. It grows an input per member.
  4. Set Wired as to match: shared clock, or shared clock and local oscillator.
  5. Wire the Array's outputs to the processor, channels, or recorders.

Input order sets antenna numbering. Use fixed gain: AGC breaks calibration.

Tuning a member, changing its rate, or switching it to Auto moves the whole Array, so the members stay aligned. A scan or hunt on a member moves the whole Array too. The Devices keep their radios, and removing the Array leaves them running. If a member drops out, the array pauses until it is back.

Calibrate

Press Calibrate on the processing node. It measures the delay, gain, and phase of each lane.

Cal sourceNeeds
SignalA strong signal every antenna receives
NoiseNoise fed into every lane, built in or through an external splitter

A time_sync array needs noise or a known pilot to recover phase after each retune. Built-in noise switches itself. Feed external noise before pressing Calibrate. On phase_coherent hardware, calibration corrects cable and path differences.

The node shows solved, still solving, or phase unknown. Phase unknown means there is not enough reference for bearings or beamforming.

Combine antennas

Wire a coherent source to a Combiner and its beam output to an ordinary channel.

ModeDoes
DiversityAligns and adds the antennas. Two antennas gain about 3 dB SNR.
CancelUses the other antennas to subtract local noise from the first

For Cancel, point the first antenna at the wanted signal and the others at the noise. Both modes need the phase: time_sync arrays need a pilot or noise reference.

The beam output also feeds a Scope and any number of channels. It stays silent until the phase is solved.

Stitch lanes into one wide stream

Wire every lane of a radio that tunes each lane on its own into a Stitch. Its wide output runs at the lane rate times the lane count, as one more radio lane.

ModeDoes
AutoTunes the lanes side by side with a small overlap. Tuning the wide lane moves them all.
ManualKeeps each lane where you tune it. Gaps between lanes stay empty.

A KrakenSDR at 2.048 MS/s gives about 8.7 MHz in Auto. Overlaps match each lane's gain and phase to its neighbour while a signal sits in them. A Stitch needs the radio's lanes to itself, so no Combiner or direction finder can share them. Use fixed, equal gain on every lane.

Direction finding

A Direction finder estimates where a signal comes from, using a coherent array. Triangulation crosses bearings from several places into a position.

Set up

  1. Add a multi-lane Device or an Array.
  2. Add Direction finder. Set Geometry and Elements to match your antennas: a circle with a radius, a line with a spacing, or explicit positions.
  3. Wire every lane to the matching input. All must come from one source.
  4. Set Offset and Bandwidth to cover the signal.
  5. Calibrate.
  6. Wire GPS position for the map and triangulation.
AlgorithmUse
BeamformerBroad and robust
MUSICSharper, but needs the right number of Sources. Start with one.

The compass

The compass shows the response, the chosen bearing, and the confidence. 0° is north, clockwise. The strip below shows calibration per lane. Phase unknown hides bearings: check the clock wiring and the calibration reference.

Listen in one direction

Wire beam to a channel. Follow bearing points the beam at the current estimate. Fixed azimuth holds a direction.

Triangulate

  1. Add Triangulation and wire each finder's events to it.
  2. Give each finder its own position, from GPS or fixed coordinates.

It shows the estimate, its error ellipse, the age of each bearing, and where to go next. For a long thin ellipse it suggests moving across the bearing. Once the estimate settles, it suggests driving towards it. Clear starts over.

Wire finder or Triangulation events to a Map to see bearing rays, the estimate, and the next waypoint. The first settled fix emits an event that Event output can forward by webhook, MQTT, or Matrix. On a phone, use the DF drive mission in field mode.

Passive radar

Passive radar compares a broadcast transmitter's direct signal with its echoes off aircraft and other objects. It measures how much further each echo travelled and its Doppler shift. Two lanes on a shared clock are enough; a time_sync array works without phase calibration.

Set up

  1. Add a Device or Array with at least two time-synced lanes.
  2. Add Passive radar.
  3. Wire the antenna pointing at the transmitter to ref.
  4. Wire the antenna pointing at the area you watch to surv.
  5. Wire GPS position for the map.
SettingDoes
IntegrationLonger finds weaker echoes, but blurs moving ones
Range binsHow far out the display reaches
Doppler spanHow large a frequency shift to search

Processing runs in five steps: cancel the direct signal and ground clutter, correlate reference with surveillance, detect cells above their background, merge neighbours, and track echoes over time.

Read the display

The display plots range against Doppler and marks detections. An echo seen repeatedly gets a track number. A single flash may be noise.

On the map

Turn on Transmitter and enter its position and frequency. With your own position known, the map draws an ellipse of possible locations for each echo.

The range is bistatic: the extra distance the echo travelled compared with the direct path. One echo gives an ellipse, not a point or a bearing.

On a phone, use the Radar watch mission in field mode.

Field mode

Field mode puts signal hunting, direction finding, and passive radar on your phone. Set up the workspace on a desktop first, then connect the phone to the same server.

Connect a phone

Open Library → Field and open the link on the phone. It carries the server token, which field mode saves and removes from the address bar. If the desktop is on localhost, the link uses a LAN address instead. You can also open /field in any browser that reaches the server.

The phone must reach the server. The desktop app and sdrmm --bind 127.0.0.1:<port> only listen on this computer; run sdrmm with the default --bind 0.0.0.0:8080.

Phone location needs HTTPS. Away from home, use a tunnel.

Missions

Each mission drives one node in the active workspace:

MissionNeedsShows
Fox huntSignal huntLevel, rising or falling, clicks that speed up
DF driveDirection finderCompass, guidance, map
Radar watchPassive radarRange-Doppler display and tracks

Fullscreen and keep-screen-on work where the browser supports them.

DF drive

The compass turns with your GPS heading, not the phone's compass. A Triangulation node adds guidance on where to drive. Without it, you still get bearings.

With a routing service configured, you also get turn-by-turn directions. Spoken directions start after you tap the screen.

Nav modeDrives to
AutoA crossing point until the estimate settles, then the estimate
DirectThe current estimate
OffNowhere; heading guidance only

Navigate in Maps hands the target to your phone's navigation app. Tap it again when the target moves; the browser cannot update an open navigation session.

Offline maps

Put basemap.pmtiles next to the server database for maps without internet. Without it, field mode uses the online map, or a blank background if that is unreachable.

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 0.0.0.0:8080 with no authentication.

Options

OptionDefaultSets
--bind <ADDRESS>0.0.0.0:8080Listen address
--db <PATH>Platform data folderSQLite database
--recordings-dir <PATH>Platform data folderRecording folder
--token <TOKEN>NoneShared access token
--tls-cert <PATH>, --tls-key <PATH>NoneHTTPS certificate chain and key, PEM
--tls-self-signedOffHTTPS with a self-signed certificate
--tls-name <NAME>Found addressesName the certificate must cover; repeatable
--routing-backend <NAME>open-route-serviceRouting: open-route-service or graph-hopper
--routing-url <URL>Public serviceSelf-hosted routing instance
--routing-key <KEY>NoneRouting API key
--remote-app <URL>https://app.sdrmm.comApp for remote access
--dev-corsOffAllow a separate frontend origin, for development only
--doctorPrint diagnostics and exit
--doctor-ratesProbe connected radios' sample rates and exit

For a service, use absolute paths for --db and --recordings-dir.

Data

The database holds workspaces, presets, bookmarks, the recording index, and the decoder log. Recording files hold the signals. Back up both. Stop the server before copying the database, or use SQLite's backup, and finish recordings before copying them.

Logs

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. The browser asks for it once and remembers it. API clients send Authorization: Bearer <token>. WebSocket and download URLs can use ?token=....

Everything except the page itself and GET /api/auth requires the token. Every client with the token can do everything. There are no user accounts or read-only roles.

HTTPS

The easiest route is a tunnel: Tailscale or Cloudflare handle the certificate.

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.

Without a certificate authority:

sdrmm --tls-self-signed

The certificate covers localhost and the server's LAN addresses. It is stored in tls beside the database and reused. 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:

sdrmm --tls-self-signed --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.

Reverse proxy

  • Bind SDR-- to loopback, or firewall its port.
  • Serve it at the root of the origin.
  • Forward WebSocket upgrades on /api/ws.

Turn-by-turn routing

Field mode gets driving directions from OpenRouteService or GraphHopper. Set --routing-key, and --routing-backend for GraphHopper. --routing-url points at a self-hosted instance. The key never leaves the server.

HTTPS with a tunnel

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

TunnelReachable from
app.sdrmm.comAny browser, behind your app.sdrmm.com login
TailscaleYour own devices only
Cloudflare TunnelAny browser, behind a login

app.sdrmm.com

No setup on the network. SDR-- dials out and app.sdrmm.com passes browsers through.

  1. Open Library → Remote and press Connect to app.sdrmm.com.
  2. Approve the code at app.sdrmm.com.
  3. Open the server from your device list there.

Headless: run sdrmm pair, approve the code, then restart sdrmm. Disconnect or removing the server in the app ends access. Traffic passes Cloudflare, which terminates HTTPS.

While connected, SDR-- reports its version, platform, open clients and each radio's name, driver and state to the app. Never frequencies, channel names or files.

Prepare SDR--

Run the tunnel on the same machine as SDR--. SDR-- 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, publish the port on loopback only and add the token file:

services:
  sdrmm:
    ports:
      - "127.0.0.1:8080:8080"
    env_file: .env

Inside the container SDR-- stays on 0.0.0.0:8080. Run the tunnel on the host, not in another container.

Tailscale

  1. Install Tailscale on the server and on each client, including your phone, 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 SDR-- 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 SDR-- 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 SDR-- 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 SDR-- does.

For field mode, open Library → Field from the HTTPS page so the link uses that address.

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.

Docker Compose

On Linux:

git clone https://github.com/Newspicel/sdrminusminus.git
cd sdrminusminus
docker compose up -d

Open http://<host>:8080. The service restarts on its own and keeps its database, recordings, and certificate under /data in the sdrmm-data volume. Keep and back up that volume. Use the :nightly tag 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 first. 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/sdrminusminus: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

Put the token in a .env file kept out of version control:

SDRMM_TOKEN=replace-with-a-long-random-secret
services:
  sdrmm:
    env_file: .env

HTTPS

The simplest option is a tunnel. To use your own certificate, 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. For a self-signed certificate, name the host clients use:

command: ["--bind", "0.0.0.0:8080", "--tls-self-signed", "--tls-name", "radio.example"]

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. Set SDRMM_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 the drop counter, 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.

Radios on other machines

A Device node can open network radios such as rtl_tcp, SpyServer, SDRconnect, and iiod directly. For SoapyRemote, run SoapySDRServer next to the radio and install SoapyRemote where SDR-- runs; the radio then appears in the normal list.

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
/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/channeltypes, /api/clients
Live receiver/api/devicesets: settings, channels, scanning, recording, playback
Workspaces/api/workspaces: activate, apply, undo, redo, export, import
Saved setups/api/templates, /api/presets, /api/bookmarks
Data/api/decoderlog, /api/recordings, /api/images, downloads
Reference/api/bandplan/regions, /api/about, /api/doctor

Errors are JSON with error and an optional detail.

WebSocket

The WebSocket carries commands, decoder events, scanner progress, and binary spectrum, audio, and video. When it says some state changed, fetch that state again through REST. Stream IDs belong to one connection. The web client in web/src is the reference implementation.

MCP

Point an MCP client at http://<server>:8080/mcp, with the bearer header if a token is set. Its tools open and tune radios, add and remove channels, scan, record, read decoded history, grab spectrum snapshots, and run the tools such as the NanoVNA.

Build and test

CI and local development run the same cargo xtask commands.

Prerequisites

ToolVersion
RustPinned in rust-toolchain.toml; rustup installs it
Node26
pnpm11, 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

rust-toolchain.toml pins stable Rust. 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/sdrminusminus.git
cd sdrminusminus
python3 scripts/build-media.py
export FFMPEG_DIR="$(python3 scripts/build-media.py --print-prefix)"
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.1 codecs SDR-- needs, from checksummed source, into .media/<target>. Keep FFMPEG_DIR set for every Cargo command. A system FFmpeg older than 9 does not compile. For cross builds, pass --target <triple> to the script. Nix uses its own FFmpeg.

The server embeds web/dist, so build the frontend first. Without it, backend-only builds get 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.

Windows

Build from a Visual Studio developer shell with LLVM and MSYS2 Make installed; the CI media action shows the setup. Set MEDIA_SHELL_BIN to the folders holding bash, make, and clang-cl. The script adds them 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, cr8, rtlsdr, hackrf, airspy, airspyhf, ad936x, net-client, and gpu-fft by default. Packaged releases use a subset.

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 list synthetic radios on the Device node: a four-lane coherent array, a test band and test transceivers.

Checks

CommandRuns
cargo xtask checkFormat, Clippy, frontend lint and type-check, release builds, generated-file drift
cargo xtask testRust and frontend tests on virtual devices
cargo xtask smokePlaywright against a real sdrmm process
cargo xtask perfDSP throughput and allocation gates
cargo xtask auditcargo-deny and RustSec advisories
cargo xtask desktopTauri compile check, no installers
cargo xtask sanitizeVendored C decoders under AddressSanitizer and UBSan
cargo xtask fuzzlibFuzzer on every decoder, channel settings, and the dPMR vocoder
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.

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/schema.d.ts
Dependenciescargo xtask licensesTHIRD_PARTY_NOTICES.md, embedded notices
web/pnpm-lock.yaml or a git dependency's revcargo xtask nix-hashHashes in packaging/nix/package.nix
Decoder reference signalscargo xtask fixturesSigMF files in fixtures/
Band-plan sourcescargo xtask bandplanEmbedded band plans
assets/icon.svgcargo xtask iconsDesktop and web icons
Demo scenes in web/e2e/scenes.tspnpm --dir web demo:recordsite/public/demo/
README screenshotscargo xtask screenshotsassets/screenshots/

nix-hash needs Nix on Linux, or runs in a nixos/nix container elsewhere. cargo xtask check catches stale output.

Desktop app

The Tauri app is outside the default workspace. On Linux it needs WebKitGTK. cargo xtask desktop checks that it compiles; Releases builds installers.

Hardware capture tests

These ignored tests measure loss on real, idle 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, rtlsdr, or both. Default rates are 20 MS/s for HackRF and 2.4 MS/s for RTL-SDR. The test fails on any loss unless told otherwise.

VariableDoes
SDRMM_CAPTURE_CHANNELS=8Channels per radio, default 4
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_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; 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 connected_rtl_counter_continuity -- --ignored --nocapture

The 8-bit counter cannot see losses of exact multiples of 256 bytes.

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 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 the Recording node
sdrmm-device-siggenSignals for the Signal generator node
sdrmm-device-virtualSynthetic radios for debug builds and tests
sdrmm-usb-streamBulk USB streaming shared by the native drivers
sdrmm-device-rtlsdrNative RTL-SDR driver
sdrmm-device-airspy, sdrmm-device-airspyhfNative Airspy drivers
sdrmm-device-hackrfNative HackRF driver
sdrmm-device-ad936xAntSDR, PlutoSDR and other AD936x boards, speaking iiod over Ethernet or USB
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-arrayAlready-open streams composed as logical lanes; no hardware opens
sdrmm-channelsAnalog demodulators, protocol decoders, their descriptors, and signal synthesis
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, and embedded assets

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 Tauri window. Both probe SoapySDR in a short-lived child process.

The dependency rules:

  • dsp does no I/O and depends on no project crate.
  • modem builds reusable modulation algorithms on dsp only.
  • channels depends on dsp, modem, and wire.
  • Measurement tooling lives in test-support crates, outside the application graph.

cargo xtask check enforces them.

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 still allocate for variable-size results.

Spectrum, audio, and video travel as binary WebSocket frames; browser audio is Opus. Decoder events are typed JSON. After a WebSocket invalidation, clients fetch durable state over REST.

cargo xtask perf measures DSP throughput, allocation, decoder searches, and publication.

Coherent processing

Every capture block carries the index of its first sample, so reported hardware gaps are visible. Coherent processing buffers each lane and works on the sample range all lanes share. After a gap it skips to the next shared index, then applies the calibrated delays and weights.

A beam is written to an ordinary capture ring, so channels, recorders, and scopes use it like any single-lane source.

An Array node combines streams that Device nodes already own. device-array exposes them as logical lanes. The engine forwards corrected IQ, coordinates tuning, and recovers members. The array never opens hardware itself.

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

The DVB-S2/S2X tables are generated from and checked against the ETSI PDF text by s2x_tables.py and dvbs2_spec_check.py in crates/modem-test-support/scripts/. dvbt_tables.py generates the DVB-T and DVB-T2 tables the same way. CI runs all three. The VL-SNR seed and Walsh-Hadamard rows were typed from the standard.

Tests catch transcription errors by checking independent properties: puncturing density, polynomial roots, published CRC values, and parity of encoded words.

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, 4,096 points15.6 µs201.6 µsCPU
Spectrum, 4 × 4,09661.9 µs178.4 µsCPU
Spectrum, 16 × 4,096251.5 µs222.4 µsCPU, margin too small
Spectrum, 4 × 65,5361.291 ms0.519 msGPU would win for larger displays
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
Radar correlation, 400,000 samples, 41 Doppler bins153.9 ms24.4 msGPU
Full radar interval171.4 ms41.2 msGPU correlation, CPU cancellation and detection

Both radar columns use the optimized CPU clutter canceller. That optimization alone, reusing delayed correlations, cut cancellation at 16,384 samples and 32 taps from 4.30 ms to 0.66 ms.

Raw medians and p95.

How it runs

The gpu-fft feature turns on GPU radar. Shaders are portable WGSL with 32-bit floats. Metal is verified on hardware; Vulkan shader parity runs in CI on Lavapipe. Software adapters are refused in production.

Radar splits its transforms to stay within 128 MiB of scratch memory. Without a usable GPU, or on a GPU error, the interval runs on the CPU instead, and errors are logged. The capture thread hands off preallocated jobs without waiting. Overload is counted; retunes and gaps discard stale work.

Spectra below 65,536 points always stay on the CPU. Larger ones use the GPU only if a startup benchmark shows it at least 20% faster. The batched spectrum and filter-bank shaders are benchmarks 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 coherent::radar::tests::benchmark_radar_pipeline

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 coherent::radar::acceleration::tests

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 used to repaint every display frame even when idle. They now repaint only when data, settings, size, or visibility change: at 30 rows/s on a 60 Hz display that halves the draws, 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 Tauri measurements.

Raw results: before, after, WebKit.

From web, with Chrome installed:

node scripts/benchmark-gpu.mjs
pnpm exec playwright install webkit
node scripts/benchmark-gpu.mjs --webkit

Release process

Tagged releases publish portable servers, desktop installers, signed update bundles, and container images. The nightly workflow updates the rolling nightly when main changes.

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 dist
cargo xtask 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 README and license files.

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 the Tauri CLI:

cargo install --locked tauri-cli
cargo xtask desktop --bundles dmg

Use deb,appimage on Linux and msi,nsis on Windows. 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.

AppImage builds need patchelf, xdg-utils, and GStreamer plugin packages. The bundle includes the installed plugins WebKit uses for audio.

Desktop updates

The app checks the latest stable GitHub release at startup. Update archives use a Tauri updater signature separate from platform code signing. Preserve the private updater key; installed clients trust its compiled public key.

Without a local signing key, the bundle task uses --no-sign. Those installers 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/sdrminusminus/releases/download/v1.2.3

Containers

Releases publish Linux amd64 and arm64 images:

ghcr.io/newspicel/sdrminusminus:<version>
ghcr.io/newspicel/sdrminusminus:latest

Nightlies update only :nightly. Smoke tests check the binary, SoapySDR modules, server startup, and embedded frontend. CI builds and smoke-tests both architectures.

Homebrew tap

The release workflow updates the sdrmm formula and sdrminusminus cask in Newspicel/homebrew-tap after publishing stable downloads:

cargo xtask homebrew-tap \
  --version 1.2.3 \
  --sums SHA256SUMS \
  --repo Newspicel/sdrminusminus \
  --out ../homebrew-tap

The generator checks required artifacts against SHA256SUMS. The tap job needs a writable HOMEBREW_TAP_TOKEN; without it, the job is skipped. Other release jobs continue.

Validate generator changes:

brew style newspicel/tap
brew audit --strict --online newspicel/tap/sdrmm
brew audit --strict --online --cask newspicel/tap/sdrminusminus

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, fixture, icon, and band-plan outputs.
  4. Validate hardware with the candidate package, including reconnect and recording.
  5. Confirm updater and platform signing credentials.
  6. Tag the reviewed commit 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.