用声卡实现的存储示波器

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/xufive/article/details/82893131

用声卡实现的存储示波器


背景知识

如果没有工科背景,就不要纠结于什么是示波器以及为什么要加上存储这个限定词了,我们还是关注重点吧:什么是音频信号?我们人耳能听到的声音的频率范围,大约在20Hz到20000Hz之间,低于下限的,叫次声波,超过上限的,叫超声波。麦克将声音变成了电流,这就是音频信号。音频信号有频率和幅度的变化,存储示波器可以把一段时间内的音频信号直观地显示在屏幕上。

采样频率

声音是连续的,麦克输出的音频信号也是连续的。计算机只能处理数字化信息,所以要对音频信号做数字化处理,这就是所谓的模(拟)数(字)转换或A/D转换,其本质是每隔一个固定间隔时间测量一次信号的大小,并用这个测量值近似代替这个时间间隔内的信号幅度。如果测量的频率超过信号最高频率的两倍,A/D转换就可以取得很好的效果。这个测量频率就是采样频率,业界的标准之一是44100Hz,是音频上限的两倍多一点。

量化精度

A/D转换过程中每次采样得到的数据都需要保存下来。采集到的信号大小,如果用一个字节表示,则信号的动态范围是从-128到127,用两个字节表示,则信号的动态范围是从-32768到32767。这就是所谓的量化精度。

生产者/消费者模式

让我们来想象一个包饺子的场景:有人负责擀皮儿,擀好的饺子皮儿一张张摞成一摞;有人负责包饺子,从成摞的饺子皮儿上揭起一张,放馅儿、捏紧,码放在平板上;有人负责煮饺子,一次取走一平板。擀皮儿、包饺子、煮饺子,是三道相互依赖又各自独立的工序,前道工序是生产者,后道工序是消费者,生产者和消费者之间使用缓冲区作为隔离,最大限度地解除二者之间的相互影响。


总体规划

设计目标

为了描述方便,我先把最终的效果贴在下面。

在这里插入图片描述

功能规划

  • 支持实时采集和触发采集两种模式
  • 触发模式下,可设置触发幅度阈值和触发数量阈值
  • 点击开始按钮则启动数据采集并同步显示(支持快捷键)
  • 点击停止按钮则停止数据采集(支持快捷键)
  • 可调整幅度显示比例(支持鼠标滚轮)
  • 可调整窗口时间宽度
  • 可在数据时间轴上快速滑动时间窗,实现快速数据定位
  • 可保存当前数据为文件(支持快捷键)
  • 可打开历史数据文件(支持快捷键)
  • 可保存当前屏幕波形为图片文件(支持快捷键)
  • 自动适应不同屏幕分辨率,改变窗口大小时自动调整界面

界面规划

  • 屏幕分成两个区域:中心区域和右侧操作区域
  • 中心区域主体是示波器屏幕,示波器屏幕是用于定位数据时间点的滑块
  • 右侧操作区域,自上而下,依次是幅度旋钮、时间窗宽度旋钮、模式选择、幅度阈值- 选择、数量阈值选择和启动/停止按钮

程序结构

文件或文件夹 说明
DSO.py 主程序,实现程序框架
audioCapture.py 音频采集模块,定了一个音频采集类AudioCapture
plotPanel.py 数据绘图模块,定了一个示波器屏幕类WaveScreen
res 资源文件夹
data 用户数据文件夹

从声卡采集数据

声音采集类的定义

pyaudio模块是python最常用的声卡模块,可以使用 pip install pyaudio 下载安装。我们在audioCapture.py文件中定义了AudioCapture类,用于从声卡采集数据。

源码:audioCapture.py

# -*- coding: utf-8 -*-

import pyaudio
import numpy as np

