lyyyuna 的小花园

动静中之动, by

RSS

用 RTL-SDR 实现一个 FM 收音机

发表于 2026-07

前言

前段时间买了一个 RTL-SDR Blog V4,插在一台 Intel NUC8 小型桌面电脑上。这台机器没有显示器,平时只通过 SSH 管理。最初的想法很简单:既然它能收 FM 广播,那能不能把 93.4 MHz 的声音通过网络传回本机?

但直接安装一个现成 SDR 软件,点几下鼠标就能听,并不是我真正想做的。我更关心设备从 USB 交出来的是什么数据,频谱是怎么算出来的,FM 为什么可以从相位里解调,实时流水线为什么会丢样,以及浏览器最终播放的 PCM 和原始 IQ 到底有什么区别。

于是我写了一个小项目 sdr-lab,所有实验都运行在这台 NUC8 上,使用 uv、Python 3.14、NumPy 和 aiohttp。整个过程分成五步:解析 IQ、计算频谱、离线解调、实时接收、浏览器播放。

最后得到的链路是这样的:

flowchart LR
    A["天线"] --> B["R828D 调谐器"]
    B --> C["RTL2832U"]
    C --> D["USB / librtlsdr"]
    D --> E["960 kS/s 复数 IQ"]
    E --> F["WBFM DSP"]
    F --> G["48 kHz PCM16"]
    G --> H["WebSocket"]
    H --> I["浏览器 Web Audio"]

这篇文章把五篇阶段性记录重新整理到一起。不会贴出所有代码,但会保留最关键的数据格式、算法、工程取舍和真实测试结果。

项目代码放在 GitHub:lyyyuna/sdr-lab。仓库里保留了每个阶段的实现、测试和学习记录,原始 IQ 文件因为体积较大没有提交。

设备给程序的不是声音

RTL-SDR V4 里有两个重要芯片:R828D 调谐器负责选频、增益、滤波和下变频,RTL2832U 负责采样和数字下变频。Linux 上的 librtlsdr 再通过 libusb 从设备读取数据。

antenna
  -> R828D tuner
  -> RTL2832U ADC and digital down-conversion
  -> USB
  -> librtlsdr
  -> application

严格来说,librtlsdr 不是内核驱动,而是用户态设备库。它向上提供设置中心频率、采样率、增益、频率校正和读取采样等接口。程序从它那里拿到的也不是“上海新闻广播的声音”,而是一段复数基带采样。

我最早保存的文件使用 CU8 格式,字节排列如下:

I0 Q0 I1 Q1 I2 Q2 ...

I 和 Q 都是 0 到 255 的无符号 8 位整数,127.5 附近表示零。归一化之后,一组 I/Q 组成一个复数:

$$ x[n] = I[n] + jQ[n] $$

对应的 Python 代码并不复杂:

components = raw.astype(np.float32).reshape(-1, 2)
i = (components[:, 0] - 127.5) / 127.5
q = (components[:, 1] - 127.5) / 127.5
iq = (i + 1j * q).astype(np.complex64)

为什么需要 I 和 Q 两路?因为普通实数采样无法区分中心频率两侧的正负频率,而复数 IQ 保留了相位旋转方向。之后的软件调谐、频谱分析和 FM 解调都依赖这部分信息。

这也是 IQ 和 PCM 的根本区别:

数据 描述的对象 典型用途
IQ 一段射频带宽中的复数基带信号 选台、频谱、解调、重新处理
PCM 已经解调后的音频振幅 播放、编码、存 WAV

在 960 kS/s 下,每秒有 960,000 个复数采样,每个采样两个字节,因此原始数据率是:

$$ 960000 \times 2 = 1920000\ \text{bytes/s} $$

10 秒文件应该正好有 19,200,000 字节和 9,600,000 个复数采样。这是解析程序最基本的不变量。

真实文件的统计如下:

{
  "byte_count": 19200000,
  "sample_count": 9600000,
  "duration_seconds": 10.0,
  "i_mean_normalized": -0.00086194,
  "q_mean_normalized": -0.00085905,
  "rms": 0.0442803,
  "clipped_component_count": 0
}

