从零实现CW Morse Code Trainer:莫尔斯码训练器的开源实践

从零实现CW Morse Code Trainer:莫尔斯码训练器的开源实践 简介面向业余无线电爱好者与莫尔斯电码初学者的开源练习工具CW Morse Code Trainer 主要用于将文本实时转为莫尔斯电码声音并随机生成符号序列供盲解码训练同时支持调节发送速度与法恩斯沃思压缩参数可帮助用户逐步提升抄收能力应对业余无线电执照考试中的相关考核。压缩包为 gz 格式共含 3 个文件可直接在 Windows 运行的 exe 程序、C 语言源代码以及 Readme 说明文本整体仅 72KB轻量易用。由于项目开源使用者既能直接运行训练也能阅读源码理解摩尔斯编码与音调生成逻辑甚至自行扩展功能或改进听感算法说明文档则降低了初次上手的门槛。已有 269 人学习下载适合从零开始接触莫尔斯电码、希望结合实践与原理进行系统练习的爱好者。 在40米波段上听到BY5XA用慢速CW呼叫我当时的抄收速度大概只有每分钟五六个字符对方发完呼号、信号报告、QTH我还在纸上纠结第一个字母到底是B还是T。这种挫败感估计每个刚入门的HAM都经历过。后来我搜了不少CW训练工具网页版、手机App、老外写的桌面软件都试了一圈最终还是决定自己动手写一个。折腾出来的这个CW Morse Code Trainer索性直接开源了让同样在练抄收的伙计们少走点弯路。这篇东西不打算写标准的项目介绍我直接把当时从零实现一个训练器时踩过的坑、算过的时序、写烂的音频代码、以及最后怎么组织开源仓库的经验全盘托出。如果你正在学CW或者想做一个自己的练习工具这篇应该能省下你不少时间。1. 为什么一个老HAM会去折腾CW训练器1.1 市面上的训练工具差口气在哪市面上的CW训练工具并不少但用下来总有几个问题让我很难坚持。网页版的最方便但浏览器对音频播放的精确时序控制其实很受系统调度影响尤其打开一堆标签页之后声音开始一顿一顿的。移动端App倒是不少可很多是把YouTube上那种WPM显示做得花里胡哨真正按Koch方法循序渐进、把每个字符的间隔控制得精确到毫秒级的少之又少。还有一个更现实的问题闭源工具你怎么知道它练你的节奏是对的有的软件所谓20WPM实际测下来字符内间隔完全不对要么点划比例失调要么字符间隔忽长忽短。CW学习里最核心的东西就是节奏机器本身在节奏上不准练得越多肌肉记忆反而越坏。1.2 这个开源项目到底要解决什么问题我自己想要的训练器核心诉求其实就几条。第一信号生成符合标准时序——点、划、间隔必须精确不能有抖动。第二音频要友好——不是那种尖锐刺耳的方波而是带包络的平滑正弦波。第三训练方法要科学——能支持Koch渐进法和Farnsworth间隔调整。第四也是最重要的一条代码得开源逻辑得透明你觉得哪个地方不对自己改就是了。CW Morse Code Trainer这个项目就这么来的。它不是一个庞大的软件而是一套用Python写的、走标准WAV输出和实时播放两条路的训练工具。你可以拿它当普通播放器用也可以把它嵌入自己的学习流程甚至改造成一个完整的CW学习平台。2. 先搞懂训练器在训练什么信号与时序2.1 莫尔斯码的时长关系比想象的严谨如果你以为莫尔斯码就是随便敲长短音那练CW一定会卡在某个速度上不去。标准莫尔斯码的时长关系其实非常清晰全部以一个点dit的时长作为基本单位一个点 1个单位时长一个划 3个单位时长字符内点和划之间间隔 1个单位时长字符与字符之间间隔 3个单位时长单词与单词之间间隔 7个单位时长这个关系不是谁拍脑袋定的它是CW信号能被稳定抄收的基础。你想想看如果发报的人点划比例不固定或者字符间隔忽大忽小抄收方的大脑就需要不断重新适应节奏非常消耗注意力。训练器的第一个核心任务就是把上述每一段时长精确地安排出来。我在写代码时用时间戳去规划整个音频流而不是边播放边拼接就是为了避免累计误差。2.2 WPM和有效速度PARIS标准说到速度很多人直接说我练到了20WPM但WPM到底怎么算的其实有讲究。业余无线电里常用的标准是PARIS系统也就是把单词PARIS作为基准。PARIS在莫尔斯码里一共占50个时间单位所以[ 1 \text{ WPM} 50 \text{ 时间单位/分钟} ]也就是说如果训练器显示20WPM一个时间单位就是 (60 / (20 \times 50) 0.06) 秒也就是60毫秒。一个点持续60ms一个划持续180ms字符间隔180ms单词间隔420ms。所有频率都是60ms的整数倍节奏感才出得来。这块计算是整个训练器的基础也是参数化设计的核心。代码里只需要用一个dit_duration变量去派生所有时长再根据不同WPM算出它的值就行。如果谁把WPM的定义写错了练出来的节奏一定不对。2.3 声音合成的细节为什么不能直接放方波理论上莫尔斯码只需要有音和无声两种状态最简单的方式是生成一段高频方波。但方波的频域特性决定了它听起来非常刺耳长时间听很容易疲劳。而且方波在开始时如果是突然从0跳到高电平会产生很明显的爆音click这在实际听感里像是一声啵非常干扰抄收。我在第一版代码里就用方波打开播放器一耳朵就被劝退了。后来改成正弦波并且给每个音加了5毫秒的淡入淡出包络。这样一个600Hz的CW音听起来就非常圆润长时间练习也不会觉得刮耳朵。别小看这5ms它在频域上能压掉绝大部分的高频分量效果立竿见影。3. 核心实现从字符表到能听的声音3.1 字符编码与时长计算第一步是把莫尔斯码表放进程序里。这里不需要什么复杂的数据结构一个字典就够了。每个字母和数字对应一串由点划组成的字符串点用.表示划用-表示。MORSE { A: .-, B: -..., C: -.-., D: -.., E: ., F: ..-., G: --., H: ...., I: .., J: .---, K: -.-, L: .-.., M: --, N: -., O: ---, P: .--., Q: --.-, R: .-., S: ..., T: -, U: ..-, V: ...-, W: .--, X: -..-, Y: -.--, Z: --.., 1: .----, 2: ..---, 3: ...--, 4: ....-, 5: ....., 6: -...., 7: --..., 8: ---.., 9: ----., 0: -----, }有了码表下一步就是给定WPM算出单位时长。我封装了一个简单的函数输入WPM直接返回秒数。这个函数后面所有地方都在用是全局的时间基准。def dit_duration(wpm: float) - float: return 60.0 / (wpm * 50.0)3.2 生成正弦波音频音频生成部分我选择用Python标准库的wave模块和struct直接写WAV文件。虽然NumPy能更快地生成音频数组但标准库的好处是零依赖任何人clone下来就能跑不用先装一堆科学计算包。对于核心演示和算法验证来说这个方式完全够用。import math import wave import struct SAMPLE_RATE 44100 BASE_FREQ 600 # 常用的CW音调频率可调 def tone(duration: float, freq: float BASE_FREQ, rate: int SAMPLE_RATE, volume: float 0.5) - bytes: n int(rate * duration) ramp int(rate * 0.005) # 5ms 淡入淡出 frames [] for i in range(n): env 1.0 if i ramp: env i / ramp elif i n - ramp: env (n - i) / ramp sample volume * env * math.sin(2 * math.pi * freq * i / rate) frames.append(struct.pack(h, int(sample * 32767))) return b.join(frames) def silence(duration: float, rate: int SAMPLE_RATE) - bytes: n int(rate * duration) return b\x00\x00 * n注意struct.pack(h, ...)是打包成16位有符号整数也就是CD音质的标准格式。env那一段就是淡入淡出包络从0平滑升到1再平滑降回0消掉爆音。3.3 一版可以跑的Python骨架下一步把编码、间隔和音频合成串起来。一个字符的音频就是按莫尔斯码序列逐个生成点或划并在它们之间插入1个单位的静音。多个字符之间插入3个单位静音。单词之间插7个单位。def char_to_audio(char: str, dit: float) - bytes: seq MORSE[char.upper()] out [] for i, symbol in enumerate(seq): if i 0: out.append(silence(dit)) if symbol .: out.append(tone(dit)) else: out.append(tone(3 * dit)) return b.join(out) def word_to_audio(word: str, wpm: float) - bytes: dit dit_duration(wpm) parts [] for i, c in enumerate(word): if i 0: parts.append(silence(3 * dit)) parts.append(char_to_audio(c, dit)) return b.join(parts)把这三个函数拼起来一个最小可用的CW生成器就完工了。它能把任意单词转成准确节奏的标准WAV音频。如果你只想给单个字符做循环播放只需要在外部再加一层for循环就行。3.4 加入Farnsworth间隔的完整逻辑初学者直接上20WPM的标准节奏几乎不可能跟得上。这时候Farnsworth方法是关键它让字符内部的点划保持高速率发射但把字符与字符之间的间隔拉长让大脑有足够时间处理听到的信号。训练器必须支持显示速度和实际字符内速度分开设置。def farnsworth_word_audio(word: str, char_wpm: float, eff_wpm: float) - bytes: dit dit_duration(char_wpm) eff_dit dit_duration(eff_wpm) # 标准字符间隔是3个ditFarnsworth额外间隔按有效速度补充 standard_gap 3 * dit target_word_time eff_dit * 50 * len(word) # 近似整词时长 parts [] for i, c in enumerate(word): if i 0: # 计算剩余字符数把多出来的时间均摊到每个字符间隔 remaining_chars len(word) - i extra_gap (target_word_time - standard_gap * remaining_chars) / remaining_chars parts.append(silence(max(extra_gap, standard_gap))) parts.append(char_to_audio(c, dit)) return b.join(parts)这个实现里的target_word_time是一个近似模型等分间隔够用了。真正的MOSPmachine-operated send practice标准会更严格地逐字符计算但对你写训练器来说这种均摊方案听起来就很自然。国内很多CW群都在用类似方法来辅助练习。4. 训练功能设计工具要能让人真正练成4.1 Koch训练法先学两个字符再说有了底层音频生成剩下的就是怎么把它组织成一个对学习真正有效果的训练系统。我在设计时第一个引入了Koch法。Koch法的核心思想非常反直觉不要从A到Z挨个学而是从高频率字符开始每次只加两个新字符。训练实现逻辑维护一个字符集列表先从K和M开始播放随机排列的字符组每组5个字符当正确率稳定超过90%时再加入下一个字符。很多训练的App把这个问题做复杂了但实际上用随机数就能实现。关键是训练器得能随机组合、不重复、并且能控制每组字符数量。import random def generate_group(chars: str, group_len: int 5) - str: return .join(random.choice(chars) for _ in range(group_len))学习CW最忌惮的是背符号表。Koch法的高明之处在于它的速度一开始就设定在目标WPM字符少时大脑完全能处理得过来但随着字符变多你会形成一个完整的声音-字母映射而不是把点划数完再翻译成文字。4.2 模拟真实通联随机呼叫与交换信息学会字符后真正的瓶颈是在真实通联中抄收。业余无线电的CW通联有一种基本格式先呼叫、然后交换信号报告、名字、QTH最后结束。如果训练器只会随机播放字符练到后期作用会快速递减因为真实通联里有大量重复的固定语序比如RST 599、QTH BeiJing、73。我的做法是做一个模拟通联生成器。它从呼号前缀池里随机生成合法的业余电台呼号配合常用Q简语拼出一次完整通联内容然后按真实通联的语速播放。这样的好处是你练的不只是孤立字符而是训练整句预期——当你听到CQ CQ时大脑会自动进入下面应该是呼号的预期状态。def generate_qso(): callsign fB{random.choice(ABCDEFGH)}{random.randint(0, 9)}{random.randrange(100, 999)} return fCQ CQ DE {callsign} PSE K连续抄收几天这种模拟通联再去频率上守听至少不会再紧张到连自己呼号都抄不清。4.3 记录与回放训练日志的价值训练器的另一个容易被忽略的功能是记录。我不是说那种简单的今天练了多少分钟的打卡而是要有符号级别的记录。哪个字符在多少WPM下抄错了错成了什么这些数据非常关键。因为CW错抄通常有规律比如B和6经常混淆、F和L容易听错知道了自己的混淆模式专项练习就特别有效果。我在项目里保存了一个CSV日志每次播放一个字符组就把字符序列、WPM、用户的输入结果、对错情况全部记下来。这玩意儿用不了几行代码但是对长期训练的指导意义非常大。如果你自己写训练器强烈建议从一开始就加上这个功能别等练了两周再去头疼为什么我总觉得C和N分不清。5. 我踩过的坑和验证效果的方法5.1 爆音、时基漂移和音量失真开发过程中最常见的三个问题估计你们也会遇到。第一个是爆音原因在上面讲过了方波直接输出或正弦波没有淡入淡出都会引起。第二个是时基漂移如果我用播放完一个音再排下一个音的模式每个音的播放延迟可能不大但累积起来一个句子后面就明显慢了。解决方法是彻底抛弃边算边播先把整段音频的采样数据全部算好一次性写入WAV或者一次性交给播放器。第三个是音量失真把正弦波振幅定成1.0再乘32767写入时就直接削波破音了所以振幅要留余量我用的0.5也就是半幅就足够清晰了。5.2 怎么判断自己真的在进步很多人练CW练到某个速度就停滞了怀疑训练器是不是不准。我自己的经验是记录首次抄收正确率而不是听了几分钟。正确率低于70%说明速度太激进保持在80%-90%的区间才是最佳学习区。不要追求每组都对那说明太简单了没有挑战性。一个可量化的标准是在同一WPM下连续抄收5组随机字符每组5个至少4组全部正确就可以提升1-2WPM。这个标准不算严格但能有效防止假性进步。所谓假性进步就是你能听出点划但是要反应半天才能写下字母这在实时通联里等于没学会。真正的熟练是不假思索地直接写出字符训练器的回放功能在这里也很有用。5.3 瓶颈期怎么办练到15WPM左右几乎每个人都会遇到瓶颈。这时别再硬提速把Farnsworth间隔打开用20WPM的字符内速度配8WPM的有效速度练一段时间让大脑适应高速音素。之后再一点点压缩间隔你会发现真实速度不知不觉就上去了。我在开源项目中把这两个参数完全暴露出来就是希望练习者能灵活调整而不是死磕一个固定模式。6. 开源发布最后一公里决定项目生死6.1 仓库结构和README写得像给用户而不是给自己代码写完了开源不只是在GitHub上点一个Public按钮。如果README写得像写给自己的备忘录项目基本就废了。我在第一次开源时吃过这个亏README只有三行字和一个代码路径后来收到好几个Issue问怎么跑参数是什么才意识到文档本身就是项目的一部分。现在项目仓库的结构大概是这样的cw_trainer/主模块包含音频生成、训练调度、日志记录examples/可运行的示例脚本clone下来直接python examples/play_word.py CQdocs/训练方法说明和参数调优建议tests/用于验证时序公式的单元测试这块不少人忽略了但时序逻辑绝对是值得测试的README.md从这是什么到怎么跑再到怎么自定义全部写清楚6.2 许可证怎么选开源许可证值得单独说一下。我给这个项目选的是MIT因为它的约束最少别人拿去用、改、甚至封装进商业产品都不需要特别复杂的合规流程。如果你希望别人改进后必须把源码也开源出来那GPL-3.0更合适。国内现在很多开发者在Gitee上做开源平台的选择上注意一下Gitee默认推荐的一些许可证另外国内项目最好在README里直接写清楚许可证类型和联系方式避免后期扯皮。从项目影响力来说MIT这种宽松许可证更容易被社区接受和传播尤其CW训练器这种工具类项目大家拿过去改改就能适配自己的课程体系使用门槛越低获得的反馈就越多。6.3 让开源项目真正被用起来最后说说分发。开源项目不是发布完就结束了真正让它活起来的是使用者反馈。我在项目里留了两个入口一个是Issues另一个是Discussions。Issues用来汇报 bug比如20WPM下音频出现抖动Discussions用来讨论训练方法比如有没有人试过用这个练Koch到25WPM。我还特意加了一个--version参数输出当前版本号和字符集大家跑出问题一报版本号我就能快速定位。这个习惯看起来很基础但真实使用中极大减少了沟通成本。开源这件事最后拼的不是技术而是你愿不愿意把自己挖过的坑、写过的烂逻辑、调过的参数都坦率地讲出来让后来的人踩着你的肩膀过去。CW Morse Code Trainer对我来说不只是一个训练工具更是对如何通过精确时序训练听觉反馈的一次实践。如果你也在写类似的东西记住一点节奏的准确性永远优先于功能的丰富性。先把点划间隔做到位再往上加功能也不迟。本文还有配套的精品资源点击获取