class AudioCapture(object):
    '''通过声卡采集音频,数据存入队列'''

    def __init__(self, dq, mode=0, level=256, over=32):
        '''构造函数'''

        self.dq = dq                                # 数据队列
        self.mode = mode                            # 实时模式(mode=0)/触发模式(mode=1)
        self.level = level                          # 触发模式下的触发阈值
        self.over = over                            # 触发模式下的触发数量
        self.chunk = 1024                           # 数据块大小
        self.running = False                        # 声音采集工作状态

    def set(self, **kwds):
        '''设置参数'''

        if 'mode' in kwds:
            self.mode = kwds['mode']
        if 'level' in kwds:
            self.level = kwds['level']
        if 'over' in kwds:
            self.over = kwds['over']

    def run(self):
        '''音频采集'''

        pa = pyaudio.PyAudio()
        stream = pa.open(
            format              = pyaudio.paInt16,  # 量化精度
            channels            = 1,                # 通道数
            rate                = 44100,            # 采样速率
            frames_per_buffer   = self.chunk,       # pyAudio内部缓存的数据块大小
            input               = True
        )

        self.running = True
        while self.running:
            data = stream.read(self.chunk)
            data = np.fromstring(data, dtype=np.int16)

            # 实时模式下,或者触发模式下超过触发阈值的数据量多于触发数量(1个数据块内)
            if self.mode == 0 or np.sum([data > self.level, data < -self.level]) > self.over:
                try:
                    self.dq.put(data, block=False)
                except:
                    print 'The data queue is Full!'
                    pass

        stream.close()
        pa.terminate()

    def stop(self):
        '''停止采集'''

        self.running = False

这段代码定义了一个音频采集(AudioCapture)类中,实例化时需要提供一个数据队列。从声卡读出的数据是str类型,需要使用numpy的fromstring()方法转成numpy的array类型。另外请注意,向队列中写数据时,采用的是非阻塞式的,如果队列已满,则会抛出异常,所以需要捕获该异常。

消费者/生产者实例

下面的代码,演示了一个典型的生产者/消费者模式:一个子线程负责采集数据并写入队列,一个子线程负责从队列中取出数据并显示。同时,也展示了如何创建及使用队列、如何创建及管理线程。

import Queue
import threading
import time

# 生产者/消费者模式
# 音频采集——生产数据,使用子线程,运行线程函数,本例是ac.run()
# 数据绘图——消费数据,使用子线程,运行线程函数,本例是read_queue()
# 生产线程和消费线程之间,使用先进先出(FIFO)的队列缓冲区

dq = Queue.Queue(100)
ac = AudioCapture(dq)

def read_queue(dq):
    while True:
        data = dq.get(block=True)
        print data.min(), data.max(), data.var()

reading_thread = threading.Thread(target=read_queue, args=(dq,))
reading_thread.setDaemon(True)
reading_thread.start()

capture_thread = threading.Thread(target=ac.run)
capture_thread.setDaemon(True)
capture_thread.start()

cmd = raw_input('Waiting...Press any key to stop.')
ac.stop()

while capture_thread.isAlive():
    #print 'running...'
    time.sleep(0.01)

print 'Game Over.'

wxPython布局基础

最简单的窗口程序框架

万丈高楼平地起。几乎所有的窗口程序,都可以从下面这个基本框架开始。

源码:base.py

#-*- coding: utf-8 -*-

import sys, os
import wx, win32api

APP_NAME = u'Digital Storage Oscilloscope'
APP_ICON_NAME = "res/wave.ico"

class mainFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent, -1, APP_NAME, style=wx.DEFAULT_FRAME_STYLE)
        self.Maximize()
        self.SetBackgroundColour(wx.Colour(240, 240, 240))

        # 图标显示
        if hasattr(sys, "frozen") and getattr(sys, "frozen") == "windows_exe":
            exeName = win32api.GetModuleFileName(win32api.GetModuleHandle(None))
            icon = wx.Icon(exeName, wx.BITMAP_TYPE_ICO)
        else :
            icon = wx.Icon(APP_ICON_NAME, wx.BITMAP_TYPE_ICO)
        self.SetIcon(icon)        
#----------------------------------------------------------------------
class mainApp(wx.App):
    def OnInit(self):
        frame = mainFrame(None)
        frame.Show()
        return True
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = mainApp(redirect=True, filename="debug.txt")
    app.MainLoop()

界面布局方法

在开始UI设计之前,有必要先来了解一下wxPython的控件布局理论。wx的所有控件几乎都有parent/id/pos/size/style等属性,其中pos是position的简写,这是一个二元组,表示控件左上角距离在其父级控件左上角的像素距离。我们可以通过设置每个控件的pos实现控件布局,这就是所谓的静态布局法。当程序窗口尺寸变化时,静态布局很难保持好的显示效果,所以更常用的布局方法是使用布局管理控件。

