从零开始构建 Pluto SDR FM 收音机:一场软件定义无线电的深度实践

从零开始构建 Pluto SDR FM 收音机:一场软件定义无线电的深度实践


你能听到什么?

本文记录了一台完整可用的 FM 调频广播收音机的诞生过程——不是用现成的收音机芯片,而是用一台软件定义无线电(SDR)设备 + Python 代码,从 IQ 采样到音频输出,从频谱显示到静噪逻辑,全部手写。

最终效果:打开程序,你可以看到实时频谱跳动,听到 88-108 MHz 的 FM 广播,按空格暂停,按数字键切换电台——和一台真正的收音机一模一样,但你能看见信号


一、什么是 SDR?为什么选 Pluto SDR?

传统收音机用模拟电路完成混频、检波、放大——一切都是硬件的。SDR 的思路不同:把天线收到的射频信号尽可能早地数字化,之后的一切——滤波、解调、去加重——都用软件算法完成。

这意味着同一块硬件,换一套代码就可以变成 FM 收音机、AM 航空波段接收机、ADS-B 飞机追踪器、频谱分析仪……这正是我们在做的事情。

ADALM-PLUTO SDR(以下简称 Pluto)是 Analog Devices 出品的入门级 SDR 开发板:

参数规格
频率范围70 MHz – 6 GHz
带宽最高 20 MHz(实际稳定 ~2.5 MHz)
ADC/DAC12-bit
接口USB 2.0(RNDIS 虚拟以太网)
价格约 ¥700-1000(学生价更低)

它通过 USB 供电并建立虚拟以太网连接,PC 端用 libiio 库收发 IQ 数据流——Python 里对应 pyadi-iio 包。


二、硬件准备

2.1 物料清单

ADALM-PLUTO SDR  ×1
FM 天线(SMA 接口)×1  ← 一定要接!否则全是噪声
USB 线(Micro-USB)  ×1
PC(Windows 10/11)  ×1

2.2 天线

Pluto 自带一根很小的 2.4 GHz 天线,不适合 FM 波段(88-108 MHz)。FM 广播波长约 3 米,理想的 FM 天线应该是 75cm 左右的鞭状天线(1/4 波长),但随便拉一根导线都能收到强台。我用的是 SMA 转 BNC 转接头 + 一根拉杆天线。

2.3 网络配置(关键步骤)

Pluto 通过 USB 模拟 RNDIS 网卡,固定 IP 为 192.168.2.1。PC 需要配置同网段静态 IP:

# 以管理员身份运行 CMD
netsh interface ip show interfaces
# 找到 "以太网 X"(Pluto 对应的网卡)

netsh interface ip set address name="以太网 X" static 192.168.2.2 255.255.255.0

验证:

ping 192.168.2.1

能 ping 通即硬件连接就绪。这一步是新手最容易卡住的地方——如果 ping 不通,检查 USB 线是否支持数据(不是纯充电线)、RNDIS 驱动是否正常加载。


三、软件环境搭建

3.1 Python 和依赖

需要 Python 3.12 64-bit,安装时勾选 “Add Python to PATH”。

pip install numpy scipy matplotlib sounddevice pyadi-iio

各包职责:

担任角色
numpy数值计算引擎:复数运算、FFT、矩阵操作
scipy.signal信号处理工具箱:FIR 滤波器设计、多级抽取
matplotlib整个 GUI 界面:频谱图、瀑布图、按钮、滑块
sounddevicePortAudio 的 Python 绑定:低延迟实时音频输出
pyadi-iioadi与 Pluto SDR 通信的二层封装

3.2 libiio DLL(占坑率最高的步骤)

pyadi-iio 依赖 C 语言的原生库 libiio.dll,需手动放入 Python 安装目录。

GitHub releases 下载 libiio-<version>-Windows.zip,解压 VS-2022-x64 文件夹中的所有 .dll 到:

C:\Program Files\Python312\

