#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ USB CDC串口录音脚本 - 使用自定义协议 详细协议规范请参考:docs/usb_cdc_protocol.md """ import serial import argparse import sys import os import time import hashlib import struct import threading from datetime import datetime from enum import IntEnum class FrameType(IntEnum): """帧类型定义""" CMD_REQUEST = 0x01 CMD_RESPONSE = 0x02 AUDIO_DATA = 0x03 MD5_DATA = 0x04 class CommandID(IntEnum): """命令码定义""" START_RECORD = 0x01 STOP_RECORD = 0x02 QUERY_STATUS = 0x03 class StatusCode(IntEnum): """状态码定义""" OK = 0x00 ERROR = 0x01 BUSY = 0x02 UNSUPPORTED = 0x03 class ParseState(IntEnum): """解析状态机""" MAGIC1 = 0 MAGIC2 = 1 TYPE = 2 LENGTH = 3 DATA = 4 CHECKSUM = 5 class CDCProtocol: """CDC协议处理类""" MAGIC_MSB = 0xAA MAGIC_LSB = 0x55 HEADER_SIZE = 7 # 数据长度限制 - 区分命令帧和音频帧 MAX_CMD_DATA_SIZE = 64 # 命令/响应/MD5最大数据长度 def __init__(self): self.reset() def reset(self): """重置解析器状态""" self.state = ParseState.MAGIC1 self.frame_type = 0 self.data_length = 0 self.data = bytearray() self.length_buf = bytearray() self.checksum = 0 @staticmethod def calc_checksum(data): """计算XOR校验和""" checksum = 0 for byte in data: checksum ^= byte return checksum def build_frame(self, frame_type, data=b''): """构建协议帧""" # 构建帧头 frame = bytearray() frame.append(self.MAGIC_MSB) frame.append(self.MAGIC_LSB) frame.append(frame_type) # 添加长度(小端序) frame.extend(struct.pack('= 4: # 小端序解析长度 self.data_length = struct.unpack(' 0: self.state = ParseState.DATA else: self.state = ParseState.CHECKSUM elif self.state == ParseState.DATA: self.data.append(byte) if len(self.data) >= self.data_length: self.state = ParseState.CHECKSUM elif self.state == ParseState.CHECKSUM: self.checksum = byte # 音频帧(Type=0x03)的校验位固定为0x00,不进行校验 if self.frame_type == FrameType.AUDIO_DATA: # 音频帧不验证校验和,固定填充0x00 pass else: # 其他帧验证校验和 frame_for_check = bytearray() frame_for_check.append(self.MAGIC_MSB) frame_for_check.append(self.MAGIC_LSB) frame_for_check.append(self.frame_type) frame_for_check.extend(struct.pack(' 2 else b'' cmd_name = { CommandID.START_RECORD: "开始录音", CommandID.STOP_RECORD: "停止录音", CommandID.QUERY_STATUS: "查询状态" }.get(cmd_id, f"0x{cmd_id:02X}") status_name = { StatusCode.OK: "成功", StatusCode.ERROR: "失败", StatusCode.BUSY: "忙碌", StatusCode.UNSUPPORTED: "不支持" }.get(status, f"0x{status:02X}") print(f"[响应] {cmd_name}: {status_name}") # 如果是状态查询响应,解析详细信息 if cmd_id == CommandID.QUERY_STATUS and status == StatusCode.OK and len(response_data) >= 9: state = response_data[0] total_bytes = struct.unpack(' 0: data = self.serial.read(self.serial.in_waiting) # 逐字节解析 for byte in data: frame = parser.parse_byte(byte) if frame: with self.lock: self.handle_frame(frame) else: time.sleep(0.01) except Exception as e: print(f"\n[错误] 接收线程异常: {e}") break def start_recording(self): """开始录音""" # 打开输出文件 try: self.audio_file = open(self.output_file, 'wb') except IOError as e: print(f"文件错误: {e}", file=sys.stderr) return False # 重置统计 self.byte_count = 0 self.seq_num = 0 self.md5_ctx = hashlib.md5() self.device_md5 = None # 启动接收线程 self.running = True self.rx_thread = threading.Thread(target=self.receive_thread_func, daemon=True) self.rx_thread.start() # 发送开始录音命令 print("-" * 50) if not self.send_command(CommandID.START_RECORD): self.running = False if self.audio_file: self.audio_file.close() return False # 等待响应 time.sleep(0.5) self.recording = True print("开始接收音频数据... (按 Ctrl+C 停止)") print("-" * 50) return True def stop_recording(self): """停止录音""" if not self.recording: return print("\n\n停止录音...") print("-" * 50) # 发送停止录音命令 self.send_command(CommandID.STOP_RECORD) # 等待剩余数据和MD5 print("等待设备发送MD5...") time.sleep(2) # 停止接收线程 self.recording = False self.running = False if self.rx_thread: self.rx_thread.join(timeout=3) # 关闭文件 if self.audio_file: self.audio_file.close() self.audio_file = None # 计算本地MD5 local_md5 = self.md5_ctx.hexdigest() print("-" * 50) print(f"录音完成!") print(f"总字节数: {self.byte_count}") print(f"总帧数: {self.seq_num}") print(f"PC端MD5: {local_md5}") if self.device_md5: print(f"设备端MD5: {self.device_md5}") if local_md5 == self.device_md5: print("MD5校验: 通过 ✓") else: print("MD5校验: 失败 ✗") print("[警告] 数据可能在传输过程中损坏!") else: print("设备端MD5: 未收到") print("-" * 50) def run(self): """运行录音""" if not self.connect(): return False try: if not self.start_recording(): return False # 等待用户中断 while self.recording: time.sleep(0.1) except KeyboardInterrupt: print("\n\n用户中断") finally: self.stop_recording() self.disconnect() return True def main(): parser = argparse.ArgumentParser( description='USB CDC串口录音工具 - 使用自定义协议', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' 使用示例: # Linux %(prog)s -p /dev/ttyACM0 -b 115200 -o output.pcm # Windows %(prog)s -p COM3 -b 115200 -o output.pcm # 指定超时时间 %(prog)s -p /dev/ttyACM0 -b 921600 -o data.pcm -t 2 协议说明: 本工具使用自定义二进制协议进行通信,详见 docs/usb_cdc_protocol.md 命令流程: 1. PC发送"开始录音"命令 2. 设备响应确认 3. 设备持续发送音频数据帧(带序列号) 4. PC发送"停止录音"命令 5. 设备发送MD5校验值 6. PC对比MD5验证数据完整性 ''' ) parser.add_argument( '-p', '--port', required=True, help='串口号 (Linux: /dev/ttyACM0, Windows: COM3)' ) parser.add_argument( '-b', '--baudrate', type=int, required=True, help='波特率 (如: 9600, 115200, 921600)' ) parser.add_argument( '-o', '--output', required=True, help='输出文件路径 (.pcm)' ) parser.add_argument( '-t', '--timeout', type=float, default=1, help='串口读取超时时间(秒), 默认: 1' ) args = parser.parse_args() # 检查输出目录是否存在 output_dir = os.path.dirname(args.output) if output_dir and not os.path.exists(output_dir): print(f"创建输出目录: {output_dir}") os.makedirs(output_dir, exist_ok=True) # 创建录音器并运行 recorder = AudioRecorder(args.port, args.baudrate, args.output, args.timeout) success = recorder.run() sys.exit(0 if success else 1) if __name__ == '__main__': main()