1. 项目概述:重温经典通信,用现代微控制器实现TTY音频发射
如果你对老式通信设备、无线电技术或者嵌入式音频处理感兴趣,那么TTY(电传打字机)绝对是一个迷人的研究对象。它不像今天的5G或Wi-Fi那样广为人知,但在上世纪60年代到90年代,它是听障人士通过电话线进行文本交流的生命线。整个系统的核心,就是将我们敲击的字母和数字,转换成一串特定的“哔哔”声,通过电话听筒发送出去。最近,我利用手头的Adafruit CLUE开发板和CircuitPython,成功复现了这个过程,制作了一个能生成标准TTY音频信号的发射器。这不仅仅是一次怀旧,更是对频移键控(FSK)、博多码(Baudot code)这些基础通信原理的绝佳实践。通过这个项目,你能深刻理解数字信号如何被“调制”成声音,以及一个完整的通信协议是如何在资源极其有限的嵌入式系统中实现的。
整个项目的硬件核心是一块Adafruit CLUE,它基于Nordic的nRF52840芯片,自带蓝牙,性能足够处理实时音频生成。软件层面则完全依靠CircuitPython,这种对初学者极其友好的嵌入式Python实现,让我们可以抛开复杂的底层寄存器操作,直接关注协议逻辑和信号生成。最终,这个发射器不仅能通过扬声器播放TTY音频,还能通过蓝牙从手机接收文本并发送,甚至有一个简单的图形界面供选择预设信息。下面,我就把从协议解析、电路搭建到代码编写的完整过程,以及其中踩过的坑和总结的技巧,毫无保留地分享出来。
2. TTY通信协议与博多码深度解析
要动手实现一个发射器,第一步必须是彻底吃透它要遵循的通信规则。TTY协议是一个精妙的设计,在有限的带宽(电话线)和简单的硬件条件下,实现了可靠的文本传输。
2.1 5位博多码:在32种组合中容纳所有字符
博多码诞生于1870年的电报时代,它最核心的挑战是:如何用仅有的5位二进制数(2^5=32种组合)来表示远超32个的字母、数字和符号?它的解决方案非常聪明:引入了“模式切换”的概念。
你可以把LTRS(字母)模式和FIGS(图形,即数字符号)模式想象成键盘上的“Shift”键。协议预留了两个特殊的5位代码作为“切换键”:
- LTRS模式字符:二进制
11111。发送它之后,接收端会将后续收到的所有字符解释为字母。 - FIGS模式字符:二进制
11011。发送它之后,接收端会将后续收到的所有字符解释为数字或符号。
例如,5位码01101在LTRS模式下代表字母“F”,而在FIGS模式下则代表感叹号“!”。因此,要发送“F!”,实际的发送序列是:LTRS码(11111)->F的码(01101)->FIGS码(11011)->!的码(01101)。
注意:协议规定,即使在连续发送同一模式字符时,每发送72个字符后也必须重新发送一次当前模式字符。这是为了对抗可能发生的传输错误导致模式失步,确保接收端始终知道当前处于哪种解码状态。在代码实现中,这是一个必须严格遵守的细节。
2.2 频移键控调制:用两种音调代表0和1
TTY采用标准的频移键控调制方式:
- 二进制 1:用1400Hz(±5%)的音频正弦波表示。
- 二进制 0:用1800Hz(±5%)的音频正弦波表示。
- 比特持续时间:每个比特(无论是1还是0)的持续时间为20ms。这决定了数据传输速率为 1/0.02 = 50 比特/秒,虽然以今天的眼光看慢如蜗牛,但在当时足够可靠。
这里有一个关键点:1400Hz的“1”音调,同时也被用作“载波”信号。在发送每个字符之前,需要先发送150ms的1400Hz载波,用于唤醒和同步远端的接收器。
2.3 完整的字符发送帧结构
发送一个字符不是简单地把5位码发出去就完事了,它需要包装成一个完整的“帧”。以发送字母“A”(其5位码为00011,注意传输顺序是先传最低有效位,即11000)为例,其完整的音频时序如下:
- 载波(Carrier):持续150ms的1400Hz长音。
- 起始位(Start Bit):1个比特时间的二进制0(即1800Hz音调),持续20ms。这是一个固定的同步信号,告诉接收方:“字符数据马上开始”。
- 数据位(Data Bits):从最低有效位(LSB)开始,依次发送5位博多码。对于“A”(
11000),发送顺序是:1(1400Hz) ->1(1400Hz) ->0(1800Hz) ->0(1800Hz) ->0(1800Hz)。每位持续20ms。 - 停止位(Stop Bit):至少1.5个比特时间的二进制1(即1400Hz音调),协议要求最少30ms。它标志着字符帧的结束,并确保接收电路有足够时间复位。
所以,字母“A”的完整音频序列是:150ms的1400Hz载波 + 20ms的1800Hz起始位 + 5*20ms的11000数据位 + 至少30ms的1400Hz停止位。
理解了这个时序图,就等于拿到了TTY协议的“密码本”。我们的代码任务,就是精确地按照这个时间线,控制开发板发出对应频率的声音。
3. 硬件搭建与CircuitPython环境准备
理论清晰之后,就要动手搭建硬件环境。这个项目对硬件要求很简洁,核心是能运行CircuitPython并生成精确音频信号的微控制器。
3.1 硬件清单与选型考量
我使用的核心设备是Adafruit CLUE nRF52840 Express。选择它有几个原因:
- 强大的处理器:nRF52840芯片主频足够,能轻松处理实时生成两个频率正弦波的任务,不会出现卡顿或时序错误。
- 丰富的内存:CircuitPython运行需要一定内存,CLUE的配置绰绰有余。
- 集成蓝牙LE:这为我们后续实现手机蓝牙输入功能提供了硬件基础,无需额外模块。
- 内置传感器和屏幕:虽然本项目未全部用到,但为扩展功能(如基于传感器的自动消息发送)留足了空间。
音频输出部分,我强烈推荐使用Adafruit STEMMA Speaker扩展板,而不是直接驱动CLUE板载的小扬声器。原因如下:
- 音质与音量:板载扬声器功率小,音量不足,且音质单薄。STEMMA Speaker板集成了一个小型D类音频放大器,能驱动更大、音质更好的扬声器,产生的1400/1800Hz信号更清晰、稳定,这对于TTY接收端正确解码至关重要。
- 连接便利:它采用STEMMA QT/JST PH 3针接口,通过配套的鳄鱼夹线可以快速、可靠地连接到CLUE,避免焊接。
完整的物料清单:
- Adafruit CLUE - nRF52840 Express 开发板 x1
- Adafruit STEMMA Speaker 扬声器放大器板 x1
- 配套的 JST PH 3Pin 转鳄鱼夹连接线 x1
- USB数据线(用于供电和编程) x1
- TTY接收机(或一部普通电话+录音设备用于测试) x1
- (可选)橡胶筋,用于固定扬声器到电话听筒
3.2 硬件连接步骤
连接非常简单,遵循“电源-地-信号”三线法则:
- 将JST连接线插入STEMMA Speaker板,注意防呆口方向。
- 用鳄鱼夹连接CLUE:
- 白色线(信号)-> CLUE板边缘连接器上标有“#0”的引脚(对应
board.A0,但在代码中我们使用board.A2的PWM音频输出,这里需要确认你的CLUE版本,通常A0/A1是模拟输入,A2是PWM音频输出。请以代码和板子丝印为准)。 - 红色线(电源 VCC)-> CLUE板上任意3V引脚。
- 黑色线(地 GND)-> CLUE板上任意GND引脚。
- 白色线(信号)-> CLUE板边缘连接器上标有“#0”的引脚(对应
实操心得:在连接前,最好用万用表通断档检查一下线缆。我曾遇到过一根线内部接触不良,导致声音断断续续,排查了半天才发现是硬件问题。另外,如果使用CLUE的板载扬声器(
board.SPEAKER),需要在代码中修改输出引脚,并意识到音量可能不足以驱动某些TTY设备的麦克风。
与TTY接收机的耦合:将STEMMA Speaker的扬声器面紧紧贴在TTY设备电话听筒的麦克风(送话器)位置。可以用两根橡胶筋十字交叉固定,确保接触稳固且减少外部振动噪音。这是声学耦合的关键,松动的接触会极大衰减信号。
3.3 CircuitPython固件刷写与库安装
- 下载固件:访问CircuitPython官网,找到CLUE对应的最新
.uf2固件文件并下载。 - 进入引导加载程序模式:用USB线连接CLUE和电脑。快速双击CLUE板上的RESET按钮。此时板载的NeoPixel LED会变绿,电脑上会出现一个名为CLUEBOOT的U盘。
- 刷写固件:将下载的
.uf2文件拖入CLUEBOOT盘符。完成后,CLUEBOOT盘符会消失,出现一个新的名为CIRCUITPY的盘符。这表明CircuitPython已成功运行。 - 安装必要的库:本项目需要用到
audiocore和audiopwmio来生成音频,如果使用蓝牙或GUI版本,还需要adafruit_ble、adafruit_display_shapes、adafruit_display_text和adafruit_clue等库。最简单的方法是下载Adafruit提供的“项目捆绑包”(Project Bundle),它包含了所有必需的库文件和code.py主程序。解压后,将其中的lib文件夹和code.py文件复制到CIRCUITPY盘的根目录即可。
4. 核心代码实现与逐行解析
硬件就绪后,最核心的部分就是代码。我们将从基础版本开始,彻底拆解其如何用CircuitPython“演奏”出TTY协议。
4.1 正弦波生成:音频信号的源头
TTY协议要求纯净的1400Hz和1800Hz正弦波。在微控制器上,我们无法存储完整的音频文件,需要实时计算。这里采用预计算一个周期正弦波样本表,然后通过改变播放采样率来得到不同频率的方法。
import time import math import array import board from audiocore import RawSample import audiopwmio # 正弦波查表参数设置 SIN_LENGTH = 100 # 一个正弦波周期的采样点数。点数越多,波形越平滑,但计算和内存占用也越大。100是一个在质量和效率间很好的平衡点。 SIN_AMPLITUDE = 2 ** 12 # 正弦波的振幅,对应PWM的占空比范围。32768是16位音频的最大值,这里取8192左右能获得较大音量且不失真。 SIN_OFFSET = 32767.5 # 直流偏置,将正弦波向上平移,使其值都在0-65535(16位)的正范围内,因为PWM输出不能为负。 DELTA_PI = 2 * math.pi / SIN_LENGTH # 计算每个采样点的相位增量。 # 生成一个周期的正弦波样本数组 sine_wave = [ int(SIN_OFFSET + SIN_AMPLITUDE * math.sin(DELTA_PI * i)) for i in range(SIN_LENGTH) ] # 关键技巧:通过改变采样率来生成不同频率 # RawSample对象播放sine_wave数组时,其实际输出频率 = sample_rate / len(sine_wave) # 因此,要得到1800Hz,采样率需设为 1800 * SIN_LENGTH # 要得到1400Hz,采样率需设为 1400 * SIN_LENGTH tones = ( RawSample(array.array("H", sine_wave), sample_rate=1800 * SIN_LENGTH), # 二进制 0 RawSample(array.array("H", sine_wave), sample_rate=1400 * SIN_LENGTH), # 二进制 1 / 载波 ) bit_0 = tones[0] bit_1 = tones[1] carrier = tones[1] # 载波与二进制1同频率注意事项:
SIN_AMPLITUDE的值需要根据你的扬声器和放大器调整。太小则音量微弱,TTY设备可能无法识别;太大(接近32768)会导致波形削顶失真,产生大量谐波,干扰主频信号。建议从2**12(4096) 开始,逐步调大测试。
4.2 博多码字符集定义
在代码中,我们用两个元组来定义LTRS和FIGS字符集。元组的索引号(0-31)直接对应该字符的5位二进制码。注意,索引0对应的是5位码00000,索引31对应11111。
LTRS = ( "\b", "E", "\n", "A", " ", "S", "I", "U", "\r", "D", "R", "J", "N", "F", "C", "K", "T", "Z", "L", "W", "H", "Y", "P", "Q", "O", "B", "G", "FIGS", "M", "X", "V", "LTRS", ) FIGS = ( "\b", "3", "\n", "-", " ", "-", "8", "7", "\r", "$", "4", "'", ",", "!", ":", "(", "5", '"', ")", "2", "=", "6", "0", "1", "9", "?", "+", "FIGS", ".", "/", ";", "LTRS", ) current_mode = LTRS # 初始模式设置为字母模式 char_count = 0 # 字符计数器,用于每72字符重发模式码4.3 协议底层函数实现
这几个函数是构建发送时序的基石,每一个都对应协议中的一个时间片段。
dac = audiopwmio.PWMAudioOut(board.A2) # 初始化PWM音频输出到A2引脚 def baudot_bit(pitch=bit_1, duration=0.022): """发送一个比特。pitch指定频率(bit_0或bit_1),duration指定持续时间(秒)。""" dac.play(pitch, loop=True) # 开始循环播放指定的音频样本 time.sleep(duration) # 保持指定的比特时间 # 注意:这里没有调用 dac.stop(),停止由上层函数控制,以实现精确时序拼接。 def baudot_carrier(duration=0.15): """发送载波信号。""" baudot_bit(carrier, duration) dac.stop() # 载波发送完毕后明确停止播放 def baudot_start(): """发送起始位(总是二进制0)。""" baudot_bit(bit_0) # 默认duration=0.022,即20ms def baudot_stop(): """发送停止位(总是二进制1,至少30ms)。""" baudot_bit(bit_1, 0.04) # 这里用了40ms,略长于最低要求,确保可靠。 dac.stop()4.4 核心:字符与消息发送函数
send_character函数严格按照帧结构组装一个字符的音频。
def send_character(value): """发送一个5位编码的字符。value是字符在LTRS或FIGS列表中的索引(0-31)。""" baudot_carrier() # 150ms载波 baudot_start() # 20ms起始位 (0) for i in range(5): # 发送5个数据位,从LSB开始 bit = (value >> i) & 0x01 # 位操作:将value右移i位,然后取最低位 baudot_bit(tones[bit]) # 根据bit值(0或1)发送对应频率 baudot_stop() # 40ms停止位 (1) baudot_carrier() # 非标准但有效的做法:再发一段短载波,使字符间间隔更清晰send_message函数则是面向用户的接口,处理字符串,自动处理模式切换和72字符规则。
def send_message(text): global char_count, current_mode for char in text: # 1. 跳过无法识别的字符 if char not in LTRS and char not in FIGS: print(f"未知字符被跳过: {char}") continue # 2. 检查并处理模式切换 if char not in current_mode: # 如果目标字符不在当前模式集中 if current_mode == LTRS: print("切换到FIGS模式") current_mode = FIGS send_character(current_mode.index("FIGS")) # 发送FIGS切换码 else: print("切换到LTRS模式") current_mode = LTRS send_character(current_mode.index("LTRS")) # 发送LTRS切换码 # 3. 每72字符或消息开头,重发当前模式码(防失步) if char_count >= 72 or char_count == 0: print("重发模式码") if current_mode == LTRS: send_character(current_mode.index("LTRS")) else: send_character(current_mode.index("FIGS")) char_count = 0 # 重置计数器 # 4. 发送目标字符本身 print(f"发送: {char}") send_character(current_mode.index(char)) time.sleep(char_pause) # 字符间可调间隔,使听起来不那么急促 char_count += 14.5 主循环与测试
基础版本的主循环简单地发送几条测试信息。
char_pause = 0.1 # 字符间间隔0.1秒 while True: send_message("\nADAFRUIT 1234567890 -$!+='()/:;?,. ") time.sleep(2) send_message("\nWELCOME TO JOHN PARK'S WORKSHOP!") time.sleep(3) send_message("\nWOULD YOU LIKE TO PLAY A GAME?") time.sleep(5)将代码上传到CLUE后,将扬声器对准电话听筒或录音设备,运行程序,你应该能听到一串有节奏的“哔哔”声。用手机录音后,可以用音频分析软件(如Audacity)观察频谱,应该能看到清晰的1400Hz和1800Hz的突发信号。
5. 功能扩展:蓝牙LE与图形界面控制
基础版本实现了核心功能,但每次修改信息都要重写代码。我们可以利用CLUE的蓝牙和屏幕,让它变得更实用、更有趣。
5.1 蓝牙LE发射器版本
这个版本让CLUE变成一个蓝牙串口(UART)从设备。你可以在手机上下载Adafruit Bluefruit LE Connect应用,连接后通过其UART功能直接输入文本,CLUE收到后实时转换为TTY音频发出。
代码改动主要集中在初始化蓝牙和修改主循环:
import time import math import array import board import audiopwmio from audiocore import RawSample from adafruit_ble import BLERadio from adafruit_ble.advertising.standard import ProvideServicesAdvertisement from adafruit_ble.services.nordic import UARTService # 蓝牙设置 ble = BLERadio() uart_server = UARTService() advertisement = ProvideServicesAdvertisement(uart_server) ble._adapter.name = "TTY_MACHINE" # 设置蓝牙设备名称 # ... (正弦波生成、字符集、发送函数等与基础版相同) ... char_pause = 0.0 # 蓝牙输入时,字符间间隔可以设为0以求最快速度 while True: print("等待蓝牙连接...") send_message("\nWAITING...\n") ble.start_advertising(advertisement) # 开始广播 while not ble.connected: # 阻塞等待连接 pass ble.stop_advertising() # 连接后停止广播 print("已连接") send_message("\nCONNECTED\n") while ble.connected: # 连接保持循环 if uart_server.in_waiting: # 检查是否有数据从手机传来 raw_bytes = uart_server.read(uart_server.in_waiting) textmsg = raw_bytes.decode().strip() # 解码字节为字符串 print(f"收到文本: {textmsg}") send_message("\n") # 发送换行 send_message(textmsg.upper()) # TTY通常使用大写字母,这里统一转换 # 断开连接后 print("断开连接") send_message("\nDISCONNECTED\n")操作流程:
- 将蓝牙版本的代码上传至CLUE。
- 手机打开Bluefruit LE Connect应用,扫描并连接名为“TTY_MACHINE”的设备。
- 进入应用中的“UART”标签页。
- 在输入框键入任何信息(如“HELLO WORLD 123”),点击发送。
- CLUE的扬声器应立即开始播放对应的TTY音频信号。
避坑技巧:蓝牙传输的字符串可能包含小写字母或TTY字符集外的符号。代码中通过
textmsg.upper()转换为大写,并通过send_message函数内的检查跳过未知字符。在实际使用中,最好在手机端做一个输入过滤,提示用户只输入大写字母、数字和有限符号,体验会更流畅。
5.2 图形界面版本
如果你觉得连手机都麻烦,CLUE自带的屏幕和按钮可以打造一个独立的离线消息发送器。这个版本在屏幕上显示几条预设信息,用A键循环选择,B键发送。
import time import math import array import board from audiocore import RawSample import audiopwmio import displayio from adafruit_display_shapes.circle import Circle from adafruit_clue import clue # 引入CLUE库,方便使用按钮和屏幕 from adafruit_display_text import label import terminalio # 定义四条预设信息,注意长度不要超出屏幕 messages = [ "HELLO FROM ADAFRUIT INDUSTRIES", "12345678910 -$!+='()/:;?", "WOULD YOU LIKE TO PLAY A GAME?", "WELCOME TO JOHN PARK'S WORKSHOP", ] # 初始化显示群组和界面元素(创建标题、信息列表、选择指示圆点等) # ... (具体的显示初始化代码较长,主要是配置屏幕、颜色、文本标签和位置) ... message_pick = 0 # 当前选中的信息索引 dot_y = [52, 82, 112, 142] # 选择圆点对应的Y坐标 while True: # A键:选择下一条信息 if clue.button_a: message_pick = (message_pick + 1) % 4 # 在0-3之间循环 dot.y = dot_y[message_pick] # 移动选择指示器 time.sleep(0.4) # 按键防抖 # B键:发送当前选中的信息 if clue.button_b: dot.fill = VFD_GREEN # 发送时改变圆点颜色作为反馈 send_message(messages[message_pick]) dot.fill = VFD_BG # 发送完毕恢复颜色这个版本极大地提升了设备的独立性和可玩性,你可以自定义messages列表,放入任何你想发送的、符合TTY字符集的句子。
6. 调试、优化与常见问题排查
在实际制作和测试过程中,你几乎一定会遇到一些问题。下面是我总结的常见故障点和解决方案。
6.1 音频信号问题排查表
| 现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 完全没有声音 | 1. 扬声器未连接或损坏。 2. 代码中音频输出引脚配置错误。 3. 音量参数 SIN_AMPLITUDE设置过低。4. 程序未运行或卡住。 | 1. 检查鳄鱼夹连接是否牢固,特别是信号线(白线)是否接到正确的GPIO(如A2)。2. 尝试用 board.SPEAKER替换board.A2测试板载扬声器。3. 逐步增大 SIN_AMPLITUDE值(如从2**15开始)。4. 检查CLUE上的RGB LED,程序运行时它应有颜色变化。通过串口监视器查看 print输出。 |
| 声音失真、破音 | SIN_AMPLITUDE值过大,导致PWM输出饱和削顶。 | 逐步减小SIN_AMPLITUDE值(如尝试2**11或2**10),直到声音清晰。 |
| TTY接收端无法解码或乱码 | 1.时序不准:比特持续时间、载波/停止位长度偏差太大。 2.频率不准:生成的1400/1800Hz频率偏差超出±5%。 3.音量问题:信号太弱或太强。 4.声学耦合差:扬声器与听筒接触不紧密或有空隙。 | 1. 用逻辑分析仪或高级音频软件(如Audacity)测量音频波形,核对每个阶段的时间长度是否符合协议(载波150ms,比特20ms等)。微调代码中的duration参数。2. 用频率计或Audacity的频谱分析功能,检查生成的音频中心频率是否在1330-1470Hz和1710-1890Hz范围内。 3. 调整 SIN_AMPLITUDE和扬声器音量(如果可调),并确保扬声器正对听筒麦克风中心。4. 使用橡胶筋紧密固定,并尝试在安静环境中测试。 |
| 蓝牙连接不稳定或无法发送 | 1. 手机蓝牙未打开或距离过远。 2. 未安装或正确使用Adafruit Bluefruit LE Connect应用。 3. 代码中蓝牙服务初始化错误。 | 1. 确保手机蓝牙已开,并将CLUE放在附近(1-2米内)。 2. 确认从App Store/Google Play安装了正版应用,并在UART界面发送信息。 3. 检查是否将蓝牙版本的所有必要库文件(如 adafruit_ble)正确复制到了CLUE的lib文件夹。 |
| 发送字符顺序错误 | 博多码的LSB-first(先传最低位)顺序弄反。 | 检查send_character函数中的循环:for i in range(5): bit = (value >> i) & 0x01。这确保了从i=0(最低位)开始发送。如果顺序反了,接收到的字符会是乱码。 |
6.2 性能与稳定性优化建议
- 时序精度:CircuitPython的
time.sleep()函数精度有限,且可能被其他中断轻微影响。对于20ms这样的关键时序,实测下来0.022比0.02更稳定。如果追求极致精度,可以考虑使用ticks相关函数进行更底层的时间控制,但代码复杂度会增加。 - 电源管理:如果使用电池供电,长时间运行后电压下降可能导致音频频率轻微漂移。确保使用电量充足的电池,或直接使用USB供电进行关键测试。
- 代码效率:预计算正弦波表(
sine_wave)和RawSample对象是明智之举,避免了在发送每个比特时进行实时数学计算,保证了音频生成的实时性。 - 扩展思考:目前的代码是“发射器”。一个更完整的项目是制作一个“收发器”,即增加麦克风输入和音频解码功能,让CLUE也能接收并显示来自真实TTY设备的信号。这需要用到ADC采样、傅里叶变换(FFT)或过零检测等算法来识别1400Hz和1800Hz,会是一个更大的挑战,但也更有成就感。
这个项目就像一座桥梁,连接了古老的通信技术和现代的嵌入式开发工具。当你亲手让一串代码控制硬件,发出那些标准的、曾承载无数信息的音频信号时,你对通信原理的理解就不再停留在书本公式上了。更重要的是,CircuitPython极大地降低了实现门槛,让爱好者能够聚焦于协议逻辑和应用创意本身。希望这份详细的拆解能帮你顺利复现,甚至激发你更多的改造灵感,比如将它集成到某个艺术装置中,或是作为一个教育工具来演示数字通信的基本原理。