验证安装:

python -c "import adi; print(adi.Pluto('ip:192.168.2.1').sample_rate)"

如果输出一个数字(如 2000000.0),说明整个链路通了:Python → pyadi-iio → libiio.dll → 网络 → Pluto SDR。


四、FM 广播原理(你需要知道的极简版)

写解调器之前,必须先理解信号在空中的形态。

4.1 调制:信息如何"搭乘"载波?

FM(Frequency Modulation)的原理是让载波频率随音频信号幅度变化

  • 载波中心频率:f_c(例如 97.4 MHz)
  • 音频信号幅度越大 → 载波频率偏离 f_c 越远
  • 频偏范围:±75 kHz(广播标准)
  • 调制信号带宽:15 kHz(单声道)

在数学上,FM 信号可以写成:

s(t) = A · cos(2π·f_c·t + 2π·Δf·∫m(τ)dτ)

其中 m(t) 是音频信号,Δf = 75 kHz 是最大频偏。

4.2 去加重:发射端的"预加重"与接收端的"反操作"

FM 广播发射时会人为提升高频分量(预加重,Pre-emphasis),以提高高频信噪比。接收端必须做去加重(De-emphasis)还原平坦频谱。

不同地区的时间常数不同:

地区时间常数 τ
中国 / 欧洲50 µs
北美75 µs

本项目使用 50 µs。去加重本质上是一个一阶低通 IIR 滤波器,截止频率 f_c = 1/(2πτ) ≈ 3.18 kHz


五、DSP 信号处理链:从空中波形到扬声器声音

这是整个项目的核心。一条完整的信号路径如下:

Pluto SDR (2 MSPS IQ)
  │
  ▼
┌──────────────────────┐
│  IQ 缓冲队列          │  ← 接收线程不断从 Pluto 拉取
└──────┬───────────────┘
       │
       ├──→ FFT ──→ 频谱图显示(128ms 刷新)
       │
       ▼
┌──────────────────────┐
│  FM 解调 (arctan 鉴频) │  ← ∠(x[n]·x*[n-1]) = 瞬时频率
└──────┬───────────────┘
       │  2 MSPS,浮点
       ▼
┌──────────────────────┐
│  多级抽取 2M → 48k     │  ← 分级降采样,保护滤波器性能
└──────┬───────────────┘
       │  48 kSPS
       ▼
┌──────────────────────┐
│  去加重 (50 µs, 1-pole) │  ← IIR 低通,补偿发射端预加重
└──────┬───────────────┘
       │
       ▼
┌──────────────────────┐
│  静噪门 (Squelch)      │  ← SNR < 阈值 → 输出静音
└──────┬───────────────┘
       │
       ▼
┌──────────────────────┐
│  音量控制 & 限幅       │  ← clip(-1.2, 1.2)
└──────┬───────────────┘
       │
       ▼
┌──────────────────────┐
│  Audio Ring Buffer    │  ← thread-safe 环形缓冲
└──────┬───────────────┘
       │
       ▼
┌──────────────────────┐
│  sounddevice 输出     │  ← 48 kHz 单声道,低延迟
└──────────────────────┘

5.1 IQ 采样与缓冲

SDR_URI   = "ip:192.168.2.1"
SAMPLE_RATE = 2000000      # 2 MSPS
ACCUM_IQ    = 131072       # 每次处理 131k 个采样点

Pluto 以 2 MSPS 采样率持续输出 12-bit 复基带采样(I/Q 各 12-bit,Python 中为 complex64)。一个独立的采集线程 _acq_loop() 不断调用 sdr.rx() 拉取数据,追加到 IQ 缓冲区。当缓冲区积攒够 ACCUM_IQ 个采样点(~65ms 的数据),触发一轮 _process()

使用生产者-消费者模型,采集线程与 GUI 主线程解耦。

5.2 FM 解调:arctan 鉴频器

