Files
arcs/tools/audio/serial_capture.py
2026-08-13 16:50:52 +08:00

559 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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('<I', len(data)))
# 添加数据
frame.extend(data)
# 计算并添加校验和
checksum = self.calc_checksum(frame)
frame.append(checksum)
return bytes(frame)
def build_cmd_request(self, cmd_id, params=b''):
"""构建命令请求帧"""
data = bytearray()
data.append(cmd_id)
data.extend(params)
return self.build_frame(FrameType.CMD_REQUEST, bytes(data))
def parse_byte(self, byte):
"""
解析单个字节
返回None=需要更多数据dict=帧解析完成
"""
if self.state == ParseState.MAGIC1:
if byte == self.MAGIC_MSB:
self.state = ParseState.MAGIC2
# 否则继续等待
elif self.state == ParseState.MAGIC2:
if byte == self.MAGIC_LSB:
self.state = ParseState.TYPE
else:
# 魔数错误,重新开始
self.reset()
if byte == self.MAGIC_MSB:
self.state = ParseState.MAGIC2
elif self.state == ParseState.TYPE:
self.frame_type = byte
self.state = ParseState.LENGTH
self.length_buf = bytearray()
elif self.state == ParseState.LENGTH:
self.length_buf.append(byte)
if len(self.length_buf) >= 4:
# 小端序解析长度
self.data_length = struct.unpack('<I', self.length_buf)[0]
self.data = bytearray()
if self.data_length > 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('<I', self.data_length))
frame_for_check.extend(self.data)
calc_checksum = self.calc_checksum(frame_for_check)
if calc_checksum != self.checksum:
# 校验失败
print(f"[警告] 校验和错误: 期望0x{calc_checksum:02X}, 收到0x{self.checksum:02X}")
self.reset()
return None
# 帧解析完成
frame = {
'type': self.frame_type,
'data': bytes(self.data)
}
# 重置状态以准备下一帧
self.reset()
return frame
return None
class AudioRecorder:
"""音频录音器"""
def __init__(self, port, baudrate, output_file, timeout=1):
self.port = port
self.baudrate = baudrate
self.output_file = output_file
self.timeout = timeout
self.serial = None
self.protocol = CDCProtocol()
self.audio_file = None
self.md5_ctx = hashlib.md5()
self.byte_count = 0
self.seq_num = 0
self.device_md5 = None
self.running = False
self.recording = False
self.rx_thread = None
self.lock = threading.Lock()
def connect(self):
"""连接串口"""
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 串口已打开: {self.port}")
print(f"波特率: {self.baudrate}")
print(f"输出文件: {self.output_file}")
print("-" * 50)
# 清空串口缓存
self.serial.reset_input_buffer()
self.serial.reset_output_buffer()
print("串口缓存已清空")
return True
except serial.SerialException as e:
print(f"串口错误: {e}", file=sys.stderr)
return False
def disconnect(self):
"""断开串口"""
if self.serial and self.serial.is_open:
self.serial.close()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 串口已关闭")
def send_command(self, cmd_id, params=b''):
"""发送命令"""
if not self.serial or not self.serial.is_open:
return False
try:
frame = self.protocol.build_cmd_request(cmd_id, params)
self.serial.write(frame)
self.serial.flush()
cmd_name = {
CommandID.START_RECORD: "开始录音",
CommandID.STOP_RECORD: "停止录音",
CommandID.QUERY_STATUS: "查询状态"
}.get(cmd_id, f"未知命令(0x{cmd_id:02X})")
print(f"[命令] 发送: {cmd_name}")
return True
except Exception as e:
print(f"[错误] 发送命令失败: {e}")
return False
def handle_cmd_response(self, data):
"""处理命令响应"""
if len(data) < 2:
print("[警告] 命令响应数据过短")
return
cmd_id = data[0]
status = data[1]
response_data = data[2:] if len(data) > 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('<I', response_data[1:5])[0]
seq_num = struct.unpack('<I', response_data[5:9])[0]
state_name = "正在录音" if state == 0x01 else "空闲"
print(f" 状态: {state_name}")
print(f" 已传输: {total_bytes} 字节")
print(f" 序列号: {seq_num}")
def handle_audio_data(self, data):
"""处理音频数据"""
if len(data) < 4:
print("[警告] 音频数据过短")
return
# 解析序列号
seq_num = struct.unpack('<I', data[0:4])[0]
pcm_data = data[4:]
# 检查序列号
if seq_num != self.seq_num:
print(f"\n[警告] 序列号跳变: 期望{self.seq_num}, 收到{seq_num}")
self.seq_num = seq_num
# 写入文件
if self.audio_file:
self.audio_file.write(pcm_data)
self.audio_file.flush()
# 更新MD5
self.md5_ctx.update(pcm_data)
# 更新统计
self.byte_count += len(pcm_data)
self.seq_num += 1
# 打印进度
print(f"\r已接收: {self.byte_count} 字节 (序列号: {seq_num})", end='', flush=True)
def handle_md5_data(self, data):
"""处理MD5数据"""
if len(data) != 16:
print(f"\n[警告] MD5数据长度错误: {len(data)}")
return
self.device_md5 = data.hex()
print(f"\n[MD5] 设备端: {self.device_md5}")
def handle_frame(self, frame):
"""处理接收到的帧"""
frame_type = frame['type']
data = frame['data']
if frame_type == FrameType.CMD_RESPONSE:
self.handle_cmd_response(data)
elif frame_type == FrameType.AUDIO_DATA:
self.handle_audio_data(data)
elif frame_type == FrameType.MD5_DATA:
self.handle_md5_data(data)
else:
print(f"[警告] 未知帧类型: 0x{frame_type:02X}")
def receive_thread_func(self):
"""接收线程函数"""
parser = CDCProtocol()
while self.running:
try:
if self.serial and self.serial.is_open and self.serial.in_waiting > 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()