字节数、采样数和时长吻合,I/Q 均值接近零,ADC 也没有明显截幅。这里的 RMS 是整段宽带 IQ 的平均幅度,不是某个 FM 电台的音量。要看信号在频率轴上分布在哪里,还得做 FFT。

另一个容易忽略的问题是,裸 IQ 文件没有文件头。仅看这些字节,无法恢复中心频率、采样率、增益和采样格式。所以录制 IQ 时必须同时保存元数据,靠文件名猜参数迟早会出错。

从 IQ 看见频谱

对一段长度为 $N$ 的复数采样做 FFT,可以得到它在各个频率 bin 上的复数幅度。采样率为 960 kS/s 时,复数 IQ 能唯一表示的无混叠频率偏移区间是 -480 kHz 到 +480 kHz。如果硬件中心频率是 93.4 MHz,实际观察窗口就是:

92.92 MHz <- 93.40 MHz -> 93.88 MHz

为什么总带宽正好是 960 kHz

这个结论对没有接触过数字信号处理的人不太直观,可以从复数平面上的“旋转”来理解。

一个频率为 $f$ 的复数音调可以写成:

$$ x[n] = e^{j2\pi fn/f_s} $$

每得到一个新采样,复数点就在 I/Q 平面上继续旋转一小步。程序实际看到的是相邻采样点之间的相位增量:

$$ \Delta\phi = 2\pi\frac{f}{f_s} $$

问题是,角度每转 360° 就会回到同一个位置。数字采样只能看到离散的点,看不到两个点之间额外转了多少整圈。因此频率 $f$ 和 $f+f_s$ 会得到完全相同的采样:

$$ e^{j2\pi(f+f_s)n/f_s} = e^{j2\pi fn/f_s}e^{j2\pi n} = e^{j2\pi fn/f_s} $$

以 960 kS/s 为例,100 kHz 和 1,060 kHz 的相位增量相差一整圈,采样结果无法区分。所有频率每隔 960 kHz 就会重复,所以只能从每组重复频率中选一个代表,互不重复的总宽度也就是 960 kHz。

理论上可以选择 0 到 960 kHz 作为代表区间。不过 IQ 是以零频为中心的基带数据,而且能区分顺时针和逆时针旋转,也就是正频率和负频率,所以通常选择:

$$ -\frac{f_s}{2} \le f < \frac{f_s}{2} $$

代入 960 kS/s,就是 -480 kHz 到 +480 kHz,总宽度仍然是 960 kHz。区间的一端要排除,因为 +480 kHz 和 -480 kHz 每个采样都旋转 180°,生成的是同一串数据。

超出这个区间的信号会折回。例如 +600 kHz 会变成:

$$ 600 - 960 = -360\ \text{kHz} $$

这就是混叠。实际 RTL-SDR 在频带边缘还会受到模拟和数字滤波器滚降影响,因此真正好用的带宽通常会比理论上的 960 kHz 略小。

程序使用 np.fft.fftfreq() 生成相对频率,再用 fftshift() 把负频率放到左边,最后加上硬件中心频率。

直接截取有限长度信号会产生频谱泄漏,所以每帧 FFT 前乘 Hann 窗。单帧噪声波动又很大,因此我没有直接平均 dB,而是先平均线性功率:

$$ P[k] = \frac{1}{M}\sum_{m=0}^{M-1}|X_m[k]|^2 $$

平均完成后再转成 dB:

$$ P_{dB}[k] = 10\log_{10}P[k] $$

先做了一个合成复数音调测试。如果输入是:

$$ x[n] = e^{j2\pi f_{offset}n/f_s} $$

那么频谱峰值必须出现在 center_frequency + f_offset。这个测试解决的是“FFT 和频率轴有没有写反”,真实射频数据则用来观察环境里到底有什么信号。两者不能混为一谈。

93.4 MHz 真实 IQ 的平均频谱如下:

93.4 MHz 附近的 IQ 平均功率频谱

计算参数和结果:

