wangzhibo
2026-10-08 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
import sys
import numpy as np
import asyncio
import wave
from collections import deque
import qasync
 
import matplotlib
matplotlib.use('qtagg')  
 
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar  # noqa: F401
from matplotlib.figure import Figure
 
from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QWidget, 
                             QHBoxLayout, QLineEdit, QPushButton, QLabel, QTextEdit)
from PyQt6.QtCore import QTimer
 
# 导入解码器
from demod import RealTimeAFSKDecoder
 
 
class UDPServerProtocol(asyncio.DatagramProtocol):
    """UDP服务器协议类"""
    def __init__(self, data_queue):
        self.client_address = None
        self.data_queue: deque = data_queue
 
    def connection_made(self, transport):
        self.transport = transport
        
    def datagram_received(self, data, addr):
        # 如果还没有客户端地址,记录第一个连接的客户端
        if self.client_address is None:
            self.client_address = addr
            print(f"接受来自 {addr} 的连接")
        
        # 只处理来自已记录客户端的数据
        if addr == self.client_address:
            # 将接收到的音频数据添加到队列
            self.data_queue.extend(data)
        else:
            print(f"忽略来自未知地址 {addr} 的数据")
 
 
class MatplotlibWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
 
        # 创建 Matplotlib 的 Figure 对象
        self.figure = Figure()
 
        # 创建 FigureCanvas 对象,它是 Figure 的 QWidget 容器
        self.canvas = FigureCanvas(self.figure)
 
        # 创建 Matplotlib 的导航工具栏
        # self.toolbar = NavigationToolbar(self.canvas, self)
        self.toolbar = None
 
        # 创建布局
        layout = QVBoxLayout()
        layout.addWidget(self.toolbar)
        layout.addWidget(self.canvas)
        self.setLayout(layout)
 
        # 初始化音频数据参数
        self.freq = 16000  # 采样频率
        self.time_window = 20  # 显示时间窗口
        self.wave_data = deque(maxlen=self.freq * self.time_window * 2) # 缓冲队列, 用于分发计算/绘图
        self.signals = deque(maxlen=self.freq * self.time_window)  # 双端队列存储信号数据
 
        # 创建包含两个子图的画布
        self.ax1 = self.figure.add_subplot(2, 1, 1)
        self.ax2 = self.figure.add_subplot(2, 1, 2)
        
        # 时域子图
        self.ax1.set_title('Real-time Audio Waveform')
        self.ax1.set_xlabel('Sample Index')
        self.ax1.set_ylabel('Amplitude')
        self.line_time, = self.ax1.plot([], [])
        self.ax1.grid(True, alpha=0.3)
        
        # 频域子图
        self.ax2.set_title('Real-time Frequency Spectrum')
        self.ax2.set_xlabel('Frequency (Hz)')
        self.ax2.set_ylabel('Magnitude')
        self.line_freq, = self.ax2.plot([], [])
        self.ax2.grid(True, alpha=0.3)
        
        self.figure.tight_layout()
 
        # 定时器用于更新图表
        self.timer = QTimer(self)
        self.timer.setInterval(100)  # 100毫秒更新一次
        self.timer.timeout.connect(self.update_plot)
        
        # 初始化AFSK解码器
        self.decoder = RealTimeAFSKDecoder(
            f_sample=self.freq,
            mark_freq=1800,
            space_freq=1500,
            bitrate=100,
            s_goertzel=9,
            threshold=0.5
        )
        
        # 解码结果回调
        self.decode_callback = None
 
    def start_plotting(self):
        """开始绘图"""
        self.timer.start()
        
    def stop_plotting(self):
        """停止绘图"""
        self.timer.stop()
 
    def update_plot(self):
        """更新绘图数据"""
        if len(self.wave_data) >= 2:
            # 进行实时解码
            # 获取最新的音频数据进行解码
            even = len(self.wave_data) // 2 * 2
            print(f"length of wave_data: {len(self.wave_data)}")
            drained = [self.wave_data.popleft() for _ in range(even)]
            signal = np.frombuffer(bytearray(drained), dtype='<i2') / 32768
            decoded_text_new = self.decoder.process_audio(signal) # 处理新增信号, 返回全量解码文本
            if decoded_text_new and self.decode_callback:
                self.decode_callback(decoded_text_new)
            self.signals.extend(signal.tolist())  # 将波形数据添加到绘图数据
 
        if len(self.signals) > 0:
            # 只显示最近的一段数据,避免图表过于密集
            signal = np.array(self.signals)
            max_samples = min(len(signal), self.freq * self.time_window)
            if len(signal) > max_samples:
                signal = signal[-max_samples:]
            
            # 更新时域图
            x = np.arange(len(signal))
            self.line_time.set_data(x, signal)
            
            # 自动调整时域坐标轴范围
            if len(signal) > 0:
                self.ax1.set_xlim(0, len(signal))
                y_min, y_max = np.min(signal), np.max(signal)
                if y_min != y_max:
                    margin = (y_max - y_min) * 0.1
                    self.ax1.set_ylim(y_min - margin, y_max + margin)
                else:
                    self.ax1.set_ylim(-1, 1)
            
            # 计算频谱(短时离散傅立叶变换)
            if len(signal) > 1:
                # 计算FFT
                fft_signal = np.abs(np.fft.fft(signal))
                frequencies = np.fft.fftfreq(len(signal), 1/self.freq)
                
                # 只取正频率部分
                positive_freq_idx = frequencies >= 0
                freq_positive = frequencies[positive_freq_idx]
                fft_positive = fft_signal[positive_freq_idx]
                
                # 更新频域图
                self.line_freq.set_data(freq_positive, fft_positive)
                
                # 自动调整频域坐标轴范围
                if len(fft_positive) > 0:
                    # 限制频率显示范围到0-4000Hz,避免过于密集
                    max_freq_show = min(4000, self.freq // 2)
                    freq_mask = freq_positive <= max_freq_show
                    if np.any(freq_mask):
                        self.ax2.set_xlim(0, max_freq_show)
                        fft_masked = fft_positive[freq_mask]
                        if len(fft_masked) > 0:
                            fft_max = np.max(fft_masked)
                            if fft_max > 0:
                                self.ax2.set_ylim(0, fft_max * 1.1)
                            else:
                                self.ax2.set_ylim(0, 1)
            
            self.canvas.draw()
 
 
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Acoustic Check")
        self.setGeometry(100, 100, 1000, 800)
 
        # 主窗口部件
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        
        # 主布局
        main_layout = QVBoxLayout(main_widget)
        
        # 绘图区域
        self.matplotlib_widget = MatplotlibWidget()
        main_layout.addWidget(self.matplotlib_widget)
        
        # 控制面板
        control_panel = QWidget()
        control_layout = QHBoxLayout(control_panel)
        
        # 监听地址和端口输入
        control_layout.addWidget(QLabel("监听地址:"))
        self.address_input = QLineEdit("0.0.0.0")
        self.address_input.setFixedWidth(120)
        control_layout.addWidget(self.address_input)
        
        control_layout.addWidget(QLabel("端口:"))
        self.port_input = QLineEdit("8000")
        self.port_input.setFixedWidth(80)
        control_layout.addWidget(self.port_input)
        
        # 监听按钮
        self.listen_button = QPushButton("开始监听")
        self.listen_button.clicked.connect(self.toggle_listening)
        control_layout.addWidget(self.listen_button)
        
        # 状态标签
        self.status_label = QLabel("状态: 未连接")
        control_layout.addWidget(self.status_label)
        
        # 数据统计标签
        self.data_label = QLabel("接收数据: 0 bytes")
        control_layout.addWidget(self.data_label)
        
        # 保存按钮
        self.save_button = QPushButton("保存音频")
        self.save_button.clicked.connect(self.save_audio)
        self.save_button.setEnabled(False)
        control_layout.addWidget(self.save_button)
        
        control_layout.addStretch()  # 添加弹性空间
        
        main_layout.addWidget(control_panel)
        
        # 解码显示区域
        decode_panel = QWidget()
        decode_layout = QVBoxLayout(decode_panel)
        
        # 解码标题
        decode_title = QLabel("实时AFSK解码结果:")
        decode_title.setStyleSheet("font-weight: bold; font-size: 14px;")
        decode_layout.addWidget(decode_title)
        
        # 解码文本显示
        self.decode_text = QTextEdit()
        self.decode_text.setMaximumHeight(150)
        self.decode_text.setReadOnly(True)
        self.decode_text.setStyleSheet("font-family: 'Courier New', monospace; font-size: 12px;")
        decode_layout.addWidget(self.decode_text)
        
        # 解码控制按钮
        decode_control_layout = QHBoxLayout()
        
        # 清空按钮
        self.clear_decode_button = QPushButton("清空解码")
        self.clear_decode_button.clicked.connect(self.clear_decode_text)
        decode_control_layout.addWidget(self.clear_decode_button)
        
        # 解码统计标签
        self.decode_stats_label = QLabel("解码统计: 0 bits, 0 chars")
        decode_control_layout.addWidget(self.decode_stats_label)
        
        decode_control_layout.addStretch()
        decode_layout.addLayout(decode_control_layout)
        
        main_layout.addWidget(decode_panel)
        
        # 设置解码回调
        self.matplotlib_widget.decode_callback = self.on_decode_text
        
        # UDP相关属性
        self.udp_transport = None
        self.is_listening = False
        
        # 数据统计定时器
        self.stats_timer = QTimer(self)
        self.stats_timer.setInterval(1000)  # 每秒更新一次统计
        self.stats_timer.timeout.connect(self.update_stats)
        
    def on_decode_text(self, new_text: str):
        """解码文本回调"""
        if new_text:
            # 添加新解码的文本
            current_text = self.decode_text.toPlainText()
            updated_text = current_text + new_text
 
            # 限制文本长度,保留最新的1000个字符
            if len(updated_text) > 1000:
                updated_text = updated_text[-1000:]
            
            self.decode_text.setPlainText(updated_text)
            
            # 滚动到底部
            cursor = self.decode_text.textCursor()
            cursor.movePosition(cursor.MoveOperation.End)
            self.decode_text.setTextCursor(cursor)
            
    def clear_decode_text(self):
        """清空解码文本"""
        self.decode_text.clear()
        if hasattr(self.matplotlib_widget, 'decoder'):
            self.matplotlib_widget.decoder.clear()
        self.decode_stats_label.setText("解码统计: 0 bits, 0 chars")
        
    def update_decode_stats(self):
        """更新解码统计"""
        if hasattr(self.matplotlib_widget, 'decoder'):
            stats = self.matplotlib_widget.decoder.get_stats()
            stats_text = (
                f"前置: {stats['prelude_bits']} , 已接收{stats['total_chars']} chars, "
                f"缓冲: {stats['buffer_bits']} bits, 状态: {stats['state']}"
            )
            self.decode_stats_label.setText(stats_text)
        
    def toggle_listening(self):
        """切换监听状态"""
        if not self.is_listening:
            self.start_listening()
        else:
            self.stop_listening()
            
    async def start_listening_async(self):
        """异步启动UDP监听"""
        try:
            address = self.address_input.text().strip()
            port = int(self.port_input.text().strip())
            
            loop = asyncio.get_running_loop()
            self.udp_transport, protocol = await loop.create_datagram_endpoint(
                lambda: UDPServerProtocol(self.matplotlib_widget.wave_data),
                local_addr=(address, port)
            )
            
            self.status_label.setText(f"状态: 监听中 ({address}:{port})")
            print(f"UDP服务器启动, 监听 {address}:{port}")
            
        except Exception as e:
            self.status_label.setText(f"状态: 启动失败 - {str(e)}")
            print(f"UDP服务器启动失败: {e}")
            self.is_listening = False
            self.listen_button.setText("开始监听")
            self.address_input.setEnabled(True)
            self.port_input.setEnabled(True)
            
    def start_listening(self):
        """开始监听"""
        try:
            int(self.port_input.text().strip())  # 验证端口号格式
        except ValueError:
            self.status_label.setText("状态: 端口号必须是数字")
            return
            
        self.is_listening = True
        self.listen_button.setText("停止监听")
        self.address_input.setEnabled(False)
        self.port_input.setEnabled(False)
        self.save_button.setEnabled(True)
        
        # 清空数据队列
        self.matplotlib_widget.wave_data.clear()
        
        # 启动绘图和统计更新
        self.matplotlib_widget.start_plotting()
        self.stats_timer.start()
        
        # 异步启动UDP服务器
        loop = asyncio.get_event_loop()
        loop.create_task(self.start_listening_async())
 
    def stop_listening(self):
        """停止监听"""
        self.is_listening = False
        self.listen_button.setText("开始监听")
        self.address_input.setEnabled(True)
        self.port_input.setEnabled(True)
        
        # 停止UDP服务器
        if self.udp_transport:
            self.udp_transport.close()
            self.udp_transport = None
            
        # 停止绘图和统计更新
        self.matplotlib_widget.stop_plotting()
        self.matplotlib_widget.wave_data.clear()
        self.stats_timer.stop()
        
        self.status_label.setText("状态: 已停止")
        
    def update_stats(self):
        """更新数据统计"""
        data_size = len(self.matplotlib_widget.signals)
        self.data_label.setText(f"接收数据: {data_size} 采样")
        
        # 更新解码统计
        self.update_decode_stats()
        
    def save_audio(self):
        """保存音频数据"""
        if len(self.matplotlib_widget.signals) > 0:
            try:
                signal_data = np.array(self.matplotlib_widget.signals)
 
                # 保存为WAV文件
                with wave.open("received_audio.wav", "wb") as wf:
                    wf.setnchannels(1)  # 单声道
                    wf.setsampwidth(2)  # 采样宽度为2字节
                    wf.setframerate(self.matplotlib_widget.freq)  # 设置采样率
                    wf.writeframes(signal_data.tobytes())  # 写入数据
                
                self.status_label.setText("状态: 音频已保存为 received_audio.wav")
                print("音频已保存为 received_audio.wav")
                
            except Exception as e:
                self.status_label.setText(f"状态: 保存失败 - {str(e)}")
                print(f"保存音频失败: {e}")
        else:
            self.status_label.setText("状态: 没有足够的数据可保存")
 
 
async def main():
    """异步主函数"""
    app = QApplication(sys.argv)
    
    # 设置异步事件循环
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    
    window = MainWindow()
    window.show()
    
    try:
        with loop:
            await loop.run_forever()
    except KeyboardInterrupt:
        print("程序被用户中断")
    finally:
        # 确保清理资源
        if window.udp_transport:
            window.udp_transport.close()