FM 解调的本质是提取瞬时频率。对于离散复信号,瞬时频率正比于相邻采样点的相位差:

demod = np.angle(iq[1:] * np.conj(iq[:-1]))

这里 iq[n] · conj(iq[n-1]) 的结果是一个复数,其角度等于前后两点的相位差。在 FM 调制中,相位差的变化反映了音频信号的幅度变化——这就是"鉴频"的全部秘密。

这份代码只有一行,但它背后是复数运算、共轭、反正切的高效 NumPy 矢量化实现。

5.3 多级抽取:从 2 MHz 到 48 kHz

FM 广播的音频带宽只有 15 kHz,2 MSPS 的原始采样率过于庞大。直接 decimate 到 48 kHz 会导致过渡带过窄,FIR 滤波器阶数极高。

解决方案:分两级降采样

第1级:FIR 滤波 + 5倍抽取
  2 MSPS → 400 kSPS
  FIR: 127阶,截止频率 = 0.45/5 = 0.09 (归一化)

第2级:有理数重采样 3/25
  400 kSPS → 400 × 3/25 = 48 kSPS
  scipy.signal.resample_poly,内部默认 Kaiser 窗
DECIM_STAGE1      = 5
DECIM_STAGE2_UP   = 3
DECIM_STAGE2_DOWN = 25

# 第1级
x = sig.upfirdn(self._filt, demod, down=DECIM_STAGE1)
# 第2级
x = sig.resample_poly(x, DECIM_STAGE2_UP, DECIM_STAGE2_DOWN, window="hann")

最终得到 48 kSPS 的音频流——正好是标准音频采样率。

5.4 归一化与去加重

# 相位差 (rad/sample @ 2M) → 频率偏移 → 音频
x *= SAMPLE_RATE / (2 * np.pi * 75000)

解调出的相位差单位是 rad/sample,需要换算为实际频偏。系数 SAMPLE_RATE / (2π × 75000) 将相位差映射到归一化音频幅度(±75 kHz 频偏对应 ±1.0)。

去加重用一个简单的一阶 IIR 低通滤波器实现:

tau   = 50e-6                          # 中国 FM 标准: 50µs
alpha = 1 - np.exp(-1 / (AUDIO_RATE * tau))
# y[n] = (1-α)·x[n] + α·y[n-1]

这是一个递归低通,截止频率 1/(2π·50µs) ≈ 3.18 kHz。每输出一个采样点,高频分量被适度衰减,恢复平坦的音频频谱。

5.5 静噪门(Squelch)

没有电台的频点全是噪声——沙沙声很刺耳。静噪门的逻辑很简单:

# 实时计算 SNR
self.signal_strength = float(np.max(spec) - np.median(spec))

# SNR 低于阈值 → 静音
if self.signal_strength < self.squelch:
    x = np.zeros_like(x)

从频谱中取 peak 和 median floor,差值就是 SNR。当 SNR 低于用户设定的阈值时,输出全零,扬声器寂静。


六、软件架构:三线程协同

┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│  采集线程          │    │  GUI 主线程        │    │  音频回调线程       │
│  _acq_loop()      │    │  matplotlib       │    │  sounddevice      │
│                   │    │  FuncAnimation     │    │  PortAudio        │
│  循环:             │    │                   │    │                   │
│  sdr.rx()         │    │  _gui_update():    │    │  _audio_cb():     │
│  → iq_buf         │    │    读 latest_spec  │    │    读 audio_ring  │
│  → _process()     │    │    画频谱/瀑布图     │    │    → outdata      │
│  → audio_ring     │    │    每 80ms 刷新     │    │    每 ~21ms 调用   │
└──────┬───────────┘    └──────────────────┘    └──────────────────┘
       │                       │                        │
       └───────────────────────┼────────────────────────┘
                               │
                     threading.Lock()
                     audio_ring (deque, 48k items max)
                     latest_spec (ndarray 512)
                     waterfall  (ndarray 100×512)