{
  "fft_size": 16384,
  "frame_count": 256,
  "frequency_min_hz": 92920000.0,
  "frequency_max_hz": 93879941.40625,
  "peak_frequency_hz": 93400000.0,
  "peak_power_dbfs": -50.0016,
  "noise_floor_dbfs": -70.8627
}

93.4 MHz 附近确实有一个宽带隆起,形状符合广播 WBFM 的特征。图上正中央还有一根很细的尖峰,不过不能急着把它理解成电台载波。RTL-SDR 属于零中频接收机,中心位置常常会出现 DC 尖峰。如果目标台恰好也放在中心,两者就叠在一起了。

这个观察直接影响了后面的实时调谐:硬件不再正好调到 93.4 MHz,而是调到 93.65 MHz,再由软件把相对偏移 -250 kHz 的目标台移回零频。这样 DC 尖峰会被搬到频道低通之外。

频谱只能证明“这里存在一个像广播 FM 的信号”,不能仅凭一张图证明电台名称。93.4 MHz 是我的测试频点,但节目身份仍然要靠实际试听或 RDS 等信息确认。

自己实现 WBFM 解调

离线解调没有调用 rtl_fm,而是把最基本的数字信号处理步骤自己实现了一遍:

960 kS/s complex IQ
  -> remove DC
  -> shift station to 0 Hz
  -> 100 kHz low-pass
  -> decimate by 4 to 240 kS/s
  -> FM phase discriminator
  -> 15 kHz audio low-pass
  -> decimate by 5 to 48 kHz
  -> 50 us de-emphasis
  -> mono PCM16 WAV

软件调谐

目标电台相对硬件中心频率偏移 $f_{offset}$ 时,把 IQ 乘上反向复数振荡器:

$$ y[n] = x[n]e^{-j2\pi f_{offset}n/f_s} $$

目标频道就会移动到基带零频。流式处理时,振荡器相位必须跨块保存。否则每个输入块都从相位零重新开始,块边界会出现不连续。

低通和抽取

广播 WBFM 的最大频偏约 75 kHz,音频带宽约 15 kHz。按 Carson 规则估算,主要频道带宽约为:

$$ 2(75\ \text{kHz} + 15\ \text{kHz}) = 180\ \text{kHz} $$

因此第一级使用大约正负 100 kHz 的低通,再从 960 kS/s 抽取到 240 kS/s。

这里顺序很重要。不能先简单地每四点取一点,再考虑滤波。那样原来超过新 Nyquist 频率的成分会折叠进来,形成混叠。项目用窗口化 sinc 生成 FIR,并只计算抽取相位需要的输出。

相位差分

FM 把声音编码在瞬时频率里,而复数 IQ 的相位旋转速度就是瞬时频率。相邻采样的旋转可以写成:

$$ r[n] = x[n]x^*[n-1] $$

$$ \Delta\phi[n] = \operatorname{atan2}(\Im(r[n]), \Re(r[n])) $$

再按采样率和最大频偏缩放,就得到归一化音频:

$$ a[n] = \Delta\phi[n]\frac{f_s}{2\pi\Delta f} $$

代码的核心其实只有几行:

rotation = iq[1:] * np.conj(iq[:-1])
phase_delta = np.arctan2(rotation.imag, rotation.real)
audio = phase_delta * sample_rate / (2 * np.pi * deviation)

真正麻烦的是流式状态。解调器必须保存上一块最后一个 IQ 采样,否则每个块的第一个相位差都没有正确参考点。

音频低通和去加重

FM 复合基带中除了 L+R 单声道,还有 19 kHz 立体声导频、38 kHz 附近的 L-R 和 57 kHz RDS。本阶段只提取 0 到 15 kHz 的 L+R,然后抽取到 48 kHz。

中国和欧洲 FM 广播使用 50 us 预加重。接收端用一阶低通做去加重:

$$ \alpha = e^{-1/(f_s\tau)},\quad \tau=50\ \mu s $$

$$ y[n] = (1-\alpha)x[n] + \alpha y[n-1] $$

它一方面恢复发送前的频率响应,一方面抑制高频噪声。

先用已知答案验证

直接拿真实广播调算法很容易陷入“好像能听”的状态。所以我先生成了一个已知的合成 FM 信号:1 kHz 音频、0.5 振幅、75 kHz 最大频偏、载波偏移 +100 kHz。

