#!/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('= 4: self.data_length = struct.unpack(' 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('>", 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(' 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()