线程安全是这里的设计关键。采集线程写入 audio_ring,音频回调读取 audio_ring——两者通过 threading.Lock() 保护。GUI 线程(FuncAnimation)只读不写,也加锁保证数据一致性。

选择 deque 作为音频缓冲是刻意之举:popleft() O(1),自动丢弃旧数据,容量上限 1 秒,保证延迟可控。

为什么不在音频回调里做解调?

音频回调要求快速返回(~21ms 内),不能有阻塞操作。把 DSP 放在采集线程做,音频回调只负责从 buffer 取数填输出——干净利落。


七、GUI 设计:matplotlib 能做什么?

很多人不知道,matplotlib 除了画图,还能构建完整的交互式界面。本项目没有用 PyQt/Tkinter,直接用 matplotlib widgets 搭了整个控制面板。

7.1 布局结构

┌─────────────────────────────────────────────────────────────┐
│  Pluto SDR — FM Radio 88-108 MHz                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│              频谱图 / 瀑布图(主显示区)                        │
│              ×轴: 频率偏移 (kHz)                              │
│              y轴: Power (dB) / Frame                         │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│  100.00 MHz   [<<] [<] [>] [>>]       ○ slow ○ fast ○ manual│
│  [Pause] [Tone]   Vol ──●──     SQL ──●──   [Waterfall]     │
│  [92.7] [107.8] [103.8] [101.8] [100.6] [104.6]            │
├─────────────────────────────────────────────────────────────┤
│  SNR: 25.3 dB | Squelch: 12.0 | Buffer: 0.3s | LIVE         │
│  ←→ ±0.1M ↑↓ ±1M 1-6 Presets Space=Pause T=Tone V=View     │
└─────────────────────────────────────────────────────────────┘

7.2 关键控件

控件类型功能
频谱图/瀑布图FuncAnimation 驱动实时刷新80ms/帧
Tune 按钮 << >> < >Button±1 MHz / ±0.1 MHz 调谐
Pause / ToneButton音频播放控制
Vol / SQL 滑块Slider音量 0-3x、静噪 0-30 dB
Gain ModeRadioButtonsslow_attack / fast_attack / manual
Presets 1-6Button ×6一键切换预设电台
键盘快捷键key_press_event←→↑↓ 调谐、数字键切换预设、Ctrl+数字保存

7.3 频谱图 vs 瀑布图

频谱图模式:实时显示当前 FFT 的幅度谱(蓝线),y 轴是 dB,x 轴是相对中心频率的偏移。

瀑布图模式:用 imshow() 渲染最近 100 帧的频谱叠加,颜色越亮 = 信号越强。横轴是频率,纵轴是时间(向下滚动)。一台强信号电台在瀑布图上会呈现为一条笔直的亮线——视觉上非常直观。

self.waterfall = np.roll(self.waterfall, -1, axis=0)
self.waterfall[-1] = wf_row

每次处理新数据时,瀑布图数组整体上移一行,新频谱填到最底部——形成向下滚动的效果。

7.4 频率切换的细节

切换频率时,本地振荡器(LO)立即改变,但 IQ 缓冲区里还有上一个频率的旧数据。如果不处理,会有短暂的声音错乱。解决方案:

def _set_freq(self, f):
    self.current_freq = round(f, 2)
    self.sdr.rx_lo = int(self.current_freq * 1e6)
    # 清空所有旧数据
    with self.lock:
        self.iq_buf = np.array([], dtype=np.complex64)
        self.audio_ring.clear()

一刀切清空缓冲区,保证新频率的数据"纯净"。


八、启动流程

程序启动到播放声音需要约 2-3 秒:

1. 连接 Pluto SDR,重试 5 次(网络偶尔抖动)
2. 设置采样率 2 MSPS、带宽 200 kHz、AGC 模式
3. "预热":采集 6 个 block 进行 DSP 处理,填满音频缓冲
4. 启动 sounddevice 音频流(低延迟模式)
5. 启动采集线程
6. 构建 GUI,启动 FuncAnimation
7. Ready → 用户可以听到声音、看到频谱