自动测试确认了几件事:

  1. 数字频移方向正确,载波能回到零频。
  2. 输出采样率是 48 kHz。
  3. 去掉启动瞬态后,音频最大谱峰落在 1 kHz,误差小于 3 Hz。
  4. 同一输入切成不同块,输出在数值误差范围内一致。

然后再解调真实 93.4 MHz 文件,得到 48 kHz、单声道、16-bit PCM,正好 10 秒。离线处理约 1.66 秒,相当于实时速度的 6 倍。100 Hz 到 5 kHz 与 16 kHz 到 23 kHz 的能量比约 52.22 dB,说明 15 kHz 音频低通确实起作用了。

从文件走向真实设备

离线链路成立后,输入从 IQ 文件换成了真实 RTL-SDR。上层 DSP 没有改,变化集中在设备边界和实时调度。

为什么没有直接使用 PyRtlSDR

这台 NUC8 上验证支持 Blog V4/R828D 的库是:

/usr/local/lib/librtlsdr.so.0

测试 PyRtlSDR 时,它在导入阶段要求当前库没有导出的 rtlsdr_set_dithering 和 GPIO 符号,设备枚举之前就会报 undefined symbol。为了不替换已经工作的 V4 库,我用标准库 ctypes 封装了项目真正需要的稳定 C API:

get_device_count / get_device_usb_strings
open / close
set_sample_rate / set_center_freq
set_tuner_gain_mode / get_tuner_gains / set_tuner_gain
set_freq_correction / reset_buffer / read_sync

这样所有 C 指针、函数签名和错误码都留在 CtypesRtlSdrBackend 中,上层只看到 RtlSdrDevice.read_samples() 返回的 NumPy complex64 数组。

R828D 的增益也不是任意浮点数。请求 20.0 dB 时,程序先读取 tuner 的离散增益表,再选择最接近的 19.7 dB。硬件约束应该在设备层被消化,DSP 不需要知道这些细节。

为什么需要有界队列

实时流水线使用一个采集线程和一个 DSP 消费者:

RTL-SDR read_sync
  -> capture thread
  -> bounded Queue[complex64]
  -> WBFM DSP consumer
  -> audio callback

如果 DSP 短暂变慢,队列可以吸收抖动;如果 DSP 长期慢于输入,队列最终会满。这里不能使用无限队列,因为那只会把“丢样”变成“内存不断增长和播放延迟不断增加”。

队列满时,程序显式记录:

captured/processed/dropped blocks
captured/processed/dropped samples
maximum queue depth
average and maximum DSP block time
dsp_realtime_ratio

其中:

$$ dsp_realtime_ratio = \frac{DSP\ 计算耗时}{处理样本对应的真实时长} $$

它小于 1,表示计算速度跟得上设备输入。

真实设备运行 10 秒,结果如下:

{
  "captured_samples": 9600000,
  "processed_samples": 9600000,
  "dropped_blocks": 0,
  "max_queue_depth": 1,
  "average_dsp_block_seconds": 0.0289995,
  "max_dsp_block_seconds": 0.0454464,
  "dsp_realtime_ratio": 0.426293
}

每个 65,536-sample IQ 块代表约 68.27 ms 的真实信号,最慢 DSP 块耗时约 45.45 ms。Python/NumPy 在当前单路 960 kS/s WBFM 下还有足够余量。

rtl_test 的一个坑

调试设备时我一度发现,每次读取到的数据都完全相同,改增益后 RMS 也不变。原因不是天线坏了,而是普通 rtl_test 会调用 rtlsdr_set_testmode(dev, 1),让 RTL2832 输出确定性测试序列,用来检查 USB 丢样。

这种数据的特征很明显:

rtl_test -t 也不是通用探测命令,它是针对 E4000 tuner 的 PLL benchmark,在 R828D 上会提前退出。后来项目使用自己的 sdr-lab devices 做非侵入式枚举,不再用 rtl_test 判断设备是否正常。

完整断电重插后,两次真实读取的 SHA-256 不同,RMS 也分别变成 0.0341 和 0.0361,说明设备已经恢复真实射频 IQ。

