简介:这是MMSSTV(Multi Media Slow-Scan Television)无线电慢扫描电视的开源源码包。该软件用于通过无线电波传输静态图像,是业余无线电爱好者远距离分享照片的重要工具。资源面向具备一定C/C++基础的无线电爱好者与软件学习者,适合用于研究图像编码、信号调制及纠错机制。包内共554个文件,约5.52MB,涵盖源代码(h/cpp/c)、Delphi窗体文件(dfm)、位图资源(bmp)、图标(ico)及说明文档(txt)等,结构完整,便于按模块检索。目前已有632人浏览学习。通过阅读源码,可掌握JPEG类压缩、DCT变换、FM/AM调制及PLL鉴频、CRC校验与FEC纠错等关键技术的具体实现;同时附带文本说明,对安装与使用有直接帮助,是理解无线电图片传送全流程的优质学习材料。
1. MMSSTV源码master:一个二十年前的无线电软件,现在还能当教材读
业余无线电圈子里,MMSSTV几乎就是"慢扫描电视(SSTV)"的代名词。它把一张图片编码成一段音频,从电台话筒送出去;几公里外另一台电脑用声卡收到这段声音,把图像一行行解出来。它的源码master分支是一个老派的Windows工程,没有云、没有框架、没有AI,只有波形、频率和位运算。对IT从业者来说,它比很多热闹的"Python实战项目"更值得拆:你在同一份源码里同时看到实时音频IO、调制解调、图像采样和状态机。本文从一个工程师会动手验证的角度出发,把这个源码的信号链路、构建方式和改造入口讲清楚,读完后你能独立把master分支跑起来,并知道从哪个函数开始改。
2. 从波形到像素:MMSSTV源码里的调制解调骨架
2.1 为什么MMSSTV把音频当传输总线
SSTV的载波不是IP包,而是可听见的音频。电台的调频链路把2000Hz左右的声音搬移到射频上,接收端再把射频解调回音频。所以MMSSTV的全部工作可以压缩成一句话:在声卡采样率和无线电带宽之间,做像素到频率的映射。取22050Hz单声道采样,能完整覆盖1500Hz到2300Hz的SSTV像素频段,还留了大量余量给滤波和同步检测。这个选择决定了后面所有的缓冲设计、过零检测代码和电平校准逻辑,也是读源码时最先要建立的坐标系。
2.2 最小发射链路:一行像素如何变成可听见的频移键控
MMSSTV的发射端做的事情,和下面这段Python骨架本质相同:把像素亮度线性映射成频率,按顺序拼成连续波形。我用Python写是为了先看耳朵能听见的形状,再回到C++里理解真实实现。
# mini_sstv_tx.py - 一行图像数据 -> SSTV音频(骨架) import math import wave SAMPLE_RATE = 22050 F_SYNC = 1900 # 行同步脉冲频率 F_PORCH = 1500 # 同步与像素之间的停顿频率 F_LO = 1500 # 像素黑电平对应频率 F_HI = 2300 # 像素白电平对应频率 def tone(freq, seconds, amp=0.7): n = int(SAMPLE_RATE * seconds) return [amp * math.sin(2 * math.pi * freq * i / SAMPLE_RATE) for i in range(n)] line = [0, 255, 127, 200, 50, 180, 100, 20] # 示意一行8个像素,0黑255白 buf = [] buf += tone(F_SYNC, 0.005) # 5ms同步脉冲,告诉接收端“新一行开始” buf += tone(F_PORCH, 0.0015) # 1.5ms porch,给接收端留出调整时间 for pix in line: f = F_LO + (F_HI - F_LO) * (pix / 255.0) # 亮度线性映射到频率 buf += tone(f, 0.005) # 每个像素持续5ms,仅为演示而放大这段代码的意义不在性能,而在展示SSTV的最小单位:同步脉冲定义行的边界,porch做电平复位,像素按亮度决定瞬时频率。真实MMSSTV里,每个像素只有约零点几毫秒,参数在模式表里按"行时间除以行像素数"折算,但骨架完全一样。代码中tone()的seconds参数就是该频率持续的时间,改大改小直接影响同步检测的灵敏度;发射端想加快速度,优先缩短的是像素时长而不是同步脉冲。
2.3 接收侧的过零检测:从声音里抠出频率序列
接收端的第一个脏活是把PCM采样换算成频率序列。最简单、也最容易读懂的算法是过零检测:记录波形从负到正跨越零点的间隔,间隔越短频率越高。下面这段C++就是我在头脑里运行MMSSTV时最常用的一版心法:
// 单声道16位整型缓冲,过零计数换算瞬时频率 int last_zero = -1; for (int i = 1; i < frames; ++i) { // 从前一个负值到当前非负值,判断为一次上升沿过零 if (buf[i - 1] < 0 && buf[i] >= 0) { if (last_zero > 0) { double freq = (double)SAMPLE_RATE / (2.0 * (i - last_zero)); // freq 就是要交给同步检测和像素映射的瞬时频率 } last_zero = i; } }这个实现能跑,但抗噪能力很差,一声爆音就能制造一堆假频率。所以MMSSTV源码里对这段输入通常会有三个加强:先做带通滤波把1500Hz到2300Hz之外的噪声切掉;再做多周期平均而不是单个过零周期;最后在像素区按行时间做中值滤波。读源码时只要看到这三个处理,就知道作者在工程上处理过真实电台噪声。理解过零检测,是理解后面所有同步、解调代码的入口。
2.4 模式表决定一切:常用SSTV模式参数参考
SSTV不是一种波形,而是一族协议。Martin、Scottie、Robot这些名字背后,是同步频率、行像素数、彩色分量发送顺序的组合差异。MMSSTV源码的厉害之处,是把这些差异全部收进一张模式表,解码器本身只按表循环执行。下面这张表是常见模式的关键参数,具体数值以你手上master分支源码里的模式表为准。
| 模式 | 分辨率 | 同步频率 | 像素频段 | 每行像素 | 单行耗时 |
|---|---|---|---|---|---|
| Martin M1 | 320×256 | 1900Hz | 1500-2300Hz | 320 | 约72.5ms |
| Scottie S1 | 320×256 | 1500Hz | 1500-2300Hz | 320 | 约72.5ms |
| Robot 36 | 320×240 | 1200Hz | 1500-2300Hz | 320 | 约150ms |
注意M1的同步频率是1900Hz,Scottie却是1500Hz,所以接收端必须先识别同步脉冲的频率,才能知道自己该进入哪种模式。这也解释了为什么源码里"找同步"是一段独立逻辑:先按已知模式表轮流试探,谁的相关值高就锁谁。改模式表里的任何一个字段,就等于定义一种新SSTV模式,这也是后文改造的起点。
3. 拆开master源码:主循环与声卡回调怎么衔接
3.1 声卡IO:waveIn回调是SSTV的呼吸
从GitHub的main分支或master分支检出这类老Windows源码后,先别看界面代码,第一步找声卡打开的地方。MMSSTV这类程序普遍使用waveIn系列API,原因是它足够底层,缓冲由自己管理,延迟可控。回调机制是核心:声卡每录满一块缓冲,系统就调用一次回调,程序在回调里把数据交给解码器,然后立刻把缓冲还给声卡。这个循环就是整台机器的呼吸。
void CALLBACK WaveInProc(HWAVEIN hwi, UINT uMsg, DWORD_PTR inst, DWORD_PTR p1, DWORD_PTR p2) { if (uMsg == WIM_DATA) { PWAVEHDR hdr = (PWAVEHDR)p1; // hdr->lpData 指向刚录满的一帧PCM // 在这里调用解码器:先找同步脉冲,再切像素,再刷新界面 DecodeSSTVFrame((short*)hdr->lpData, hdr->dwBytesRecorded / 2); // 归还缓冲,否则声卡不会再录 waveInAddBuffer(hwi, hdr, sizeof(WAVEHDR)); } }这段代码里有三个参数值得盯住:hdr->dwBytesRecorded是本次实际录到的字节数,别拿缓冲总长度当有效长度;WAVEHDR结构在初始化时由waveInPrepareHeader设置,回调里只做归还;hwi是声卡句柄,全程不能变。常见的崩溃原因就是回调里做了耗时操作,比如直接写磁盘或画图,导致waveInAddBuffer来得太晚,底层缓冲区溢出。正确做法是把解码结果放进一个队列,界面刷新由定时器去消费。
3.2 模式表:一张表驱动的收发状态机
读MMSSTV源码最过瘾的部分,是看它的模式表结构。这里没有设计模式,就是一组结构体数组,收发两端共用。每个模式字段的含义直接对应波形参数。
typedef struct { int mode_id; /* 面板上显示的编号 */ int vis_code; /* 握手阶段发送的VIS字节 */ int width; /* 每行像素数 */ int height; /* 总行数 */ double sync_ms; /* 同步脉冲时长,毫秒 */ double porch_ms; /* porch时长,毫秒 */ int color_order[3]; /* 彩色分量发送顺序 */ } SSTV_MODE;vis_code是接收端识别模式的钥匙,发射端在正式图像之前会先发一段握手信号,其中就包含这个字节;接收端收完握手就知道接下来该按哪个模式的时序去等待同步脉冲。调试时最容易出问题的也是这里:发收两端如果用了不同的模式表,波形参数完全一致但vis_code对不上,接收端会显示"未知模式"。我在改动时一般先用mode_id固定收发,联调通过后再去管vis_code。
3.3 master源码里的三层结构
这类源码的目录虽然各不相同,但逻辑上永远是三层:IO层负责waveIn/waveOut、混音器电平读取;DSP层负责滤波、过零检测、同步搜索、像素频率到RGB的换算;UI层负责频谱显示、图像窗口、历史记录。读的时候不要从上往下读,要从下往上读:先看DSP层怎么把PCM变成一行像素,再看IO层怎么把声卡数据喂给DSP,最后才看UI层怎么把像素画出来。这和读ugui源码解析时先看像素刷屏接口再回看控件布局是一个套路:底层数据结构确定了,上层只是搬运工。
3.4 从master分支检出到编译通过
本地搭建的第一步是把分支检出到干净目录,然后只做一件事:找工程文件。
git checkout master git pull --ff-only # 列出工程文件,常见的是.sln、.vcxproj或老式.dsp/.vcproj ls -la | grep -E "\.(sln|vcxproj|dsp)$"找到.sln后用Visual Studio打开,或者用MSBuild命令行编译。老工程需要手动处理几个点,下面这张表是我实际编译这类源码时遇到最多的三类问题:
| 错误现象 | 常见原因 | 对策 |
|---|---|---|
| LNK2019:waveInOpen等符号无法解析 | 没有链接winmm.lib | 在链接器依赖里追加winmm.lib |
| 采样率总是不对,或声音明显变调 | 初始化代码里硬编码了44100,而SSTV用22050 | 把音频初始化参数改成22050Hz、单声道、16位 |
| 界面中文或调用字符乱码 | 工程用Unicode字符集,源码按ANSI编写 | 项目属性里把字符集改成"使用多字节字符集" |
编译通过并不代表能收到图,还需要一组稳定的声音输入输出。注意这类源码大多面向x86编译,x64通常也能过,但如果你在回调里用了DWORD_PTR强转指针,建议优先用x86跑通,省去Linux内核源码那种位宽适配的额外调试成本。编译只要过了,后面的验证才算开始。
4. 让master源码吐出第一张图:本地收发闭环
4.1 不接天线也能做的自收自发链路
没有电台也能完整验证MMSSTV,思路是让声卡的输出直接接到输入。常用的做法是用一个虚拟声卡把发射波形回灌给接收端,比如VB-CABLE这类工具,本质上就是一根软件音频线。搭建步骤和检查点如下:
| 步骤 | 操作 | 检查点 |
|---|---|---|
| 1 | 安装虚拟声卡并重启系统声音服务 | 系统声音设备里出现CABLE Input和CABLE Output两个设备 |
| 2 | 打开MMSSTV的发射窗口,音频输出选CABLE Input | 点击发射后电平条开始跳动,频谱窗出现能量 |
| 3 | 打开接收窗口,音频输入选CABLE Output | 状态从IDLE变成SYNC,行计数持续增长 |
| 4 | 选择一幅带大块色块的测试图发送 | 接收窗口出现可辨认的图像,颜色无明显错位 |
这套闭环的价值在于把射频问题完全剥离。如果虚拟声卡的链路能传输成功,说明源码的收发逻辑是通的;接下来才轮到电台和声卡电平的问题。我在第一次验证时曾经漏掉第2步,发射和接收都选了同一个物理声卡,结果就是笔记本喇叭放出来的声音又被麦克风收进去,在真机上形成啸叫,电平表全红。虚拟声卡的回灌路径就是为了避免这种自激。
4.2 用FFmpeg和Python做一帧客观判定
眼睛看有没有图不算严谨,我用一个更客观的方式验证:发射端生成一张左上角留白块的测试图,接收端解码得到BMP后,用Python检查那个区域的平均亮度。如果白块区域平均亮度高于某个阈值,说明从像素到波形再到像素的链路是闭环成立的。
# 如果发射端支持导出WAV,或你用虚拟声卡录下了音频, # 统一重采样到22050Hz单声道再喂给接收比较稳 ffmpeg -i mmsstv_tx.wav -ar 22050 -ac 1 -f wav mmsstv_tx_mono.wav# verify_rx.py - 检查接收图像左上角是否为白块 from PIL import Image img = Image.open("rx_img.bmp").convert("L") # 灰度量,0黑255白 patch = img.crop((0, 0, 20, 20)) # 发射端左上角20x20纯白 avg = sum(patch.getdata()) / (20 * 20) print(f"avg luminance = {avg:.1f}") assert avg > 200, f"白块平均亮度过低: {avg}"两个命令都要理解参数含义:-ar 22050把采样率强制到MMSSTV的基准采样率,避免虚拟声卡44.1kHz默认值在重采样时产生频率偏移;-ac 1确保单声道,双声道会让过零检测出现叠加的相位错误。Python脚本里crop的位置坐标必须和发射测试图的白块位置一致,否则亮度断言会落在旁边黑底上,得出假阳性或假阴性。这套组合也可以反过来当回归测试:每次改动源码后跑一遍,看平均亮度是否下降超过5个灰度级。
4.3 闭环失败时的三个排查点
如果收不到图或者图像破碎,先别急着改源码,按下面顺序排查。第一是削波:发射电平推到0dB以上,波形顶部被削平,过零检测会在一段平直区里失去过零事件,产生低频假频。观察电平条,最好保持在-6dB到-3dB。第二是采样率不匹配:虚拟声卡默认48kHz或44.1kHz,而MMSSTV按22050Hz计算每个像素的时长,频率会整体偏高。检查点是在接收端的频谱窗里看1900Hz同步脉冲是否落在1900Hz的刻度上。第三是缓冲过小:老代码默认512样本,在Windows 10以上系统很容易被驱动换成更大的包,回调触发的节奏不对,导致解码器把半个同步脉冲当成一整个。遇到这种问题,在声音设备属性里把默认格式调成"16位,22050Hz"通常能绕开大部分驱动重采样路径。
5. 进阶:改master源码的三个切入点
5.1 加一种自己的SSTV模式
在模式表里追加一行结构体,以Martin M1为底,把sync_ms改成7ms,porch_ms改成2ms,并分配一个你不知道会与标准模式冲突的vis_code。改完同步宽度后,接收端找同步的阈值也要跟着调,通常源码里会有一个同步脉冲最小宽度的常量,默认填5ms,改成4~6ms之间才能容忍新模式的抖动。我一般会先把收发两端都锁死在mode_id上,绕过vis_code识别,联调通过后再打开握手校验。
5.2 在像素频率上埋入频偏水印
在亮度到频率的映射处加一个固定频偏,接收端减回,就能在图像里嵌入一层对设备链路透明的水印。发射端映射改为f = F_LO + (F_HI - F_LO) * (pix / 255.0) + 37.0,接收端在过零检测后把频率减去37Hz再换算像素。这个37不是随便取的:它要大于频率检测分辨率的倒数,但又不能大到超过相邻像素的步进,否则会直接造成偏色。实际操作中先试10Hz,看接收像素的灰度误差分布,再决定往哪个方向加。
5.3 把解码出的行接给OpenCV
在"一行像素解完"的函数末尾增加一个回调,把行缓冲的首地址和宽度通过全局结构体交给另一个线程;UI线程负责把行数据复制到OpenCV的Mat里。这样MMSSTV就变成了一个实时图像采集前端,后续做目标识别、云台跟踪都顺手。注意回调里只能做memcpy级操作,任何锁和分配都会拖垮声卡缓冲归还的节奏。从一个稳定的master分支出发,这三个改造都能在半小时内看到效果,也足够让你把SSTV的信号链路彻底吃透。
本文还有配套的精品资源,点击获取