Beginner · Lesson 10 of 10

Recording and reading IQ files

Raw IQ sample formats from cf32 to cu8, SigMF metadata, writing and reading a synthetic capture in numpy, finding a burst in its spectrogram, and recording from a real SDR with SoapySDR

Beginner45 minUpdated 15 Nov 2026Free until 31 Jan 2027

You will learn

  • Name the common raw IQ formats (interleaved float32, int16, int8 and offset uint8), give their bytes per complex sample, and convert each to complex64 in numpy
  • Write a SigMF recording with the required global, captures and annotations objects and read it back using only the metadata
  • Measure the quantisation noise that 16-bit and 8-bit storage add to a capture and see it in the spectrum
  • Locate a burst in a spectrogram and store its time and frequency extent as a SigMF annotation
  • Record samples from an SDR with SoapySDR and save them as a SigMF recording

Before this: Your first GNU Radio flowgraph

Every software radio ends up writing samples to disk. You record a capture in the field, analyse it at your desk, share it with a colleague, or replay it into a receiver you are debugging. A file of raw samples is useless without three facts: how each sample is stored, the sample rate, and the centre frequency. This lesson is for readers who have run the flowgraph of lesson 9 and want to keep what their radio produces. We cover the raw formats you will meet, the SigMF metadata standard that records the missing facts, a synthetic capture written and read back in numpy, and a real recording with SoapySDR.

What a raw IQ file holds

An IQ file is a sequence of complex baseband samples with nothing else in it: no header, no sample rate, no frequency. Each sample is two numbers, in-phase (I) and quadrature (Q), stored one after the other. The universal convention is I first, then Q: I0, Q0, I1, Q1 and so on. What varies is the number type.

Format Common names Bytes per complex sample Values Notes
32-bit float cf32, SigMF cf32_le, SoapySDR CF32, numpy complex64 8 about ±1.0 for full scale No quantisation to worry about. Large files
16-bit signed integer cs16, SigMF ci16_le, SoapySDR CS16 4 −32768 to 32767 Enough for 12-bit and 14-bit converters
8-bit signed integer cs8, SigMF ci8, SoapySDR CS8 2 −128 to 127 Small files. Quantisation noise about 50 dB below full scale
8-bit unsigned (offset binary) cu8, SigMF cu8, SoapySDR CU8 2 0 to 255, zero at 127.5 Subtract the offset before use

Three things trip people up. First, numpy names complex types by the total width of the pair, so complex64 is two float32 values. SigMF and SoapySDR name them by the width of each component, so cf32 is the same thing. The SigMF specification points out this difference explicitly. Second, multi-byte formats have an endianness. Almost every machine you will use is little-endian, and SigMF spells it out in the type name (_le). Third, integer formats have no defined full scale. Whether 32767 means 1.0 or 0.5 is a convention of the software that wrote the file. Our code below maps the largest integer to 1.0, which is the usual choice.

cf32_leI0: float32, 4 bytesQ0: float32, 4 bytesI1: float32Q1: float32ci16_leI0 int16Q0 int16I1 int16Q1 int164 bytes per samplecu8I0Q0I1Q12 bytes per sample, unsigned, zero at 127.5file offset increases to the right (one box width = 4 bytes in cf32, 2 in ci16, 1 in cu8)
Byte layout of the first two complex samples in three raw IQ formats. Every format interleaves I and Q, in-phase first. Only the width and type of each number change.

SigMF: the metadata that makes a file usable

The Signal Metadata Format (SigMF) is an open specification maintained on GitHub. It stores a recording as two files with the same base name. The dataset file, with extension .sigmf-data, holds the raw samples and nothing else. The metadata file, with extension .sigmf-meta, is JSON. At the time of writing the current release of the specification is v1.2.6.

The metadata has three top-level objects, all required by the schema:

  • global describes the whole recording. It must contain core:datatype (for example cf32_le) and core:version (the SigMF version used). It usually contains core:sample_rate in samples per second, and may add core:description, core:author, core:hw for the hardware and core:recorder for the software.
  • captures is a list of segments. Each must have core:sample_start, the sample index where the segment begins. The first segment normally adds core:frequency, the centre frequency in Hz, and core:datetime, an RFC 3339 timestamp in UTC ending in Z.
  • annotations is a list, possibly empty, that marks regions of interest. Each needs core:sample_start and usually has core:sample_count, core:freq_lower_edge, core:freq_upper_edge and core:label. The two frequency edges must be given together, and the specification asks for them at RF when the centre frequency is known.