把声音送到远程浏览器

NUC8 没有显示器,直接在这台机器上播放音频意义不大。下一步就是决定通过网络传什么。

方案 传输内容 960 kS/s 时带宽 特点
rtl_tcp 原始 CU8 IQ 约 1.92 MB/s 客户端可以自己选台和解调
IQ WebSocket 原始 IQ 约 1.92 MB/s 协议容易观察,但浏览器要做 DSP
PCM WebSocket 48 kHz PCM16 约 96 KB/s 简单透明,适合学习音频流
WebRTC + Opus 压缩音频 通常更低 延迟和公网能力好,但系统复杂

rtl_tcp 本质上是把设备配置和原始 IQ 数据通过 TCP 暴露出去,它自己并不完成 FM 解调。它很适合让远端 SDR 客户端接管设备,但当前目标是理解从 IQ 到声音的整条链路,所以我选择在 NUC8 上完成 DSP,只向浏览器发送 PCM16。

48 kHz、单声道、16 bit PCM 的理论数据率是:

$$ 48000 \times 2 = 96000\ \text{bytes/s} $$

这个带宽在局域网里几乎没有压力,又保留了一个非常直观的协议边界。

PCM16 编码不能逐块归一化

WBFM 解调器输出 float32,范围大致在 -1 到 1。离线 WAV 可以统计整段峰值后统一归一化,实时流却不能按每块峰值分别放大。否则安静片段里的底噪会突然变得很响,相邻块的音量也会跳来跳去。

实时编码只使用固定 audio_gain,裁剪到 [-1, 1] 后转成 little-endian signed 16-bit PCM。

慢客户端不能拖住设备

服务器为每个 WebSocket 客户端建立独立的有界 asyncio.Queue。某个浏览器如果因为后台标签页或网络抖动变慢,队列满时只删除它自己的最旧音频块,再放入最新块。

DSP audio callback
  -> event loop broadcaster
     -> client A bounded queue
     -> client B bounded queue
     -> client C bounded queue

实时收听应该优先保持“现在”,而不是补发几秒前已经过时的声音。慢客户端也不应该影响 USB 采集和其他听众。

DSP 工作线程不能直接操作事件循环里的队列,所以通过:

loop.call_soon_threadsafe(publish_pcm, pcm)

把 PCM 交回 aiohttp 所在的事件循环。

浏览器如何连续播放

WebSocket 连接后,服务端先发送一帧配置 JSON:

{
  "type": "stream-config",
  "format": "s16le",
  "sample_rate_hz": 48000,
  "channels": 1,
  "station_frequency_hz": 93400000
}

后续消息都是二进制 PCM。每个 65,536-sample IQ 块约产生 3,277 个音频采样,也就是 6,554 字节和约 68 ms 音频。

浏览器不能在每个块到达时立即播放,否则网络抖动会在块边界制造间断。页面维护一个 nextStartTime,第一次播放预留约 150 ms 缓冲,后续 AudioBuffer 紧接着上一块排程。如果排程已经落后,就重新建立缓冲。

用户点击 Play 后才创建或恢复 AudioContext,这是浏览器自动播放策略的要求。最新 PCM 同时会画到 Canvas 上,状态接口每秒刷新设备、监听者、IQ 丢块、队列峰值和 DSP 实时比。

长时间服务还藏着一个内存问题

实时模式已经设置 collect_audio=False,不会像离线 WAV 那样保存所有音频块。但早期实现仍把每个 DSP 块耗时追加到 dsp_times 列表。几秒钟测试完全看不出来,服务运行几天后内存还是会缓慢增长。

最后只保留三个累计量:处理块数、DSP 总耗时和最大单块耗时。平均值用 total / count 计算,监控所需内存不再随运行时间增长。

真实服务结果

服务直接监听 NUC8 的所有网络接口:

uv run sdr-lab serve-fm \
  --station-frequency 93400000 \
  --host 0.0.0.0 \
  --port 8080

用下面的命令查看 NUC8 的局域网 IP:

hostname -I