wx.BoxSizer是最常用的布局管理控件,可以将其视为控件容器。装入wx.BoxSizer中的所有控件,垂直或者水平排列。不同于大多数的控件有具体的形象,wx.BoxSizer是无形的、不可见的,实例化时也不需要parent/id/pos/size/style等属性,只需要指定是水平的还是垂直的。下面这段代码演示了如何使用wx.BoxSizer实现布局。

#-*- coding: utf-8 -*-

import sys, os
import wx, win32api

APP_NAME = u'Digital Storage Oscilloscope'
APP_ICON_NAME = "res/wave.ico"

class mainFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent, -1, APP_NAME, style=wx.DEFAULT_FRAME_STYLE)
        self.SetBackgroundColour(wx.Colour(240, 240, 240))
        self.SetSize((400,200))
        self.Center()

        # 图标显示
        if hasattr(sys, "frozen") and getattr(sys, "frozen") == "windows_exe":
            exeName = win32api.GetModuleFileName(win32api.GetModuleHandle(None))
            icon = wx.Icon(exeName, wx.BITMAP_TYPE_ICO)
        else :
            icon = wx.Icon(APP_ICON_NAME, wx.BITMAP_TYPE_ICO)
        self.SetIcon(icon)

        # 2个文本控件、4个数据输入框、1个按钮
        st1 = wx.StaticText(self, -1, u'幅度', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        st2 = wx.StaticText(self, -1, u'时间', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        tc11 = wx.TextCtrl(self, -1, u'', style=wx.TE_CENTER)
        tc12 = wx.TextCtrl(self, -1, u'', style=wx.TE_CENTER)
        tc21 = wx.TextCtrl(self, -1, u'', style=wx.TE_CENTER)
        tc22 = wx.TextCtrl(self, -1, u'', style=wx.TE_CENTER)
        btn = wx.Button(self, -1, u'确定')

        sizer_0 = wx.BoxSizer(wx.VERTICAL)  # 垂直布局控件
        sizer_11 = wx.BoxSizer()            # 水平布局空间      
        sizer_12 = wx.BoxSizer()            # 水平布局空间

        # sizer_11 装入1个文本控件(st1)、2个数据输入框(tc11/tc12)
        sizer_11.Add(st1, 0, wx.ALIGN_CENTER_VERTICAL|wx.RIGHT, 10)
        sizer_11.Add(tc11, 2, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 0)
        sizer_11.Add(tc12, 1, wx.ALIGN_CENTER_VERTICAL|wx.LEFT, 5)

        # sizer_12 装入1个文本控件(st2、2个数据输入框(tc21/tc22)
        sizer_12.Add(st2, 0, wx.ALIGN_CENTER_VERTICAL|wx.RIGHT, 10)
        sizer_12.Add(tc21, 2, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 0)
        sizer_12.Add(tc22, 3, wx.ALIGN_CENTER_VERTICAL|wx.LEFT, 5)

        # sizer_0 装入sizer_11、sizer_12和按钮(btn)
        sizer_0.Add(sizer_11, 0, wx.EXPAND|wx.TOP|wx.LEFT|wx.RIGHT, 20)
        sizer_0.Add(sizer_12, 0, wx.EXPAND|wx.TOP|wx.LEFT|wx.RIGHT, 20)
        sizer_0.Add(btn, 1, wx.EXPAND|wx.ALL, 20)

        # 将sizer_0放置到父级控件上
        self.SetSizer(sizer_0)
        self.SetAutoLayout(True)

#----------------------------------------------------------------------
class mainApp(wx.App):
    def OnInit(self):
        frame = mainFrame(None)
        frame.Show()
        return True
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = mainApp()
    app.MainLoop()

改变窗口大小,可以看到控件位置会自动调整。显示效果如下图所示。

在这里插入图片描述

在这里插入图片描述


界面设计

示波器屏幕原型

为了保持代码结构清晰,我们把示波器屏幕代码独立出来,单独保存为一个模块,文件名为plotPanel.py。示波器屏幕类WaveScreen继承自wx.Panel类,wx.Panel类是UI设计中的面板控件,可以在其上放置按钮、图片、文字、输入框等控件。

plotPanel_0.py

# -*- coding: utf-8 -*-

import wx

class WaveScreen(wx.Panel):
    '''示波器显示屏幕'''

    def __init__(self, parent):
        '''构造函数'''

        wx.Panel.__init__(self, parent, -1, style=wx.EXPAND)
        self.SetBackgroundColour(wx.Colour(0, 0, 0))

        self.parent = parent
        self.ML,self.MR,self.MT,self.MB = 70,70,40,40 # 绘图边框距屏幕边缘距离(左右上下)

        self.Bind(wx.EVT_SIZE, self.onSize)
        self.Bind(wx.EVT_PAINT, self.onPaint)

    def onSize(self, evt):
        '''响应窗口大小变化'''

        w, h = self.parent.GetSize()
        self.w_scr, self.h_scr = w-176, h-118               # 示波器屏幕宽度、高度
        self.rePaint()

    def onPaint(self, evt):
        '''响应重绘事件'''

        dc = wx.PaintDC(self)
        self.plot(dc)

    def rePaint(self):
        '''手动重绘'''

        dc = wx.ClientDC(self)
        self.plot(dc)

    def plot(self, dc):
        '''绘制屏幕'''

        dc.Clear()

        # 绘制外边框
        dc.SetPen(wx.Pen(wx.Colour(224,0,0), 1))
        dc.DrawLine(self.ML, self.MT, self.w_scr-self.MR, self.MT)
        dc.DrawLine(self.ML, self.h_scr-self.MB, self.w_scr-self.MR, self.h_scr-self.MB)
        dc.DrawLine(self.ML, self.MT, self.ML, self.h_scr-self.MB)
        dc.DrawLine(self.w_scr-self.MR, self.MT, self.w_scr-self.MR, self.h_scr-self.MB)

框架原型

根据总体设计规划,在最简单的窗口程序框架的基础上,应用布局管理控件,将数字存储示波器的界面写成代码如下。这段代码,只包含了控件和控件布局,不涉及任何的处理逻辑。运行显示的效果已经和设计目标完全一样了,只是无法做任何操作,除了点击“关于”菜单。

DSO_0.py

#-*- coding: utf-8 -*-

import sys, os
import wx, win32api
import wx.lib.buttons as buttons
import wx.lib.agw.knobctrl as KC
from wx.lib.wordwrap import wordwrap

# 请注意:此处导入的是plotPanel_0,而非plotPanel
from plotPanel_0 import *

APP_NAME = u'Digital Storage Oscilloscope'
APP_ICON_NAME = "res/wave.ico"
APP_VERSION = '0.99'

class mainFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent, -1, APP_NAME, style=wx.DEFAULT_FRAME_STYLE)
        self.Maximize()
        self.SetBackgroundColour(wx.Colour(240, 240, 240))

        # 图标显示
        if hasattr(sys, "frozen") and getattr(sys, "frozen") == "windows_exe":
            exeName = win32api.GetModuleFileName(win32api.GetModuleHandle(None))
            icon = wx.Icon(exeName, wx.BITMAP_TYPE_ICO)
        else :
            icon = wx.Icon(APP_ICON_NAME, wx.BITMAP_TYPE_ICO)
        self.SetIcon(icon)

        self.__create_menu_bar()                        # 创建菜单栏
        self.__create_status_bar()                      # 创建状态栏

        self.mode_ch = [u'实时模式', u'触发模式']          # 触发模式选择项
        self.level_ch = ['128', '256', '512', '1024']   # 触发幅度选择项
        self.over_ch = ['1', '8', '32', '128']          # 触发数量选择项

        # ------------------------------------------------------
        # 0. 创建布局管理控件
        sizer_max = wx.BoxSizer()                       # 最顶层的布局控件,水平布局
        sizer_left = wx.BoxSizer(wx.VERTICAL)           # 左侧区域布局控件,垂直布局
        sizer_right = wx.BoxSizer(wx.VERTICAL)          # 右侧区域布局控件,垂直布局

        # 1. 实例化示波器屏幕
        self.screen = WaveScreen(self)

        # 2. 创建垂直轴(幅度)调整旋钮
        self.label_knob_V = wx.StaticText(self, -1, u'幅度调整', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        self.knob_V = KC.KnobCtrl(self, -1, size=(120, 120))
        self.knob_V.SetBackgroundColour(wx.Colour(240, 240, 240))
        self.knob_V.SetTags(range(0, 171, 10))
        self.knob_V.SetAngularRange(-45, 225)
        self.knob_V.SetValue(150)

        # 3. 创建水平轴(时间)调整旋钮
        self.label_knob_H = wx.StaticText(self, -1, u'宽度调整', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        self.knob_H = KC.KnobCtrl(self, -1, size=(120, 120))
        self.knob_H.SetBackgroundColour(wx.Colour(240, 240, 240))
        self.knob_H.SetTags(range(0, 131, 10))
        self.knob_H.SetAngularRange(-45, 225)
        self.knob_H.SetValue(40)

        # 4. 创建模式选择、幅度阈值选择和数量阈值选择
        self.mode_rb = wx.RadioBox(self,
            id              = -1,
            label           = u'模式选择',
            choices         = self.mode_ch,
            majorDimension  = 1,
            style           = wx.RA_SPECIFY_COLS,
            name            = 'mode'
        )

        self.level_rb = wx.RadioBox(self,
            id              = -1,
            label           = u'触发阈值',
            choices         = self.level_ch,
            majorDimension  = 2,
            style           = wx.RA_SPECIFY_COLS,
            name            = 'level'
        )

        self.over_rb = wx.RadioBox(self,
            id              = -1,
            label           = u'触发数量',
            choices         = self.over_ch,
            majorDimension  = 2,
            style           = wx.RA_SPECIFY_COLS,
            name            = 'over'
        )

        self.mode_rb.SetSelection(0)
        self.level_rb.SetSelection(1)
        self.over_rb.SetSelection(2)

        # 5. 创建启动/停止按钮
        self.start_btm = wx.Bitmap(os.path.join('res', 'start.png'), wx.BITMAP_TYPE_ANY)
        self.stop_btm = wx.Bitmap(os.path.join('res', 'stop.png'), wx.BITMAP_TYPE_ANY)
        self.op_btn = buttons.GenBitmapToggleButton(self, -1, bitmap=self.start_btm, size=(-1,80))
        self.op_btn.SetBackgroundColour(wx.Colour(192, 224, 224))
        self.op_btn.SetBitmapSelected(self.stop_btm)

        # 6. 创建滑块
        self.slider = wx.Slider(self, -1, 0, 0, 100, size=wx.DefaultSize, style=wx.SL_HORIZONTAL)

        # 7. 部件组装
        sizer_left.Add(self.screen, 1, wx.EXPAND|wx.ALL, 0)
        sizer_left.Add(self.slider, 0, wx.EXPAND|wx.TOP|wx.BOTTOM, 5)

        sizer_right.Add(self.knob_V, 0, wx.TOP, 0)
        sizer_right.Add(self.label_knob_V, 0, wx.EXPAND|wx.TOP, 10)
        sizer_right.Add(self.knob_H, 0, wx.TOP, 20)
        sizer_right.Add(self.label_knob_H, 0, wx.EXPAND|wx.TOP, 10)
        sizer_right.Add(self.mode_rb, 0, wx.EXPAND|wx.TOP, 40)
        sizer_right.Add(self.level_rb, 0, wx.EXPAND|wx.TOP, 20)
        sizer_right.Add(self.over_rb, 0, wx.EXPAND|wx.TOP, 20)
        sizer_right.Add(self.op_btn, 0, wx.EXPAND|wx.TOP, 30)

        sizer_max.Add(sizer_left, 1, wx.EXPAND|wx.ALL, 0)
        sizer_max.Add(sizer_right, 0, wx.ALL, 20)

        # 8. 大功告成
        self.SetSizer(sizer_max)
        self.SetAutoLayout(True)

    def __create_menu_bar(self):
        '''创建菜单栏'''

        id_open = wx.NewId()
        id_save_data = wx.NewId()
        id_save_img = wx.NewId()
        id_quit = wx.NewId()
        id_start = wx.NewId()
        id_stop = wx.NewId()
        id_about = wx.NewId()

        mb = wx.MenuBar()

        m = wx.Menu()
        m.Append(id_open, u'打开数据文件\tCtrl+O', u'打开保存的数据文件')
        m.Append(id_save_data, u'保存数据为文件\tCtrl+S', u'将当前数据保存为文件')
        m.Append(id_save_img, u'保存波形为图片\tCtrl+P', u'将当前波形保存为图片')
        m.AppendSeparator()
        m.Append(id_quit, u'退出\tCtrl+C', u'退出系统')
        mb.Append(m, u'文件(&F)')

        m = wx.Menu()
        m.Append(id_start, u'启动\tCtrl+R', u'启动数据采集')
        m.Append(id_stop, u'停止\tCtrl+T', u'停止数据采集')
        mb.Append(m, u'操作(&O)')

        m = wx.Menu()
        m.Append(id_about, u'关于\tCtrl+A', '')
        mb.Append(m, u'帮助(&H)')

        self.SetMenuBar(mb)
        self.Bind(wx.EVT_MENU, self.onMenuOpen, id=id_open)
        self.Bind(wx.EVT_MENU, self.onMenuSaveData, id=id_save_data)
        self.Bind(wx.EVT_MENU, self.onMenuSaveImage, id=id_save_img)
        self.Bind(wx.EVT_MENU, self.OnMenuQuit, id=id_quit)
        self.Bind(wx.EVT_MENU, self.onMenuStart, id=id_start)
        self.Bind(wx.EVT_MENU, self.onMenuStop, id=id_stop)
        self.Bind(wx.EVT_MENU, self.onMenuAbout, id=id_about)

    def __create_status_bar(self):
        '''创建状态栏'''

        self.statusbar = self.CreateStatusBar()
        self.statusbar.SetFieldsCount(3)
        self.statusbar.SetStatusWidths([-1,-3, -1])
        self.statusbar.SetStatusStyles([wx.SB_RAISED, wx.SB_RAISED, wx.SB_RAISED])
        self.statusbar.SetStatusText(u'[email protected], Jilin University', 2)

    def onMenuOpen(self, evt):
        '''打开数据文件'''

        pass

    def onMenuSaveData(self, evt):
        '''保存数据为文件'''

        pass

    def onMenuSaveImage(self, evt):
        '''保存为图片'''

        pass

    def OnMenuQuit(self, evt):
        '''关闭窗口'''

        pass

    def onMenuStart(self, evt):
        '''响应启动捕捉菜单'''

        pass

    def onMenuStop(self, evt):
        '''响应停止捕捉菜单'''

        pass

    def onMenuAbout(self, evt):
        '''关于'''

        about = wx.AboutDialogInfo()
        about.Name = APP_NAME
        about.Version = APP_VERSION
        about.Copyright = u"(C) 吉林大学数学学院 许棪"
        about.Description = wordwrap(
            u"音频信号存储示波器是用计算机声卡采集音频输入信号,并将音频数据绘制在屏幕上的一款软件,"
            u"可以实时模式或触发模式工作,并可将数据和波形保存为文件。"
            u"\n\n你可以尝试着用它来记录并显示你的口哨声,或者找到更多更有趣的应用。"
            u"我曾经用它来观察导体切割磁场产生的电流。"
            u'如果你也想重复我的实验,请谨慎操作,以免损坏声卡或电脑。',
            400, wx.ClientDC(self), margin=5)
        #about.WebSite = ("[email protected]", u"给开发者发邮件")
        about.Developers = [u"许棪" ]
        licenseText = u"欢迎非商业性的使用、复制、传播和二次开发。"
        about.License = wordwrap(licenseText, 400, wx.ClientDC(self), margin=5)

        wx.AboutBox(about)

#----------------------------------------------------------------------
class mainApp(wx.App):
    def OnInit(self):
        frame = mainFrame(None)
        frame.Show()
        return True
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = mainApp()
    app.MainLoop()

逻辑处理

声明主窗口的若干重要属性

根据规划,示波器有两种工作模式:实时模式和触发模式。模式选择控件(RedioButton)可以改变工作模式,而数据采集线程需要根据当前模式选择恰当的处理方式,因此,当前工作模式是一个很多地方都会用到的数据,有必要把它设置成主窗口类的属性之一。类似的情况还有当前触发阈值、当前触发数量、滑块位置表示的当前时间,时间轴窗口宽度、当前纵轴最大值等。

我们还需要创建一个声卡采集对象,用于采集声卡数据。声卡采集对象具有run()和stop()方法,受控于程序界面上启动/停止按钮,run()是以线程的方式运行的,采集到的数据写入队列缓冲区。另外,从数据队列中顺序读出的数据块,也需要保存在预先设定的数据结构中,为此我们准备了一个list来存储这些数据。

class mainFrame(wx.Frame):
    '''音频信号存储示波器窗口类'''

    def __init__(self, parent):
        '''构造函数'''

        ... ...
        if not os.path.isdir('data'): # 如果数据存储文件夹不存在,则创建
            os.mkdir('data')

        self.mode = 0                                   # 当前模式
        self.level = 256                                # 当前触发阈值
        self.over = 32                                  # 当前触发数量
        self.curr_pos = 0                               # 滑块位置表示的当前时间
        self.time_width = 10                            # 时间轴窗口宽度(单位:毫秒)
        self.value_max = 32768                          # 当前纵轴最大值

        self.audio = list()                             # 保存从队列中读出的数据
        self.dq = Queue.Queue(100)                      # 数据缓存队列
        self.ac = AudioCapture(
            self.dq,
            mode=self.mode,
            level=self.over,
            over=self.over)                             # 创建音频采集对象
        self.capture_thread = None                      # 音频采集线程
        ... ...

为什么声音采集线程是None呢?因为这个线程只有在点击启动按钮时才会被创建和运行,构造函数里仅仅是声明。不提前声明,也完全没有问题,这样做是为了提供程序的可读性。需要说明的是,把采集线程定义为类的属性,是为了关闭窗口时检查这个线程是否还在运行,若还在运行,则先关闭声再终止线程。为此,我们需要将窗口关闭事件wx.EVT_CLOSE绑定到事件函数OnMenuQuit()上,该函数也是菜单中“退出系统”的响应函数。

class mainFrame(wx.Frame):
    '''音频信号存储示波器窗口类'''

    def __init__(self, parent):
        '''构造函数'''

        ... ...
        self.Bind(wx.EVT_CLOSE, self.OnMenuQuit) # 将窗口关闭事件绑定到事件函数
        ... ...

    def OnMenuQuit(self, evt):
        '''关闭窗口'''

        if self.capture_thread and self.capture_thread.isAlive():
            self.ac.stop()
        while self.capture_thread and self.capture_thread.isAlive():
            time.sleep(0.1)
        self.Destroy()

在状态栏上显示采集到的数据时间长度

在创建状态栏时,已经演示了如何在状态蓝的指定区域显示信息。为了更简洁一点,我们为mainFrame定义了一个显示数据时间长度的专用方法setTip()。那么数据时长如何计算呢?假定声卡采样频率为44100Hz,每次读取1024字节的数据块,那么一个数据块对应的时间长度是23.219954648526078毫秒(1024*1000/44100),我们把这个数据写成一个常量。

TIME_K = 23.219954648526078 # 采样速率为44100时,1024个数据时长,单位毫秒

class mainFrame(wx.Frame):
    '''音频信号存储示波器窗口类'''

    def setTip(self):
        '''设置状态条上数据长度信息'''

        length = len(self.audio) * TIME_K
        self.statusbar.SetStatusText(u'总时长:%.03f秒'%(length/1000.0), 1)

从数据队列中读出数据

在数据生产者/消费者模式中,数据的生产和消费是各自独立的,二者使用数据缓冲区耦合。在本例中,从声卡采集数据的线程,就是数据生产者,对应的,从队列中读出数据的线程,就是数据消费者。线程的创建时需要将线程函数作为参数传入,而线程函数的参数(如果有的话),则视为创建线程的args参数或kargs参数。在窗口程序中,如果线程函数需要调用窗口类的方法,一般需要借助于wx.CallAfter()。

class mainFrame(wx.Frame):
    '''音频信号存储示波器窗口类'''
    def __init__(self, parent):
        '''构造函数'''

        ... ...
        # 启动线程:以阻塞方式从队列中读出数据
        read_thread = threading.Thread(target=self.readData)
        read_thread.setDaemon(True)
        read_thread.start()
        ... ...

    def readData(self):
        '''从队列中读取数据'''

        while True:
            data = self.dq.get(block=True)
            self.audio.append(data)
            length = len(self.audio) * TIME_K
            if length > self.time_width:
                self.curr_pos = length - self.time_width
            else:
                self.curr_pos = 0.0
            self.screen.rePaint()
            wx.CallAfter(self.setTip)

猜你喜欢

转载自blog.csdn.net/xufive/article/details/82893131