预填充(pre-fill)阶段确保音频缓冲有足够的样本,避免启动时卡顿或断流。


九、使用指南

9.1 基本操作

操作按键
微调频率 ±0.1 MHz
粗调频率 ±1 MHz
切换到预设 1-6数字键 1-6
保存当前频率到预设Ctrl+1-6
暂停/恢复音频Space
播放 1kHz 测试音T
切换频谱/瀑布图V
循环增益模式M

9.2 收到一个清晰的电台

  1. 接好天线,放在窗边
  2. 把静噪(SQL)设为 0,先听到噪声确认通路正常
  3. 慢慢拨频率,看频谱上有没有"凸起"的峰——那就是电台
  4. 看到峰后,停在那个频率,把静噪调高到噪声消失但电台不中断的水平
  5. 音质不满意可切换增益模式(slow 适合稳定信号,fast 适合快速波动)

9.3 使用预设

预设存储在 fm_presets.json 中,一个简单的 JSON 数组。你可以直接编辑这个文件,也可以 Ctrl+数字保存:

[92.7, 107.8, 103.8, 101.8, 100.6, 104.6]

十、踩过的坑

10.1 libiio DLL 版本不匹配

如果 DLL 和 pyadi-iio 版本不匹配,import 直接报错。解决方案:去 GitHub 下载与 pyadi-iio 兼容的 libiio 版本。可以用 pip show pyadi-iio 查看版本。

10.2 Pluto 采样率上限

Pluto 标称 20 MHz 带宽,但 USB 2.0 实际数据吞吐量有限。稳定工作上限约 2-3 MSPS。本项目用 2 MSPS,正好覆盖 FM 波段 1 MHz 宽(88-108 MHz 需要 ~1 MHz 带宽)。

10.3 声音卡顿

音频缓冲不足导致 underflow。解决方案:增大 ACCUM_IQ(处理块大小),同时调大 audio_ring 容量。当前设置 131072 采样点/块、1 秒缓冲,实测稳定。

10.4 matplotlib 的 GUI 限制

matplotlib 的交互性能不如 PyQt,FuncAnimation 帧率波动时频谱会有轻微抖晃。但对于这个应用来说,80ms 刷新完全够用——你听的是声音,看的是频谱趋势。

10.5 不接天线的惨痛教训

Pluto 不接天线时虽然收不到真实信号,但板子本身会辐射数字噪声——频谱看起来像有信号,实际上是假象。一定接天线。


十一、这个项目的延展

FM 收音机只是 Pluto SDR 能力的冰山一角。同仓库中还有:

  • FM Stereo — 同一条 FM 信号,提取 19 kHz 导频,解码 L-R 差信号,输出双声道立体声
  • AM Radio — 108-137 MHz 航空波段(AM 调制),收听机场通播
  • ADS-B — 1090 MHz 飞机位置广播,解码 ICAO 地址和高度
  • 实时频谱分析仪 — 全频段扫频,峰值保持
  • 频谱扫描 — 生成静态瀑布图和频谱图

硬件不变,软件可变——这就是 SDR 的魅力所在。


十二、总结

从零开始到今天,这条链路串起了:

物理层(天线、射频前端)
  → 数字层(ADC、IQ 流、libiio)
  → 信号处理层(FM 解调、抽取、滤波、去加重)
  → 应用层(音频播放、GUI 交互)

每一层都是自己写的,没有调用任何"收音机"库函数。你看到频谱上的每一个点、听到的每一帧声音,都是代码一行行算出来的。

这就是软件定义无线电。


硬件:ADALM-PLUTO SDR | 语言:Python 3.12 | 依赖:numpy, scipy, matplotlib, sounddevice, pyadi-iio

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值