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
- Install SDR--.
- Build your first receiver.
- Learn how nodes and wires fit together.
Find a guide
| Task | Guide |
|---|---|
| Connect a radio | Radios |
| Listen to a signal | Channels |
| Decode data | Decoders |
| Save and replay signals | Recording and playback |
| Run the radio somewhere else | Deployment |
| Use a phone in the field | Field mode |
| Fix a problem | Troubleshooting |
| 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.
| Installation | Best for |
|---|---|
| Desktop app | A radio on your computer |
| Portable server | A Raspberry Pi, home server, or remote receiver |
| Homebrew | macOS or Linux with Homebrew |
| WinGet | Windows |
| APT | Debian and Ubuntu |
| DNF | Fedora |
| AUR | Arch Linux |
| Nix | Linux managed with Nix |
| Container | Docker |
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.
| Platform | Package |
|---|---|
| macOS | .dmg for Apple silicon or Intel |
| Linux | .deb, .rpm or .AppImage for x86-64 or ARM64 |
| Windows | .msi or .exe for x86-64, .exe for ARM64 |
Portable server
Download and unpack the sdrmm archive for your system from the download page,
then run it:
./sdrmm
On Windows, run sdrmm.exe. Open http://localhost:8080 on the server, 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
AUR
yay -S sdrminusminus-bin # desktop app
yay -S sdrmm-bin # server
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.
The Nix package reaches local radios through SoapySDR. Pick the modules with soapyPlugins.
This NixOS example assumes the flake input is named sdrminusminus:
environment.systemPackages = [
(inputs.sdrminusminus.packages.${pkgs.stdenv.hostPlatform.system}.sdrmm.override {
soapyPlugins = with pkgs; [ soapyrtlsdr 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
- Plug in a radio and check Radios if it needs a driver.
- Build your first receiver.
- To build from source, see Build and test.
Your first receiver
Listen to a local FM station with an RTL-SDR. You need the receiver, an antenna, and 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 explains what you just built.
- Library → Templates sets up other receivers in one click.
- The decoder catalog lists every mode.
Nodes and wires
Everything in 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:
| Port | Carries | Typical wire |
|---|---|---|
iq | Raw radio samples | Device → channel, Scope, recorder |
audio | Demodulated sound | Channel → Audio FX, Speaker, Audio recorder |
events | Decoded messages | Channel → Readout, Decoder log, Map |
baseband | One channel's filtered IQ | Channel → Baseband scope, recorder, Network IQ |
video | Pictures and video | Channel → Video |
control | Tuning commands | Scanner, Satellite → channel |
position | Station location | GPS position → Map, Recorder, ADS-B |
Node types
| Group | Nodes |
|---|---|
| Sources | Device, Recording, Signal generator, GPS position |
| Decoders | AM, NFM, WFM, ADS-B, DMR, and every other decoder |
| Tools | Array, Scanner, Signal hunt, Spectrum monitor, Satellite, DMR trunk system, Event filter, Audio FX, Direction finder, Triangulation, Passive radar, Combiner |
| Outputs | Scope, 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, through SoapySDR, or over the network. Recordings and test 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:
| Radio | Needs |
|---|---|
| RTL-SDR | Nothing |
| KrakenSDR, KerberosSDR | Nothing |
| HackRF | Nothing |
| Airspy R2, Mini, HF+, HF+ Discovery | Nothing |
| AntSDR, ADALM-Pluto, other AD936x boards | The board serving iiod |
| SDRplay RSP1, RSP1A, RSP1B, RSP2, RSPduo, RSPdx, RSPdx-R2 | SDRplay API 3.15+, or SDRconnect on another machine |
| KiwiSDR | Network access to one |
| Dragon Labs CR-8 | Vendor library and a build with cr8 |
| bladeRF, LimeSDR, USRP, others | A SoapySDR module |
The Nix package uses SoapySDR for all local radios.
Making a radio? Write to hi@jhaag.me to get it supported and tested.
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.
Linux USB permissions
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.
SoapySDR
SoapySDR covers radios without a built-in driver. Install the core and a module for your radio:
| Radio | Module |
|---|---|
| bladeRF | SoapyBladeRF |
| LimeSDR | SoapyLMS7 |
| USRP | SoapyUHD |
| Remote SoapySDR server | SoapyRemote |
| System | Core | Example module |
|---|---|---|
| Debian, Ubuntu, Raspberry Pi OS | sudo apt install libsoapysdr0.8 | soapysdr-module-bladerf |
| Fedora | sudo dnf install SoapySDR | SoapySDR-bladeRF |
| Arch | sudo pacman -S soapysdr | soapybladerf |
| macOS | brew install soapysdr | soapybladerf |
| Windows | PothosSDR, on PATH | Included |
| NixOS | soapyPlugins |
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.
Modules must match SoapySDR 0.8. Others are rejected and logged. For unusual install locations:
| Variable | Value |
|---|---|
SDRMM_SOAPY_LIBRARY | Full path to the core library |
SDRMM_SOAPY_MODULE_PATH | Extra 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.
Network radios
On an empty Device node, open the Network tab and enter host:port:
| Protocol | Default port |
|---|---|
rtl_tcp | 1234 |
| SpyServer | 5555 |
| SDRconnect | 5454 |
| KiwiSDR | 8073 |
| AD936x / iiod | 30431 |
The address becomes the radio's identity in the workspace. A remote SoapySDRServer shows up in
the normal radio list instead, through SoapyRemote. Network IQ uses a lot of bandwidth: pick the
lowest rate that works and watch the drop counter.
The bookmark button next to Add saves an address. Saved radios are listed above the form on every empty Device node; click one to connect.
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.
Other sources
| Node | Gives |
|---|---|
| Recording | Plays a SigMF recording |
| Signal generator | Test signals in 44 modes, from a plain tone to DVB-T |
Debug builds also list synthetic radios: a four-lane coherent array and test transceivers.
Device controls
Controls mean the same thing on every radio:
| Control | Sets |
|---|---|
| Rate | Sample rate |
| Filter | Analog bandwidth before sampling, or Auto |
| Antenna | Input port, when there is a choice |
| AGC | Auto 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, Attenuator | One gain stage each, in dB or firmware steps |
| Amp | A switchable preamp |
| Bias tee | Power on the antenna port for an active antenna or LNA |
| PPM | Crystal correction |
| Converter | Local oscillator of an up- or downconverter, in MHz |
| DC block | Removes the radio's own DC spike |
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.
RTL-SDR
| Control | Does |
|---|---|
| Tuner | Gain, in the tuner's own steps: 20 dB on an R820T becomes 19.7 dB |
| AGC | Tuner AGC |
| Bias tee | Antenna-port power |
| Direct sampling | off, i, or q. Not on the RTL-SDR Blog V4 or V4 Lite, which upconvert HF. |
Rates: 225 to 300 kHz, or 900 kHz to 3.2 MHz. 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.
HackRF
| Control | Does |
|---|---|
| LNA | Gain in 8 dB steps |
| VGA | Gain in 2 dB steps |
| Amp | +14 dB RF amplifier |
| Filter | Baseband filter, or Auto |
| Bias tee | Antenna-port power |
Airspy
Built in, no vendor library needed. To use SoapySDR instead, build without airspy and airspyhf.
R2 and Mini: 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.
AntSDR, PlutoSDR and other AD936x boards
Talks to iiod directly over Ethernet or USB, with no libiio or SoapySDR. USB boards appear on their
own. Search also tries ant.local, 192.168.1.10, pluto.local, and 192.168.2.1. Enter
other addresses in the Network tab.
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 2.1 to 61.44 MS/s, limited by the link: USB 2.0 carries a few MS/s, gigabit Ethernet much more. On a 2×2 board both RX lanes share a clock and are phase coherent.
| Control | Does |
|---|---|
| Tuner | Receive gain per lane |
| TX | Transmit attenuation per lane |
| AGC | Off, slow attack, fast attack, or hybrid |
| Quadrature, RF DC, baseband DC tracking | Hardware corrections |
| FIR filter | Programmable decimating filter |
| Antenna, TX port | Receive and transmit ports |
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.
Both gain sliders raise gain when moved up:
| Slider | Sets |
|---|---|
| RF | LNA gain. The steps depend on frequency, port, and HDR mode. |
| IF | 0 to 39 dB |
AGC runs the IF gain at 5, 50, or 100 Hz. 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:
| Setting | Does |
|---|---|
lna | RF gain over the LNA states; lower means more gain. There is no IF gain. |
device_vfo_frequency | SDRconnect's VFO inside the sampled window |
filter_bandwidth | SDRconnect's channel filter |
receiver | Which radio: name, slot, or serial |
network_mode | Stream quality |
device_profile | Load a saved SDRconnect profile |
recording | Record on the SDRconnect machine |
The driver follows the public SDRplay API specification. No vendor code is included.
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.
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
- Press + Add and pick a mode.
- Wire Device
iqto the channel'siq. - Set the channel frequency.
- Wire the outputs you need:
| Output | Wire to | You get |
|---|---|---|
audio | Speaker | Live sound |
audio | Audio recorder | A WAV file |
audio | Audio FX | Filtered, denoised or levelled sound |
events | Readout | Current state: station text, aircraft table |
events | Decoder log | Message history |
events | Map | Positions |
events | Export | CSV or JSON of logged rows |
video | Video | ATV frames or SSTV pictures |
baseband | Baseband scope, recorder, or Network IQ | The channel's filtered IQ |
To swap the mode, right-click the channel and choose Replace with…. The frequency and squelch
stay; wires the new mode has no port for are dropped. m and M cycle 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.
| Mode | Opens |
|---|---|
| Off | Always |
| Manual | Above a fixed level |
| Auto | A 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:
| Setting | Behaviour |
|---|---|
| Detect | Shows the CTCSS tone or DCS code, never mutes |
| CTCSS | Opens only for the chosen tone |
| DCS | Opens only for the chosen code |
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:
| Stage | Does |
|---|---|
| De-click | Removes short clicks from the audio. |
| Passband | Cuts audio below and above two frequencies. |
| Notches | Removes up to four chosen tones, each with its own width. |
| Auto notch | Finds and removes steady tones. |
| Denoise | Spectral: 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. |
| AGC | Levels 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.
| Mode | Passes |
|---|---|
| Keep | Only matching events |
| Drop | Everything except matching events |
A rule that does not apply to an event is ignored: a talkgroup rule never judges an aircraft. A
drop filter with no rules drops nothing. Chain filters to combine them, for example keep POCSAG,
then drop messages containing TEST.
A filter only affects events that arrive after it is set. 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.
| Group | Tested on air | Fixture only | Experimental |
|---|---|---|---|
| Analog voice | AM, NFM, SSB, WFM with stereo and RDS | ||
| Digital voice | DMR, FreeDV 1600 | D-STAR, System Fusion, NXDN, P25 Phase 1, dPMR, M17 | |
| Aviation | ADS-B (1090ES) | ACARS, VDL Mode 2, HFDL, Inmarsat Classic Aero | VOR, ILS localizer and glideslope |
| Marine | AIS, NAVTEX, DSC, Inmarsat STD-C and EGC | ||
| Amateur and HF | CW skimmer, FT8, FT4, WSPR | APRS / AX.25, RTTY, PSK31 to PSK250, Morse | |
| Paging and telemetry | POCSAG | FLEX, ERMES, Selcall (CCIR, ZVEI), DCF77, WWVB, MSF, JJY | |
| Pictures and video | SSTV, ATV | ||
| Broadcast digital | DAB and DAB+ | DVB-T/T2, DATV (DVB-S/S2), DRM30 and DRM+ | |
| Utility | Signal identifier | Iridium bursts, DECT survey | GNSS lab (GPS L1 C/A) |
| Label | Means |
|---|---|
| Tested on air | Verified live, through a real radio and the full receiver |
| Fixture only | Verified on recordings, generated IQ, or reference vectors. Not yet verified live. |
| Experimental | Partly 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
| Mode | Works | Missing |
|---|---|---|
| DATV | DVB-S/S2/S2X, programme tables, audio, video, GSE | Verified on synthetic IQ only |
| DVB-T/T2 | DVB-T HP/LP, T2-Base and Lite, SISO/MISO, 1K to 32K, PLP choice, audio, video | Synthetic IQ only. No GSE, no multi-RF TFS. |
| DRM30 / DRM+ | Lock, SNR, frequency error | No FAC, SDC, or MSC. No services or audio. |
| GNSS lab | GPS L1 C/A acquisition and navigation data | No position fix |
| VOR / ILS | Radial, difference in depth of modulation | Tested 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.
| System | Needs |
|---|---|
| Linux | CAP_NET_ADMIN for the server |
| macOS | Permission to create a utun interface; name it like utun8 |
| Windows | Administrator rights and wintun.dll beside the executable |
Routing and multicast are up to your operating system.
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.
| System | Finds its channels by |
|---|---|
| Tier III, including Capacity Max | Reading channel definitions from the control channel |
| Capacity Plus | Watching for carriers that share rest-channel changes |
| Hytera XPT | Same 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.
| Setting | Does |
|---|---|
| Follow VIS | Reads the mode from the transmission |
| Manual mode | Uses the chosen mode when the header was missed |
| Slant correction | Straightens pictures from a slightly off clock. Leave it on. |
| Keep unfinished pictures | Saves 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.
| Setting | Choice |
|---|---|
| Band | Europe 1880 to 1900 MHz, or US 1920 to 1930 MHz |
| Side | Base, 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
| Node | Use it to |
|---|---|
| Scanner | Step through frequencies and stop on activity |
| Signal identifier | Name an unknown signal |
| Spectrum monitor | Catch and decode everything in the Device's window |
| Signal hunt | Walk towards a transmitter by signal strength |
| Signal survey | Map signal strength while you move |
Library → Occupancy shows how busy each frequency on the selected Device has been, hour by hour.
Scan
A Scanner drives one channel, never the radio. On auto tuning the radio follows the channel. Tuned by hand, the radio stays put and the scan skips targets outside its window.
- Add a channel in the mode you want to hear and wire it to a Speaker.
- Add Scanner and wire its
controlto the channel'scontrol. - Enter frequency ranges and choose a mode.
- Set the detection level and start.
| Mode | Stops on |
|---|---|
| Targets | A listed frequency above the threshold |
| Close call | The 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.
| Setting | Does |
|---|---|
| Record audio | Attaches an 8 kHz WAV clip. Long signals get a clip every 30 seconds. |
| Min confidence | Skips 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
- Add Signal survey and wire Device
iqand GPSpositionto it. - Pick an offset inside the Device's window and a measurement width.
- Wait for a level and a GPS fix, then start.
- 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
| Tab | Source |
|---|---|
| Receiver | A serial NMEA GPS on the server. Set the baud rate. |
| Network | gpsd, default 127.0.0.1:2947 |
| Fixed | Latitude and longitude you type in |
| This device | The 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
| Node | Uses position for |
|---|---|
| ADS-B | Decoding aircraft positions |
| Map | Your station, route, and a heatmap of visited places |
| Recorder | Location in the recording's metadata |
| Satellite | Pass and Doppler prediction |
| Signal survey | Where each measurement was taken |
| Direction finder, Passive radar, Propagation map | Placing 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
- Add GPS position. A fixed position is fine for a station that never moves.
- Add Satellite and wire GPS
positionto itsposition. - Search by name or NORAD number, or paste element lines.
- Pick a transmitter, or type the downlink.
- Wire Satellite
controlto each decoder'scontrol.
On auto tuning the radio follows the decoder, as it does for a scanner.
Readouts
| Readout | Shows |
|---|---|
| Look | Azimuth and elevation |
| Doppler | The shift being corrected, and how fast it changes |
| Send on | The Doppler-corrected uplink, if the transmitter has one |
| Next pass | Time until rise, or until set during a pass |
| Elements | Age 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
- Add FT8, FT4, or WSPR channels and a Propagation map.
- Wire each channel's
eventsto the map. - Wire GPS position to
position. A fixed position works.
On opening, the map loads the last six hours of logged decodes.
Layers
| Layer | Shows |
|---|---|
| Activity | Estimated reflection points, weighted by count and age |
| MUF | Estimated MUF lower bound per Maidenhead square |
| Paths | Great-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
| Node | Records | Wire from | Format |
|---|---|---|---|
| Recorder | The Device's full IQ | Device iq | SigMF |
| Baseband recorder | One channel's filtered IQ | Channel baseband | SigMF |
| Audio recorder | One channel's audio | Channel audio | 48 kHz 16-bit WAV |
| Time machine | IQ from before you pressed the button | Device iq | SigMF |
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:
- Wire Device
iqto Time machine, and GPSpositionif you have one. - Set how many seconds to keep and press Arm.
- Press Capture to save the buffer and keep recording live.
- 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.
| Node | Mode | For |
|---|---|---|
| Network IQ | UDP or TCP | GNU Radio and other raw IQ tools |
| Network IQ | rtl_tcp server | rtl_433 and other rtl_tcp clients |
| Event output | ADS-B Beast TCP | Flight 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
- Add Network IQ and wire one Device
iqlane or one channelbaseband. - Pick the protocol, encoding, and destination
host:port. - Start the receiving program, then press Start export.
- Set the receiver to the rate and centre frequency shown on the node.
Channel baseband sends only that channel, at its lower rate. The sample rate is locked during
export. 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:
| Encoding | Samples | Bytes per I/Q pair | GNU Radio type |
|---|---|---|---|
cf32_le | 32-bit float | 8 | Complex |
ci16_le | Signed 16-bit | 4 | Short, then Interleaved Short to Complex |
cu8 | Unsigned 8-bit, zero at 127.5 | 2 | RTL-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
- Tune the Device to 433.92 MHz.
- Wire Device
iqinto Network IQ and pick rtl_tcp server (rtl_433) on127.0.0.1:1234. - Press Start export and run rtl_433 with the rate and frequency shown:
rtl_433 -d rtl_tcp:127.0.0.1:1234 -s 1024000 -f 433920000 -F json
A channel's baseband works too if it covers the sensor. Commands from the client cannot retune
the radio; set that on the canvas. Up to eight clients can connect. A slow one is dropped without
affecting the others.
ADS-B Beast
- Wire the ADS-B channel's
eventsinto Event output. - Pick ADS-B Beast TCP, set
127.0.0.1:30005, and press Open server. - Point your feeder at that address.
The server sends Beast binary frames with 12 MHz timestamps and signal levels. Timestamps count samples, not GPS time, and restart after gaps. Use one ADS-B channel per output. Up to 16 clients can connect.
Workspaces and presets
| Holds | Use it to | |
|---|---|---|
| Workspace | Nodes, wires, rack, radio settings, band plan | Keep a whole receiver |
| Template | A ready-made receiver, built in | Start a common setup |
| Preset | A saved copy of a tuned workspace | Get back to a known state |
| Bookmark | A frequency and a name | Retune 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.
| Tool | Does |
|---|---|
| Antenna calculator | Dimensions for dipoles, folded and inverted-V dipoles, end-fed half-waves, ground planes, 5/8 verticals, J-poles, quad loops, and Yagis |
| NanoVNA | Sweeps an antenna over USB and shows SWR, impedance, and a Smith chart. Calibrates, and exports Touchstone files. |
| Radio programmer | Reads, 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 the reference in the app.
| Keys | Action |
|---|---|
| Left / Right | Tune down or up one step |
| Shift + Left / Right | Tune ten steps |
[ / ] | Choose a smaller or larger tuning step |
f | Focus the device dial; press Enter to type a frequency |
, / . | Select the previous or next channel |
m / M | Cycle the selected channel's analog mode forward or backward |
- / + or = | Lower or raise squelch by 2 dB |
s | Toggle squelch |
1–9 | Select the nth node in the patch |
p | Pin or unpin the selected node on the rack |
v | Switch between Patch and Rack |
Ctrl/⌘ Z | Undo the last workspace change, for every connected client |
Ctrl/⌘ Shift Z or Ctrl/⌘ Y | Redo |
Ctrl/⌘ C | Copy the selected nodes and the wires between them |
Ctrl/⌘ V | Paste them beside the originals |
| Backspace | Delete the selected node or wire |
? | Open the keyboard reference |
| Escape | Close 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-- readyin 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
| Value | Does |
|---|---|
auto | Default. Turns off DMABUF when the NVIDIA driver is loaded. |
safe | Turns off DMABUF and accelerated compositing. The waterfall may run slower. |
off | Changes 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
- Check that the operating system sees it.
- Run
sdrmm --doctorand fix any library or permission error. - Close other SDR programs that may hold it.
- For SoapySDR radios, run
SoapySDRUtil --findand check the module is built for 0.8. - 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
audioto 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
eventsto the right place: Decoder log for messages, Readout for current state, Map for positions. - Adjust gain. Watch for clipping and drops.
- Check the mode's maturity.
Drops and gaps
The drop counter on a Device counts samples lost anywhere between the radio and the decoders. Drops damage audio, spectrum, recordings, and decoding.
- Lower the sample rate and close channels and displays you do not need.
- 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/recordingsis on the persisted volume. - Stop the recording. Metadata is written on stop.
- Check each capture has both
.sigmf-metaand.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:
| Tier | Shared | Can do |
|---|---|---|
phase_coherent | Clock and local oscillator | Bearings, beamforming, combining, passive radar |
time_sync | Clock only | Passive radar. The rest needs calibration after every retune. |
none | Nothing | Independent 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.
- Add a Device for each receiver.
- Give them the same sample rate, and the same frequency if they share tuning.
- Wire each Device
iqto the Array. It grows an input per member. - Set Wired as to match: shared clock, or shared clock and local oscillator.
- 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 source | Needs |
|---|---|
| Signal | A strong signal every antenna receives |
| Noise | Noise 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.
| Mode | Does |
|---|---|
| Diversity | Aligns and adds the antennas. Two antennas gain about 3 dB SNR. |
| Cancel | Uses 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.
| Mode | Does |
|---|---|
| Auto | Tunes the lanes side by side with a small overlap. Tuning the wide lane moves them all. |
| Manual | Keeps 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
- Add a multi-lane Device or an Array.
- Add Direction finder. Set Geometry and Elements to match your antennas: a circle with a radius, a line with a spacing, or explicit positions.
- Wire every lane to the matching input. All must come from one source.
- Set Offset and Bandwidth to cover the signal.
- Calibrate.
- Wire GPS position for the map and triangulation.
| Algorithm | Use |
|---|---|
| Beamformer | Broad and robust |
| MUSIC | Sharper, 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
- Add Triangulation and wire each finder's
eventsto it. - 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
- Add a Device or Array with at least two time-synced lanes.
- Add Passive radar.
- Wire the antenna pointing at the transmitter to
ref. - Wire the antenna pointing at the area you watch to
surv. - Wire GPS position for the map.
| Setting | Does |
|---|---|
| Integration | Longer finds weaker echoes, but blurs moving ones |
| Range bins | How far out the display reaches |
| Doppler span | How 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:
| Mission | Needs | Shows |
|---|---|---|
| Fox hunt | Signal hunt | Level, rising or falling, clicks that speed up |
| DF drive | Direction finder | Compass, guidance, map |
| Radar watch | Passive radar | Range-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 mode | Drives to |
|---|---|
| Auto | A crossing point until the estimate settles, then the estimate |
| Direct | The current estimate |
| Off | Nowhere; 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
| Option | Default | Sets |
|---|---|---|
--bind <ADDRESS> | 0.0.0.0:8080 | Listen address |
--db <PATH> | Platform data folder | SQLite database |
--recordings-dir <PATH> | Platform data folder | Recording folder |
--token <TOKEN> | None | Shared access token |
--tls-cert <PATH>, --tls-key <PATH> | None | HTTPS certificate chain and key, PEM |
--tls-self-signed | Off | HTTPS with a self-signed certificate |
--tls-name <NAME> | Found addresses | Name the certificate must cover; repeatable |
--routing-backend <NAME> | open-route-service | Routing: open-route-service or graph-hopper |
--routing-url <URL> | Public service | Self-hosted routing instance |
--routing-key <KEY> | None | Routing API key |
--dev-cors | Off | Allow a separate frontend origin, for development only |
--doctor | Print diagnostics and exit | |
--doctor-rates | Probe connected radios' sample rates and exit |
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.
| Tunnel | Reachable from |
|---|---|
| Tailscale | Your own devices only |
| Cloudflare Tunnel | Any browser, behind a login |
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
-
Install Tailscale on the server and on each client, including your phone, all in the same tailnet.
-
In the admin console's DNS page, turn on MagicDNS and HTTPS Certificates. Machine names become public in certificate transparency logs.
-
On the server:
tailscale serve --bg --https=443 http://127.0.0.1:8080 tailscale serve status -
Open the printed
https://<machine>.<tailnet>.ts.netaddress and enter the 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.
- Add the login first. In Zero Trust → Access controls → Applications, create a
Self-hosted and private app for
radio.example.comwith no path. Add an Allow policy for your email or group and save. - In Networking → Tunnels → Create Tunnel, name it
sdrmmand follow Install and Run to installcloudflaredas a service on the SDR-- machine. Keep its tunnel token private. - When the tunnel is Healthy, add a Published application route: hostname
radio.example.com, no path, servicehttp://127.0.0.1:8080. - Open
https://radio.example.com, log in, then enter the 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/wsconnection in the browser's developer tools. - Bad gateway: check
http://127.0.0.1:8080works on the tunnel machine. - Cloudflare: open a private window and check the login appears before 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:
- Save the
sdrmm --doctorreport. - Stream for 30 minutes and check the drop counter, audio, and spectrum.
- Try tuning, gain, rate, and every control you plan to use.
- Unplug and replug the radio. The workspace should pick it up again.
- Record a short capture and play it back.
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.
| Endpoint | Serves |
|---|---|
/api/docs | Swagger UI |
/api/openapi.json | OpenAPI schema |
/api/ws | WebSocket |
/mcp | MCP over streamable HTTP |
The OpenAPI schema is also in the repository, for generating clients without a running server.
With a token set, send it on every request:
curl -H "Authorization: Bearer $SDRMM_TOKEN" http://receiver.local:8080/api/state
REST
| Area | Routes |
|---|---|
| State and discovery | /api/state, /api/devices, /api/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
| Tool | Version |
|---|---|
| Rust | Pinned in rust-toolchain.toml; rustup installs it |
| Node | 26 |
| pnpm | 11, exact version in web/package.json |
| FFmpeg | 9, built by scripts/build-media.py |
| Native | C/C++ compiler, Clang/libclang, CMake, GNU Make, NASM, Python 3.12+ |
sudo apt-get install -y build-essential cmake clang libclang-dev python3 nasm # Debian, Ubuntu
brew install cmake python nasm # macOS
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 and test transceivers.
Checks
| Command | Runs |
|---|---|
cargo xtask check | Format, Clippy, frontend lint and type-check, release builds, generated-file drift |
cargo xtask test | Rust and frontend tests on virtual devices |
cargo xtask smoke | Playwright against a real sdrmm process |
cargo xtask perf | DSP throughput and allocation gates |
cargo xtask audit | cargo-deny and RustSec advisories |
cargo xtask desktop | Tauri compile check, no installers |
cargo xtask sanitize | Vendored C decoders under AddressSanitizer and UBSan |
cargo xtask fuzz | libFuzzer 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 change | Run | Updates |
|---|---|---|
| REST routes or wire types | cargo xtask codegen | openapi.json, web/src/generated/schema.d.ts |
| Dependencies | cargo xtask licenses | THIRD_PARTY_NOTICES.md, embedded notices |
web/pnpm-lock.yaml or a git dependency's rev | cargo xtask nix-hash | Hashes in packaging/nix/package.nix |
| Decoder reference signals | cargo xtask fixtures | SigMF files in fixtures/ |
| Band-plan sources | cargo xtask bandplan | Embedded band plans |
assets/icon.svg | cargo xtask icons | Desktop and web icons |
Demo scenes in web/e2e/scenes.ts | pnpm --dir web demo:record | site/public/demo/ |
| README screenshots | cargo xtask screenshots | assets/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.
| Variable | Does |
|---|---|
SDRMM_CAPTURE_CHANNELS=8 | Channels per radio, default 4 |
SDRMM_CAPTURE_MIXED=1 | Cycle NFM, WFM, AM, and SSB |
SDRMM_CAPTURE_RETUNE=1 | Retune channels every 5 s |
SDRMM_CAPTURE_DEVICE_RETUNE=1 | Retune radios every 5 s |
SDRMM_CAPTURE_RTL_RATE=3200000 | Override the RTL-SDR rate only |
SDRMM_CAPTURE_CPU_THREADS=4 | Add CPU load threads |
SDRMM_CAPTURE_RECORD=1 | Record IQ and verify sample counts |
SDRMM_CAPTURE_HISTORY=1 | Capture history, then record live, and verify |
SDRMM_CAPTURE_HISTORY_SECONDS=6 | History length, default 1 s |
SDRMM_CAPTURE_TRANSPORT_SECONDS=5 | Raw USB test length per radio; 0 skips it |
SDRMM_CAPTURE_ALLOW_DROPS=1 | Measure overload instead of failing |
Software counters miss some USB losses. For RTL-SDR, also check the hardware byte counter:
SDRMM_RTL_TEST_RATE=3200000 SDRMM_RTL_TEST_SECONDS=60 \
cargo test -p sdrmm-device-rtlsdr --lib 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
| Crate | Responsibility |
|---|---|
sdrmm-dsp | Allocation-free signal-processing primitives; no I/O or internal project dependencies |
sdrmm-modem | Reusable modem algorithms depending only on DSP |
sdrmm-modem-test-support | Modem measurement catalogs, simulations, and baseline tooling; tests and developer tools only |
sdrmm-wire | Shared settings, DTOs, events, patch graph, and OpenAPI schemas |
sdrmm-device | Hardware-independent device traits, capabilities, settings, and registry |
sdrmm-device-recording | SigMF playback behind the Recording node |
sdrmm-device-siggen | Test signals behind the Signal generator node |
sdrmm-device-virtual | Synthetic radios for debug builds and tests |
sdrmm-usb-stream | Bulk USB streaming shared by the native drivers |
sdrmm-device-rtlsdr | Native RTL-SDR driver |
sdrmm-device-airspy, sdrmm-device-airspyhf | Native Airspy drivers |
sdrmm-device-hackrf | Native HackRF driver |
sdrmm-device-ad936x | AntSDR, PlutoSDR and other AD936x boards, speaking iiod over Ethernet or USB |
sdrmm-device-soapy | Local hardware through SoapySDR |
sdrmm-device-sdrplay | SDRplay RSP receivers through the vendor API, loaded at runtime |
sdrmm-device-rtltcp | Direct rtl_tcp client |
sdrmm-device-spyserver | Direct SpyServer client |
sdrmm-device-sdrconnect | SDRplay SDRconnect over its WebSocket API |
sdrmm-device-kiwisdr | KiwiSDR over its WebSocket API |
sdrmm-device-cr8 | Dragon Labs CR-8 through the vendor SDK, loaded at runtime |
sdrmm-device-array | Already-open streams composed as logical lanes; no hardware opens |
sdrmm-channels | Analog demodulators, protocol decoders, and their descriptors |
sdrmm-recorder | SigMF writing, reading, scanning, and export |
sdrmm-orbit | SGP4, pass prediction, and Doppler |
sdrmm-tools | Antenna calculator and NanoVNA |
sdrmm-cps | Codeplug reading, writing, and conversion |
sdrmm-test-support | Allocation and timing helpers for tests |
sdrmm-engine | Device supervision, channelization, scanning, streams, recording, and state snapshots |
sdrmm-server | REST, WebSocket, MCP, persistence, band plans, auth, 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:
dspdoes no I/O and depends on no project crate.modembuilds reusable modulation algorithms ondsponly.channelsdepends ondsp,modem, andwire.- 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
| Layer | Tested with |
|---|---|
| DSP | Analytic and golden vectors, allocation and throughput gates |
| Decoders | Recorded IQ with expected output, generated vectors |
| Engine | End-to-end runs on virtual devices |
| Server | Handlers, persistence, streams, auth, OpenAPI, codegen drift |
| Client | Unit tests and browser smoke flows |
Test at the narrowest layer that proves the behaviour. Add end-to-end coverage when a change crosses layers. CI never touches real radios.
Tables from standards
Some decoder constants are copied from the standards:
| Constants | File |
|---|---|
| DAB puncturing and protection profiles | crates/channels/src/dab/protection.rs |
| DAB phase reference | crates/channels/src/dab/ofdm.rs |
| DVB-S puncturing and Reed-Solomon parameters | crates/channels/src/datv/dvbs.rs |
| DVB-S2 LDPC accumulator addresses | crates/channels/src/datv/dvbs2/tables/ |
| DVB-S2X LDPC addresses, constellations and interleavers | crates/channels/src/datv/dvbs2/s2x/ |
| VL-SNR header sequence | crates/channels/src/datv/dvbs2/vlsnr.rs |
| DVB-T continual pilot and TPS carriers | crates/channels/src/datv/dvbt/en300744.rs |
| DVB-T2 pilots, reserved carriers, P1, L1 and LDPC tables | crates/channels/src/datv/dvbt/t2/en302755/ |
Sources: ETSI EN 300 401 (DAB), TS 102 563 (DAB+), EN 300 421 (DVB-S), EN 302 307-1 and -2 (DVB-S2/S2X), EN 300 744 (DVB-T), EN 302 755 (DVB-T2), TS 102 606 (GSE), and ES 201 980 (DRM).
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.
| Work | CPU median | GPU median | Runs on |
|---|---|---|---|
| Spectrum, 4,096 points | 15.6 µs | 201.6 µs | CPU |
| Spectrum, 4 × 4,096 | 61.9 µs | 178.4 µs | CPU |
| Spectrum, 16 × 4,096 | 251.5 µs | 222.4 µs | CPU, margin too small |
| Spectrum, 4 × 65,536 | 1.291 ms | 0.519 ms | GPU would win for larger displays |
| Filter bank, 13 bands, 8,192 samples | 79.5 µs | 177.3 µs | CPU |
| Filter bank, 13 bands, 32,768 samples | 395.4 µs | 222.6 µs | Larger than any real block |
| Radar correlation, 400,000 samples, 41 Doppler bins | 153.9 ms | 24.4 ms | GPU |
| Full radar interval | 171.4 ms | 41.2 ms | GPU 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.
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 draw | Software draw |
|---|---|---|
| 640 × 240 | 0.076 ms | 2.236 ms |
| 1280 × 720 | 0.246 ms | 12.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
- Run
cargo xtask check,test,smoke, andaudit. - Run
cargo xtask desktopand build the container. - Check generated API, license, fixture, icon, and band-plan outputs.
- Validate hardware with the candidate package, including reconnect and recording.
- Confirm updater and platform signing credentials.
- Tag the reviewed commit and check every artifact job.
- Install a published artifact and run
sdrmm --versionandsdrmm --doctor.
Manual workflow dispatch rehearses the artifact matrix without publishing a GitHub release.