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

903 lines
33 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串口录音工具 - GUI版本
支持实时波形显示和录音控制
"""
import sys
import os
import serial
import time
import hashlib
import struct
import threading
import queue
from datetime import datetime
from enum import IntEnum
import numpy as np
# GUI相关
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import matplotlib.animation as animation
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
MAX_AUDIO_DATA_SIZE = 65536
MAX_DATA_SIZE = MAX_AUDIO_DATA_SIZE
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):
"""解析单个字节"""
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]
if self.data_length > self.MAX_DATA_SIZE:
self.reset()
return None
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
# 音频帧不验证校验和
if self.frame_type == FrameType.AUDIO_DATA:
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:
self.reset()
return None
# 帧解析完成
frame = {
'type': self.frame_type,
'data': bytes(self.data)
}
self.reset()
return frame
return None
class AudioRecorderGUI:
"""带GUI的音频录音器"""
def __init__(self, root):
self.root = root
self.root.title("USB CDC 录音工具")
self.root.geometry("900x700")
# 录音状态
self.recording = False
self.serial = None
self.rx_thread = None
self.running = False
self.audio_file = None
self.byte_count = 0
self.seq_num = 0
self.md5_ctx = None
self.device_md5 = None
self.is_saving_file = False # 记录当前是否在保存文件
# 音频数据队列(用于波形显示)
self.audio_queue = queue.Queue(maxsize=100)
self.waveform_data = {} # 字典key为通道号value为numpy数组
self.max_waveform_samples = 16000 # 显示1秒的数据假设16kHz采样率
self.x_axis_max = 16000 # X轴显示的最大样本数可通过滚轮调节
self.x_axis_auto_fit = False # 是否自动适应显示所有数据
self.x_view_position = 0 # X轴视图起始位置用于滚动条
self.auto_scroll = True # 是否自动滚动到最新数据
self.updating_scrollbar = False # 标志:正在程序更新滚动条(防止触发回调)
# 波形线条(多通道)
self.waveform_lines = []
self.channel_colors = ['b', 'r', 'g', 'orange', 'purple', 'cyan', 'magenta', 'brown']
# Y轴范围控制每个通道独立
self.y_limits = {} # {channel: (ymin, ymax)}
# 协议解析器
self.protocol = CDCProtocol()
self.lock = threading.Lock()
# 创建GUI
self.create_widgets()
def create_widgets(self):
"""创建GUI组件"""
# === 顶部:配置区域 ===
config_frame = ttk.LabelFrame(self.root, text="配置", padding=10)
config_frame.pack(fill=tk.X, padx=10, pady=5)
# 串口选择
ttk.Label(config_frame, text="串口:").grid(row=0, column=0, sticky=tk.W, padx=5)
self.port_var = tk.StringVar(value="/dev/ttyACM0")
port_entry = ttk.Entry(config_frame, textvariable=self.port_var, width=20)
port_entry.grid(row=0, column=1, padx=5)
# 波特率
ttk.Label(config_frame, text="波特率:").grid(row=0, column=2, sticky=tk.W, padx=5)
self.baud_var = tk.StringVar(value="921600")
baud_combo = ttk.Combobox(config_frame, textvariable=self.baud_var, width=15,
values=["115200", "460800", "921600", "1500000"])
baud_combo.grid(row=0, column=3, padx=5)
# 采样率
ttk.Label(config_frame, text="采样率:").grid(row=0, column=4, sticky=tk.W, padx=5)
self.sample_rate_var = tk.StringVar(value="16000")
sample_rate_combo = ttk.Combobox(config_frame, textvariable=self.sample_rate_var, width=10,
values=["8000", "16000", "32000", "48000"],
state="readonly")
sample_rate_combo.grid(row=0, column=5, padx=5)
# 位深
ttk.Label(config_frame, text="位深:").grid(row=0, column=6, sticky=tk.W, padx=5)
self.bit_depth_var = tk.StringVar(value="16")
bit_depth_combo = ttk.Combobox(config_frame, textvariable=self.bit_depth_var, width=8,
values=["16"],
state="readonly")
bit_depth_combo.grid(row=0, column=7, padx=5)
# 通道数
ttk.Label(config_frame, text="通道数:").grid(row=0, column=8, sticky=tk.W, padx=5)
self.channels_var = tk.StringVar(value="1")
channels_combo = ttk.Combobox(config_frame, textvariable=self.channels_var, width=8,
values=["1", "2", "3", "4", "5", "6", "8"],
state="readonly")
channels_combo.grid(row=0, column=9, padx=5)
channels_combo.bind("<<ComboboxSelected>>", self.on_channels_changed)
# 是否保存文件选项和输出文件(同一行)
self.save_file_var = tk.BooleanVar(value=True)
save_check = ttk.Checkbutton(config_frame, text="保存文件:",
variable=self.save_file_var,
command=self.toggle_file_controls)
save_check.grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
self.output_var = tk.StringVar(value="output.pcm")
self.output_entry = ttk.Entry(config_frame, textvariable=self.output_var, width=35)
self.output_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky=tk.W)
self.browse_btn = ttk.Button(config_frame, text="浏览...", command=self.browse_output_file)
self.browse_btn.grid(row=1, column=3, padx=5, pady=5)
# === 中间:控制按钮区域 ===
control_frame = ttk.Frame(self.root, padding=10)
control_frame.pack(fill=tk.X, padx=10, pady=5)
self.start_btn = ttk.Button(control_frame, text="开始录音", command=self.start_recording,
state=tk.NORMAL, width=15)
self.start_btn.pack(side=tk.LEFT, padx=5)
self.stop_btn = ttk.Button(control_frame, text="停止录音", command=self.stop_recording,
state=tk.DISABLED, width=15)
self.stop_btn.pack(side=tk.LEFT, padx=5)
# === 波形显示区域 ===
waveform_frame = ttk.LabelFrame(self.root, text="实时波形", padding=10)
waveform_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# 创建matplotlib图形初始为空稍后根据通道数创建子图
self.fig = Figure(figsize=(10, 6), dpi=100)
self.axes = [] # 多个子图的列表
# 将matplotlib嵌入到tkinter
self.canvas = FigureCanvasTkAgg(self.fig, master=waveform_frame)
self.canvas.draw()
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
# 添加横向滚动条
scrollbar_frame = ttk.Frame(waveform_frame)
scrollbar_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Label(scrollbar_frame, text="时间轴:").pack(side=tk.LEFT, padx=5)
self.x_scrollbar = ttk.Scale(scrollbar_frame, from_=0, to=100, orient=tk.HORIZONTAL,
command=self.on_x_scroll)
self.x_scrollbar.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
self.auto_scroll_var = tk.BooleanVar(value=True)
self.auto_scroll_check = ttk.Checkbutton(scrollbar_frame, text="自动滚动",
variable=self.auto_scroll_var,
command=self.toggle_auto_scroll)
self.auto_scroll_check.pack(side=tk.LEFT, padx=5)
# 初始化波形线(多通道)- 必须在canvas创建后
self.init_waveform_lines()
# 绑定鼠标滚轮事件用于Y轴范围调节
self.canvas.mpl_connect('scroll_event', self.on_scroll)
# === 底部:状态信息区域 ===
status_frame = ttk.LabelFrame(self.root, text="状态信息", padding=10)
status_frame.pack(fill=tk.X, padx=10, pady=5)
# 状态标签
self.status_label = ttk.Label(status_frame, text="就绪", foreground="green", font=("Arial", 10, "bold"))
self.status_label.grid(row=0, column=0, columnspan=4, sticky=tk.W, pady=5)
# 统计信息
ttk.Label(status_frame, text="已接收:").grid(row=1, column=0, sticky=tk.W)
self.bytes_label = ttk.Label(status_frame, text="0 字节", width=20)
self.bytes_label.grid(row=1, column=1, sticky=tk.W)
ttk.Label(status_frame, text="序列号:").grid(row=1, column=2, sticky=tk.W, padx=20)
self.seq_label = ttk.Label(status_frame, text="0", width=15)
self.seq_label.grid(row=1, column=3, sticky=tk.W)
ttk.Label(status_frame, text="PC MD5:").grid(row=2, column=0, sticky=tk.W)
self.pc_md5_label = ttk.Label(status_frame, text="-", width=40)
self.pc_md5_label.grid(row=2, column=1, columnspan=3, sticky=tk.W)
ttk.Label(status_frame, text="设备 MD5:").grid(row=3, column=0, sticky=tk.W)
self.device_md5_label = ttk.Label(status_frame, text="-", width=40)
self.device_md5_label.grid(row=3, column=1, columnspan=3, sticky=tk.W)
# 启动波形更新动画
self.ani = animation.FuncAnimation(self.fig, self.update_waveform,
interval=100, blit=False)
def init_waveform_lines(self):
"""初始化波形线(每个通道独立子图)"""
# 清除所有旧的子图
self.fig.clear()
self.axes = []
self.waveform_lines = []
# 获取通道数
num_channels = int(self.channels_var.get())
# 为每个通道创建独立的子图
for ch in range(num_channels):
# 创建子图 (行数, 列数, 索引)
ax = self.fig.add_subplot(num_channels, 1, ch + 1)
# 设置子图属性 - 将通道标签放在左边作为Y轴标签
ax.set_ylabel(f'CH{ch}', fontsize=10, fontweight='bold', rotation=0,
labelpad=20, va='center')
ax.grid(True, alpha=0.3)
ax.set_ylim(-32768, 32767)
# 设置Y轴刻度标签字体大小
ax.tick_params(axis='y', labelsize=7)
ax.tick_params(axis='x', labelsize=7)
# 只在最后一个子图显示X轴标签
if ch == num_channels - 1:
ax.set_xlabel('样本', fontsize=8)
else:
ax.set_xticklabels([])
# 为该子图创建波形线
color = self.channel_colors[ch % len(self.channel_colors)]
line, = ax.plot([], [], color=color, linewidth=0.8)
self.axes.append(ax)
self.waveform_lines.append(line)
# 初始化该通道的Y轴范围
self.y_limits[ch] = (-32768, 32767)
# 调整子图间距 - 更紧凑的布局
self.fig.subplots_adjust(left=0.08, right=0.98, top=0.98, bottom=0.05, hspace=0.15)
# 初始化波形数据字典
self.waveform_data = {ch: np.array([]) for ch in range(num_channels)}
self.canvas.draw()
def on_channels_changed(self, event=None):
"""通道数改变时的回调"""
if not self.recording:
self.init_waveform_lines()
# 更新最大样本数(根据采样率)
sample_rate = int(self.sample_rate_var.get())
self.max_waveform_samples = sample_rate # 显示1秒数据
self.x_axis_max = sample_rate # 同步更新X轴最大值
def on_x_scroll(self, value):
"""X轴滚动条回调"""
# 当用户拖动滚动条时,禁用自动滚动
if self.auto_scroll_var.get():
self.auto_scroll_var.set(False)
self.auto_scroll = False
# 更新视图位置value 是0-100的百分比
self.x_view_position = float(value)
def toggle_auto_scroll(self):
"""切换自动滚动模式"""
self.auto_scroll = self.auto_scroll_var.get()
if self.auto_scroll:
# 启用自动滚动时,将视图移到最右侧
self.x_scrollbar.set(100)
self.x_view_position = 100
def on_scroll(self, event):
"""鼠标滚轮事件 - 调整Y轴或X轴范围"""
if event.y is None:
return
target_channel = None
target_ax = None
# 先尝试检测鼠标是否在某个子图内
if event.inaxes is not None:
for ch, ax in enumerate(self.axes):
if event.inaxes == ax:
target_channel = ch
target_ax = ax
break
# 如果不在子图内检测鼠标Y坐标在哪个子图的垂直范围内包括Y轴区域和间隙
if target_channel is None:
for ch, ax in enumerate(self.axes):
bbox = ax.get_window_extent()
# 扩大检测范围上下各扩展10像素
if bbox.y0 - 10 <= event.y <= bbox.y1 + 10:
target_channel = ch
target_ax = ax
break
# 如果找到了目标通道
if target_channel is not None and target_ax is not None:
bbox = target_ax.get_window_extent()
# 判断鼠标在Y轴左侧还是右侧波形区域
# Y轴区域大约在子图左边界后60像素内
y_axis_right_edge = bbox.x0 + 60
if event.x is not None and event.x > y_axis_right_edge:
# 鼠标在波形区域Y轴右侧- 调节X轴范围所有通道同步
zoom_factor = 0.9 if event.button == 'up' else 1.1
if self.x_axis_auto_fit and event.button == 'up':
# 当前是自动适应模式,向上滚动开始缩放
sample_rate = int(self.sample_rate_var.get())
self.x_axis_max = int(sample_rate * zoom_factor)
self.x_axis_auto_fit = False
else:
# 计算新的X轴最大值
new_x_max = self.x_axis_max * zoom_factor
# 限制范围最小800个样本最大100000个样本
new_x_max = max(800, min(100000, int(new_x_max)))
# 如果缩小到最小值,切换到自动适应模式
if new_x_max <= 800 and event.button == 'down':
self.x_axis_auto_fit = True
else:
self.x_axis_auto_fit = False
self.x_axis_max = new_x_max
# 不在这里直接设置xlim让update_waveform处理
self.canvas.draw_idle()
else:
# 鼠标在Y轴区域 - 调节该通道的Y轴范围
# 获取当前Y轴范围
ymin, ymax = self.y_limits.get(target_channel, (-32768, 32767))
y_range = ymax - ymin
y_center = (ymin + ymax) / 2
# 滚轮向上:缩小范围(放大显示)
# 滚轮向下:扩大范围(缩小显示)
zoom_factor = 0.9 if event.button == 'up' else 1.1
# 计算新的Y轴范围
new_range = y_range * zoom_factor
# 限制最小和最大范围
new_range = max(100, min(65536, new_range))
# 计算新的Y轴上下限保持中心不变
new_ymin = y_center - new_range / 2
new_ymax = y_center + new_range / 2
# 更新Y轴范围
self.y_limits[target_channel] = (new_ymin, new_ymax)
target_ax.set_ylim(new_ymin, new_ymax)
self.canvas.draw_idle()
def parse_interleaved_pcm(self, pcm_data):
"""
解析交叉排列的PCM数据
输入格式: ch0 ch1 ch2 ... ch0 ch1 ch2 ...
返回: {0: [ch0_samples], 1: [ch1_samples], ...}
"""
num_channels = int(self.channels_var.get())
bit_depth = int(self.bit_depth_var.get())
# 将bytes转换为int16数组
if bit_depth == 16:
samples = np.frombuffer(pcm_data, dtype=np.int16)
else:
# 未来可以扩展支持其他位深
samples = np.frombuffer(pcm_data, dtype=np.int16)
# 分离各通道数据
channel_data = {}
for ch in range(num_channels):
channel_data[ch] = samples[ch::num_channels]
return channel_data
def browse_output_file(self):
"""浏览输出文件"""
filename = filedialog.asksaveasfilename(
defaultextension=".pcm",
filetypes=[("PCM files", "*.pcm"), ("WAV files", "*.wav"), ("All files", "*.*")]
)
if filename:
self.output_var.set(filename)
def toggle_file_controls(self):
"""切换文件控件的启用状态"""
if self.save_file_var.get():
# 启用文件控件
self.output_entry.config(state=tk.NORMAL)
self.browse_btn.config(state=tk.NORMAL)
else:
# 禁用文件控件
self.output_entry.config(state=tk.DISABLED)
self.browse_btn.config(state=tk.DISABLED)
def update_status(self, message, color="black"):
"""更新状态信息"""
self.status_label.config(text=message, foreground=color)
def update_statistics(self):
"""更新统计信息"""
self.bytes_label.config(text=f"{self.byte_count} 字节")
self.seq_label.config(text=str(self.seq_num))
def update_waveform(self, frame):
"""更新波形显示(多通道支持)"""
try:
# 从队列获取新的音频数据
while not self.audio_queue.empty():
try:
new_data = self.audio_queue.get_nowait()
# 解析交叉排列的多通道数据
channel_data = self.parse_interleaved_pcm(new_data)
# 更新每个通道的数据
for ch, samples in channel_data.items():
if ch in self.waveform_data:
# 拼接新数据
self.waveform_data[ch] = np.concatenate([self.waveform_data[ch], samples])
# 限制最大缓冲区为1000000个样本
max_buffer_size = 1000000
if len(self.waveform_data[ch]) > max_buffer_size:
self.waveform_data[ch] = self.waveform_data[ch][-max_buffer_size:]
except queue.Empty:
break
# 更新每条波形线和对应子图的X轴
num_channels = int(self.channels_var.get())
for ch in range(num_channels):
if ch < len(self.waveform_lines) and ch < len(self.axes) and ch in self.waveform_data:
data = self.waveform_data[ch]
if len(data) > 0:
# 更新波形线数据
self.waveform_lines[ch].set_data(range(len(data)), data)
# 为每个子图设置X轴范围
if self.x_axis_auto_fit:
# 自动适应模式:显示所有数据
self.axes[ch].set_xlim(0, max(len(data), 1000))
else:
# 固定窗口模式 - 根据滚动条位置显示
if len(data) <= self.x_axis_max:
# 数据不足从0开始显示
self.axes[ch].set_xlim(0, max(self.x_axis_max, 1000))
else:
# 根据滚动条位置计算显示范围
if self.auto_scroll:
# 自动滚动:显示最新数据
x_end = len(data)
x_start = max(0, x_end - self.x_axis_max)
# 同步更新滚动条位置(不触发回调)
self.x_scrollbar.set(100)
else:
# 手动滚动:根据滚动条位置显示
total_range = len(data) - self.x_axis_max
x_start = int((self.x_view_position / 100.0) * total_range)
x_start = max(0, min(x_start, total_range))
x_end = x_start + self.x_axis_max
self.axes[ch].set_xlim(x_start, x_end)
# 应用该通道的Y轴范围
if ch in self.y_limits:
self.axes[ch].set_ylim(self.y_limits[ch])
except Exception as e:
print(f"波形更新错误: {e}")
return tuple(self.waveform_lines) if self.waveform_lines else ()
def send_command(self, cmd_id, params=b''):
"""发送命令"""
if not self.serial or not self.serial.is_open:
return False
frame = self.protocol.build_cmd_request(cmd_id, params)
try:
self.serial.write(frame)
return True
except Exception as e:
self.update_status(f"发送命令失败: {e}", "red")
return False
def handle_cmd_response(self, data):
"""处理命令响应"""
if len(data) < 2:
return
cmd_id = data[0]
status = data[1]
status_str = {0: "成功", 1: "失败", 2: "忙碌", 3: "不支持"}.get(status, "未知")
cmd_str = {1: "开始录音", 2: "停止录音", 3: "查询状态"}.get(cmd_id, f"命令{cmd_id}")
message = f"{cmd_str}: {status_str}"
color = "green" if status == 0 else "red"
self.update_status(message, color)
def handle_audio_data(self, data):
"""处理音频数据"""
if len(data) < 4:
return
# 解析序列号
seq_num = struct.unpack('<I', data[0:4])[0]
pcm_data = data[4:]
# 检查序列号
if seq_num != self.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
# 更新GUI
self.root.after(0, self.update_statistics)
# 添加到波形队列(无论是否保存文件都显示波形)
try:
self.audio_queue.put_nowait(pcm_data)
except queue.Full:
# 队列满了,丢弃旧数据
try:
self.audio_queue.get_nowait()
self.audio_queue.put_nowait(pcm_data)
except:
pass
def handle_md5_data(self, data):
"""处理MD5数据"""
if len(data) != 16:
return
self.device_md5 = data.hex()
self.device_md5_label.config(text=self.device_md5)
# 比较MD5
pc_md5 = self.md5_ctx.hexdigest()
self.pc_md5_label.config(text=pc_md5)
# 显示结果
if pc_md5 == self.device_md5:
if self.is_saving_file:
self.update_status("录音完成MD5校验通过 ✓", "green")
else:
self.update_status("录音完成未保存文件MD5校验通过 ✓", "green")
else:
if self.is_saving_file:
self.update_status("录音完成MD5校验失败 ✗", "red")
else:
self.update_status("录音完成未保存文件MD5校验失败 ✗", "red")
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)
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"接收线程异常: {e}")
break
def start_recording(self):
"""开始录音"""
port = self.port_var.get()
baudrate = int(self.baud_var.get())
save_file = self.save_file_var.get()
output_file = self.output_var.get()
# 打开串口
try:
self.serial = serial.Serial(port, baudrate, timeout=1)
self.update_status(f"串口已打开: {port}", "green")
except Exception as e:
messagebox.showerror("错误", f"无法打开串口: {e}")
return
# 打开输出文件(仅当启用保存时)
if save_file:
try:
self.audio_file = open(output_file, 'wb')
except IOError as e:
messagebox.showerror("错误", f"无法创建文件: {e}")
self.serial.close()
return
else:
self.audio_file = None # 不保存文件
# 重置统计
self.byte_count = 0
self.seq_num = 0
self.md5_ctx = hashlib.md5()
self.device_md5 = None
# 重置波形数据为字典格式(多通道)
num_channels = int(self.channels_var.get())
self.waveform_data = {ch: np.array([]) for ch in range(num_channels)}
# 重置X轴最大值和自动适应模式
sample_rate = int(self.sample_rate_var.get())
self.x_axis_max = sample_rate
self.x_axis_auto_fit = False # 开始录音时使用固定窗口模式
# 重置滚动条状态
self.x_view_position = 0
self.auto_scroll = True
self.auto_scroll_var.set(True)
self.x_scrollbar.set(100)
self.pc_md5_label.config(text="-")
self.device_md5_label.config(text="-")
self.update_statistics()
# 启动接收线程
self.running = True
self.rx_thread = threading.Thread(target=self.receive_thread_func, daemon=True)
self.rx_thread.start()
# 发送开始录音命令
time.sleep(0.1)
if not self.send_command(CommandID.START_RECORD):
self.update_status("发送开始录音命令失败", "red")
self.running = False
if self.audio_file:
self.audio_file.close()
if self.serial:
self.serial.close()
return
self.recording = True
self.is_saving_file = save_file # 记录是否保存文件
self.start_btn.config(state=tk.DISABLED)
self.stop_btn.config(state=tk.NORMAL)
# 更新状态信息
if save_file:
self.update_status(f"正在录音并保存到: {output_file}", "blue")
else:
self.update_status("正在录音(仅显示波形,不保存文件)", "blue")
def stop_recording(self):
"""停止录音"""
if not self.recording:
return
self.recording = False
self.update_status("正在停止录音...", "orange")
# 发送停止录音命令
if self.send_command(CommandID.STOP_RECORD):
# 等待MD5数据
time.sleep(1)
# 停止接收线程
self.running = False
if self.rx_thread:
self.rx_thread.join(timeout=2)
# 关闭文件和串口
if self.audio_file:
self.audio_file.close()
self.audio_file = None
if self.serial and self.serial.is_open:
self.serial.close()
self.serial = None
self.start_btn.config(state=tk.NORMAL)
self.stop_btn.config(state=tk.DISABLED)
# 如果没有收到MD5数据显示停止信息
if not self.device_md5:
if self.is_saving_file:
self.update_status("录音已停止", "orange")
else:
self.update_status("录音已停止(未保存文件)", "orange")
def on_closing(self):
"""窗口关闭事件"""
if self.recording:
if messagebox.askokcancel("退出", "录音正在进行,确定要退出吗?"):
self.stop_recording()
time.sleep(0.5)
self.root.destroy()
else:
self.root.destroy()
def main():
"""主函数"""
root = tk.Tk()
app = AudioRecorderGUI(root)
root.protocol("WM_DELETE_WINDOW", app.on_closing)
root.mainloop()
if __name__ == "__main__":
main()