The datatype string follows a small grammar: c or r for complex or real, then f32, f64, i32, i16, u32 or u16 followed by _le or _be, or one of the byte types i8 and u8 with no endianness. So cf32_le, ci16_le and cu8 are all valid, and cs16 is not a SigMF name even though SoapySDR uses CS16 for the same layout.

A synthetic capture, three ways

The block builds 65.5 ms of a 2 MHz complex capture: a tone at +300 kHz, a QPSK burst at −500 kHz that is on from 30 % to 70 % of the capture, and a low noise floor. It writes the same signal as cf32_le, ci16_le and cu8, each with its own metadata file. The reader takes the datatype and sample rate from the metadata only, converts to complex64 and estimates the spectrum.

import json
from pathlib import Path

import numpy as np
from scipy import signal

rng = np.random.default_rng(10)
fs = 2.0e6                       # sample rate, 2 MHz complex
fc = 100.0e6                     # centre frequency we record in the metadata (the signal is synthetic)
n = 2**17                        # 131 072 samples, 65.5 ms
t = np.arange(n) / fs

# A synthetic capture (complex128 while we build it): a tone, a QPSK burst and a little noise.
tone = 0.3 * np.exp(2j * np.pi * 300e3 * t)
sps = 20                                                     # 100 kbaud at 2 MHz
n_sym = -(-n // sps)                                         # enough symbols to cover n samples
syms = (rng.choice([-1, 1], n_sym) + 1j * rng.choice([-1, 1], n_sym)) / np.sqrt(2)
lowpass = signal.firwin(401, 70e3, window=("kaiser", 10.0), fs=fs)     # sharp, low-sidelobe filter
shaped = signal.lfilter(lowpass, 1.0, np.repeat(syms, sps)[:n])
burst = 0.4 * shaped / np.std(shaped) * np.exp(-2j * np.pi * 500e3 * t)
gate = np.zeros(n)
gate[int(0.3 * n) : int(0.7 * n)] = 1.0                      # burst on from 30 % to 70 % of the capture
burst *= np.convolve(gate, np.hanning(401) / np.hanning(401).sum(), mode="same")   # soft edges
noise = 1e-4 * (rng.normal(size=n) + 1j * rng.normal(size=n)) / np.sqrt(2)
x = tone + burst + noise
print(f"peak |I| or |Q|: {max(np.abs(x.real).max(), np.abs(x.imag).max()):.3f} (full scale is 1.0)")

# How each SigMF datatype is stored: numpy dtype per rail, scale to full scale, offset of zero.
FORMATS = {
    "cf32_le": ("<f4", 1.0, 0.0),
    "ci16_le": ("<i2", 32767.0, 0.0),
    "cu8": ("u1", 127.5, 127.5),
}

def write_sigmf(base: str, z: np.ndarray, datatype: str) -> None:
    dt, scale, offset = FORMATS[datatype]
    iq = np.empty(2 * z.size)
    iq[0::2], iq[1::2] = z.real, z.imag                      # interleave, in-phase first
    raw = iq * scale + offset
    if dt != "<f4":
        info = np.iinfo(np.dtype(dt))
        raw = np.clip(np.round(raw), info.min, info.max)
    raw.astype(dt).tofile(f"{base}.sigmf-data")
    meta = {
        "global": {"core:datatype": datatype, "core:sample_rate": fs, "core:version": "1.2.6",
                   "core:description": "Synthetic tone and QPSK burst for Wireless Field Guide"},
        "captures": [{"core:sample_start": 0, "core:frequency": fc, "core:datetime": "2026-11-15T09:00:00Z"}],
        "annotations": [],
    }
    Path(f"{base}.sigmf-meta").write_text(json.dumps(meta, indent=2))

def read_sigmf(base: str) -> tuple[np.ndarray, dict]:
    meta = json.loads(Path(f"{base}.sigmf-meta").read_text())
    dt, scale, offset = FORMATS[meta["global"]["core:datatype"]]
    raw = np.fromfile(f"{base}.sigmf-data", dtype=dt).astype(np.float32)
    iq = (raw - offset) / scale
    return (iq[0::2] + 1j * iq[1::2]).astype(np.complex64), meta

psd = {}
for dtype in FORMATS:
    base = f"capture_{dtype}"
    write_sigmf(base, x, dtype)
    y, meta = read_sigmf(base)
    size_kib = Path(f"{base}.sigmf-data").stat().st_size / 1024
    f, p = signal.welch(y, fs=meta["global"]["core:sample_rate"], window="blackmanharris", nperseg=1250, detrend=False, return_onesided=False,
                        scaling="spectrum")
    f, p = np.fft.fftshift(f), np.fft.fftshift(p)
    psd[dtype] = 10 * np.log10(p.reshape(50, 25).mean(axis=1))   # 50 points, 40 kHz apart
    quiet = (f > -200e3) & (f < 100e3)                         # a band with nothing in it
    err = 20 * np.log10(np.sqrt(np.mean(np.abs(y - x) ** 2)))
    print(f"{dtype:8s} {size_kib:7.0f} KiB, rms error vs original {err:6.1f} dB, "
          f"mean level, -200 to +100 kHz: {10 * np.log10(np.mean(p[quiet])):6.1f} dB")

fc_bins = f.reshape(50, 25).mean(axis=1)
print("freq kHz, cf32_le dB, ci16_le dB, cu8 dB")
for i in range(50):
    print(f"{fc_bins[i] / 1e3:.0f}, {psd['cf32_le'][i]:.1f}, {psd['ci16_le'][i]:.1f}, {psd['cu8'][i]:.1f}")
peak |I| or |Q|: 0.712 (full scale is 1.0)
cf32_le     1024 KiB, rms error vs original -159.6 dB, mean level, -200 to +100 kHz: -108.0 dB
ci16_le      512 KiB, rms error vs original  -98.1 dB, mean level, -200 to +100 kHz: -107.9 dB
cu8          256 KiB, rms error vs original  -49.6 dB, mean level, -200 to +100 kHz:  -76.3 dB
freq kHz, cf32_le dB, ci16_le dB, cu8 dB
-981, -108.0, -107.9, -80.0
-941, -108.0, -107.9, -80.3
-901, -108.2, -108.1, -67.8
-861, -108.0, -108.0, -80.1
-821, -107.9, -107.8, -80.2
-781, -107.8, -107.7, -80.1
-741, -107.9, -107.8, -80.2
-701, -107.9, -107.8, -80.1
-661, -107.8, -107.7, -80.2
-621, -107.9, -107.8, -80.0
-581, -41.6, -41.6, -41.6
-541, -28.8, -28.8, -28.8
-501, -26.5, -26.5, -26.5
-461, -28.5, -28.5, -28.5
-421, -40.2, -40.2, -40.2
-381, -107.7, -107.7, -80.1
-341, -107.8, -107.8, -80.2
-301, -107.8, -107.8, -80.3
-261, -107.8, -107.8, -80.3
-221, -108.1, -108.0, -80.2
-181, -108.0, -107.9, -80.1
-141, -107.9, -107.8, -80.4
-101, -107.9, -107.8, -69.5
-61, -108.0, -108.0, -80.1
-21, -108.2, -108.1, -80.3
19, -107.9, -107.9, -80.2
59, -107.9, -107.8, -80.0
99, -107.8, -107.8, -80.1
139, -108.1, -108.0, -80.1
179, -107.8, -107.7, -80.2
219, -107.9, -107.8, -80.2
259, -107.9, -107.9, -80.3
299, -21.4, -21.4, -21.4
339, -107.8, -107.7, -80.3
379, -107.9, -107.8, -80.1
419, -107.9, -107.8, -80.2
459, -107.8, -107.8, -80.2
499, -107.8, -107.7, -80.3
539, -108.1, -108.1, -80.1
579, -107.9, -107.9, -80.3
619, -107.8, -107.8, -80.0
659, -108.0, -107.9, -80.3
699, -107.8, -107.7, -69.6
739, -108.0, -107.9, -80.2
779, -107.9, -107.8, -80.1
819, -107.8, -107.7, -80.1
859, -107.9, -107.9, -80.2
899, -107.9, -107.9, -80.1
939, -108.1, -108.1, -80.2
979, -108.0, -107.9, -80.0

The file sizes halve with each step down: 1024 KiB, 512 KiB and 256 KiB for 131 072 samples. The price is quantisation noise. A uniform quantiser with step Δ adds noise of variance Δ²/12 per rail. For 16 bits scaled to ±1 that is far below anything else in the capture. The int16 copy differs from the original by −98.1 dB, and its spectrum is indistinguishable from the float one. For 8 bits the error is −49.6 dB. In the empty part of the band, between −200 kHz and +100 kHz, the mean level rises from −108.0 dB to −76.3 dB.

-100-80-60-40-20-5000500cf32_leci16_lecu8power per bin (dB)frequency offset (kHz)
Spectrum of the synthetic 2 MHz capture after storage as cf32_le, ci16_le and cu8, read back through the SigMF metadata. Each point is the mean over 40 kHz. The tone at +300 kHz and the QPSK burst near −500 kHz are the same in all three. The cu8 copy has a noise floor near −80 dB and spurs that the other two do not. Values from the first code block.

The cu8 trace shows more than a raised floor. Spurs appear near −901 kHz, −101 kHz and +699 kHz, reaching −67.8 dB. Rounding to 256 levels is a non-linear operation, and with a strong tone in the input the error is not purely random: it contains harmonics and intermodulation products of the signals. On a real 8-bit receiver these spurs are easy to mistake for real signals. Two habits help. Keep the gain such that the strongest signal uses most of the range without clipping, and when a spur appears, change the gain or the tuning and see whether it moves the way a real signal would.

Finding a burst and annotating it

A spectrogram is a sequence of short spectra, one per time slice. It is the quickest way to see when a signal is present. The next block rebuilds the same capture (each block on this site runs on its own), writes it as cf32_le, computes a spectrogram with 256-point slices, sums the power in the 400 kHz to 600 kHz band below centre, and finds where it rises. It then records the result as a SigMF annotation with the edges at RF, since the capture metadata gives a centre frequency.

import json
from pathlib import Path

import numpy as np
from scipy import signal

# Rebuild the same capture as before and save it as cf32_le SigMF.
rng = np.random.default_rng(10)
fs, fc, n = 2.0e6, 100.0e6, 2**17
t = np.arange(n) / fs
sps = 20
n_sym = -(-n // sps)
syms = (rng.choice([-1, 1], n_sym) + 1j * rng.choice([-1, 1], n_sym)) / np.sqrt(2)
lowpass = signal.firwin(401, 70e3, window=("kaiser", 10.0), fs=fs)
shaped = signal.lfilter(lowpass, 1.0, np.repeat(syms, sps)[:n])
gate = np.zeros(n)
gate[int(0.3 * n) : int(0.7 * n)] = 1.0
gate = np.convolve(gate, np.hanning(401) / np.hanning(401).sum(), mode="same")
x = (0.3 * np.exp(2j * np.pi * 300e3 * t)
     + 0.4 * shaped / np.std(shaped) * np.exp(-2j * np.pi * 500e3 * t) * gate
     + 1e-4 * (rng.normal(size=n) + 1j * rng.normal(size=n)) / np.sqrt(2))
x.astype(np.complex64).tofile("burst.sigmf-data")           # complex64 is interleaved float32, I first
meta = {
    "global": {"core:datatype": "cf32_le", "core:sample_rate": fs, "core:version": "1.2.6"},
    "captures": [{"core:sample_start": 0, "core:frequency": fc}],
    "annotations": [],
}

# Read it back the way any tool would: datatype and sample rate come from the metadata.
y = np.fromfile("burst.sigmf-data", dtype=np.complex64)
fs_r = meta["global"]["core:sample_rate"]
f, tt, s = signal.spectrogram(y, fs=fs_r, window="blackmanharris", nperseg=256, noverlap=0,
                              detrend=False, return_onesided=False, scaling="spectrum")
f = np.fft.fftshift(f)
s = np.fft.fftshift(s, axes=0)
print(f"spectrogram: {f.size} frequency bins of {fs_r / f.size / 1e3:.2f} kHz, {tt.size} time slices of {256 / fs_r * 1e6:.0f} us")

# Find the burst: power in the 400-600 kHz band below the centre, slice by slice.
band = (f >= -600e3) & (f <= -400e3)
p_band = 10 * np.log10(s[band].sum(axis=0))
on = p_band > p_band.max() - 20                              # within 20 dB of the strongest slice
first, last = np.argmax(on), on.size - 1 - np.argmax(on[::-1])
start = int(first * 256)
count = int((last + 1) * 256) - start
meta["annotations"].append({
    "core:sample_start": start,
    "core:sample_count": count,
    "core:freq_lower_edge": fc - 600e3,                       # at RF, because the capture has a known centre
    "core:freq_upper_edge": fc - 400e3,
    "core:label": "QPSK burst",
})
Path("burst.sigmf-meta").write_text(json.dumps(meta, indent=2))
print(f"burst found from sample {start} to {start + count} ({start / fs_r * 1e3:.2f} ms to {(start + count) / fs_r * 1e3:.2f} ms)")
print(f"true burst edges: {0.3 * n / fs_r * 1e3:.2f} ms to {0.7 * n / fs_r * 1e3:.2f} ms")
print("band power over time (every 10th slice): t ms, dB")
for k in range(0, tt.size, 10):
    print(f"{tt[k] * 1e3:.2f}, {p_band[k]:.1f}")
print(Path("burst.sigmf-meta").read_text())
spectrogram: 256 frequency bins of 7.81 kHz, 512 time slices of 128 us
burst found from sample 39168 to 91904 (19.58 ms to 45.95 ms)
true burst edges: 19.66 ms to 45.88 ms
band power over time (every 10th slice): t ms, dB
0.06, -87.7
1.34, -88.7
2.62, -86.3
3.90, -87.5
5.18, -86.1
6.46, -86.2
7.74, -89.3
9.02, -87.9
10.30, -86.5
11.58, -88.7
12.86, -87.2
14.14, -85.0
15.42, -86.0
16.70, -87.6
17.98, -86.0
19.26, -87.2
20.54, -5.3
21.82, -5.1
23.10, -5.1
24.38, -5.0
25.66, -4.8
26.94, -4.7
28.22, -4.7
29.50, -4.9
30.78, -4.5
32.06, -5.0
33.34, -5.1
34.62, -5.1
35.90, -4.8
37.18, -4.8
38.46, -4.9
39.74, -5.0
41.02, -5.1
42.30, -5.0
43.58, -4.8
44.86, -5.2
46.14, -88.0
47.42, -91.3
48.70, -87.1
49.98, -86.9
51.26, -86.0
52.54, -87.7
53.82, -86.8
55.10, -88.9
56.38, -86.1
57.66, -86.8
58.94, -87.8
60.22, -88.6
61.50, -86.9
62.78, -86.9
64.06, -88.0
65.34, -86.3
{
  "global": {
    "core:datatype": "cf32_le",
    "core:sample_rate": 2000000.0,
    "core:version": "1.2.6"
  },
  "captures": [
    {
      "core:sample_start": 0,
      "core:frequency": 100000000.0
    }
  ],
  "annotations": [
    {
      "core:sample_start": 39168,
      "core:sample_count": 52736,
      "core:freq_lower_edge": 99400000.0,
      "core:freq_upper_edge": 99600000.0,
      "core:label": "QPSK burst"
    }
  ]
}

The detector finds the burst from 19.58 ms to 45.95 ms. The true burst edges, before the soft ramps we applied, are 19.66 ms and 45.88 ms. The detected region is slightly wider because the threshold is 20 dB below the peak and the 128 µs slices round outwards. The annotation stores this as sample 39 168 with a count of 52 736, and the frequency edges as 99.4 MHz and 99.6 MHz.

-80-60-40-20204060band power (dB)time (ms)
Power in the band from 400 kHz to 600 kHz below centre, per 128 µs spectrogram slice, every tenth slice shown. The QPSK burst raises the band power by about 80 dB between 20 ms and 46 ms. Values from the second code block.

Annotations are what make a shared recording useful. A colleague, or a script, can jump straight to the labelled bursts without scanning the whole file.

Recording from a real radio

The final block records from an SDR through SoapySDR, which describes itself as a vendor and platform neutral SDR support library. It has Python bindings, and each family of radios is reached through its own SoapySDR support module. This block needs an SDR connected to your computer and the SoapySDR module for that radio installed. It will not run without one, which is why it starts with # needs-hardware and our checker only checks its syntax. Change driver to the SoapySDR driver key of your radio. Our hardware table lists common receivers with their frequency ranges and converter widths.

The calls follow the SoapySDR Python examples. You open the device, set the sample rate and centre frequency for receive channel 0, set up a stream in a chosen sample format, activate it, and call readStream repeatedly with a buffer. getStreamFormats lists the formats the driver can deliver. The block asks for CS16, interleaved int16, and saves the samples as SigMF ci16_le with the start time in core:datetime.

# needs-hardware
import json
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import SoapySDR
from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CS16

driver = "rtlsdr"                 # change to the SoapySDR driver key of your radio
fs = 2.0e6                        # sample rate, Hz
fc = 100.0e6                      # centre frequency, Hz (pick a band you may legally receive)
n = 2**18                         # samples to record

sdr = SoapySDR.Device(dict(driver=driver))
print("formats offered:", sdr.getStreamFormats(SOAPY_SDR_RX, 0))
sdr.setSampleRate(SOAPY_SDR_RX, 0, fs)
sdr.setFrequency(SOAPY_SDR_RX, 0, fc)
stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CS16)       # interleaved int16, I first
start_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
sdr.activateStream(stream)

buf = np.empty(2 * n, dtype=np.int16)                        # two int16 values per complex sample
got = 0
while got < n:
    chunk = np.empty(2 * 8192, dtype=np.int16)
    sr = sdr.readStream(stream, [chunk], 8192)               # count is in complex samples
    if sr.ret > 0:
        k = min(sr.ret, n - got)
        buf[2 * got : 2 * (got + k)] = chunk[: 2 * k]
        got += k
    elif sr.ret < 0:
        print(f"readStream error {sr.ret}")
        break
sdr.deactivateStream(stream)
sdr.closeStream(stream)

buf[: 2 * got].astype("<i2").tofile("radio.sigmf-data")
meta = {
    "global": {"core:datatype": "ci16_le", "core:sample_rate": fs, "core:version": "1.2.6",
               "core:hw": f"SoapySDR driver {driver}"},
    "captures": [{"core:sample_start": 0, "core:frequency": fc, "core:datetime": start_time}],
    "annotations": [],
}
Path("radio.sigmf-meta").write_text(json.dumps(meta, indent=2))
print(f"recorded {got} samples at {fs / 1e6:.1f} MHz around {fc / 1e6:.3f} MHz")

A few practical notes. readStream may return fewer samples than requested, so the loop keeps count. A negative return value is an error code. SOAPY_SDR_OVERFLOW (−4) means samples were dropped because the host did not read fast enough. Overflows leave gaps in the recording, so record to a fast local disk and keep the sample rate no higher than you need. Receiving is legal in many places for many bands, but not everywhere and not for everything. Check your national rules before recording someone else’s transmissions. The GNU Radio FM receiver project builds on this with a live receiver.

Exercise

  1. Add a ci8 format to the first block (signed 8-bit, scale 127, offset 0). Compare its error and floor with cu8 and explain why they are almost the same.
  2. Halve the amplitude of every signal in the first block before writing cu8. How do the spurs and the floor change relative to the tone? Then double it and look for clipping.
  3. In the second block, add a second annotation for the tone at +300 kHz covering the whole capture. Read the metadata back and print every annotation’s label and its duration in milliseconds.

Summary

  • A raw IQ file is interleaved I and Q samples, in-phase first, with no header. The common types are cf32, cs16 (SigMF ci16_le), cs8 and cu8, at 8, 4, 2 and 2 bytes per complex sample.
  • SigMF pairs a .sigmf-data file with a .sigmf-meta JSON file holding global, captures and annotations. core:datatype and core:version are required, and core:sample_rate and core:frequency make the file usable.
  • In our test, 16-bit storage added error at −98.1 dB and was invisible in the spectrum. 8-bit storage added error at −49.6 dB, raised the floor by about 30 dB and created spurs.
  • A spectrogram and a band-power threshold found the burst to within one 128 µs slice, and a SigMF annotation recorded it in time and RF frequency.
  • Code that opens a radio needs the hardware connected. SoapySDR gives one API for many radios.

Further reading