然后在同一局域网的电脑或手机上访问 http://<NUC8 的局域网 IP>:8080/。例如 NUC8 的地址是 192.168.1.20,访问地址就是 http://192.168.1.20:8080/,不需要再建立 SSH 隧道。

实际运行的页面如下。截图时浏览器已经连接到 93.4 MHz 音频流,波形持续更新,IQ 丢块仍为 0。

RTL-SDR FM 浏览器播放界面

一次约 26 分钟的真实运行中,服务处理了 1,514,930,176 个 IQ 样本:

{
  "captured_blocks": 23116,
  "processed_blocks": 23116,
  "dropped_blocks": 0,
  "dropped_samples": 0,
  "max_queue_depth": 1,
  "average_dsp_block_seconds": 0.0283792,
  "max_dsp_block_seconds": 0.0541097,
  "dsp_realtime_ratio": 0.415711
}

浏览器通过局域网 WebSocket 读取到的一帧 PCM 包含 3,277 个采样,最小值 -11,737,最大值 6,638,RMS 为 3,819.3。它不是空白占位数据,而是真实 IQ 解调后的音频。

0.0.0.0 表示监听机器的所有网络接口,并不等于自动暴露到公网。当前服务没有认证和 TLS,所以我只在可信局域网中使用,并依靠路由器和主机防火墙限制外部访问。如果机器有公网 IP,或者端口会被映射到公网,就不应该直接使用这种方式。

Python 还是 Rust

项目开始前我也纠结过要不要把设备采集、环形缓冲和网络服务写成 Rust,再用 Python 做算法实验。后来决定这一阶段全部使用 Python,原因不是 Rust 没价值,而是当前链路的瓶颈不在 Python 语法本身。

Rust 在更高采样率、多频道并行解调、纯 Rust USB、长期服务可靠性和低延迟协议上会有优势。但如果算法还是逐个 Python 对象和逐采样循环,单纯把设备读取换成 Rust 并不会自动解决问题。反过来,只要把主要计算保持在 NumPy 向量操作里,Python 也不必一开始就依赖多进程。

这次实验给我的判断是:语言会成为瓶颈,但要用测量证明它已经成为瓶颈。当前更重要的是 USB 稳定性、有界队列、跨块状态和正确的 DSP 算法。

总结

整个项目从一个很朴素的问题开始:RTL-SDR 插在远程服务器上,我怎么听到 FM 广播。一路做下来,真正学到的并不只是“如何播放 93.4 MHz”。

  1. RTL-SDR 输出的是一段带宽内的复数 IQ,不是声音。
  2. 裸 IQ 必须配套保存中心频率、采样率、增益和格式等元数据。
  3. 频谱分析要正确处理复数频率轴、窗函数和线性功率平均。
  4. WBFM 解调的核心是数字频移、抗混叠抽取和相位差分,难点则是跨块状态。
  5. 合成信号用于验证已知答案,真实射频用于验证系统边界,两种测试缺一不可。
  6. 实时系统必须使用有界队列和明确的丢块指标,不能用无限缓存掩盖处理能力不足。
  7. rtl_tcp、IQ WebSocket、PCM WebSocket 和 WebRTC 解决的是不同层次的问题,不存在一个方案适合所有阶段。
  8. 是否换 Rust 应该由采样率、并发频道数和实测余量决定,而不是由“Python 看起来慢”决定。

现在这条链路已经可以持续工作:真实射频在 NUC8 上采集和解调,48 kHz PCM 通过 WebSocket 送到本机浏览器。再往后,可以继续做立体声、RDS、频谱瀑布和远程调谐,也可以把 PCM 接到 Opus/WebRTC 上比较延迟与带宽。

不过我觉得这一步最有意思的地方,还是把“收音机里有声音”拆成了一个个可以测量、可以写测试、也可以被证明错误的数据边界。软件无线电的“软件”二字,大概就体现在这里。

P.S. 93.4 MHz 的频谱形状和实际声音可以证明这里存在广播信号,但只靠当前实验不能严谨证明具体台名。要做这件事,RDS 会比看 FFT 峰值靠谱得多。

lyyyuna 沪ICP备2025110782号-1