基于声卡实现的音频存储示波器,可作为电磁学实验的测量仪表

1 前言

十年前女儿读高中,电磁学是那个学期物理课的重点内容。女儿回家吐槽说课堂上的物理实验是纸上谈兵,老师只播放幻灯片和实验动画,并没有仪表可以直观地看到电磁实验中感应电流的变化。为了帮助女儿理解电磁感应,爷儿俩花了一星期时间,做了一个用声卡测量电磁实验中感应电流的软件,还有一套楞次定律的实验装置。

在这里插入图片描述

还记得楞次定律吗?增反减同,来拒去留

那时候,Python还是默默无闻的无名之辈,而我当时用的工具就是Python,GUI库用的是wxPython,数据采集和处理则是pyAudio模块和Numpy模块。时至今日,Python已成为最具影响力的开发工具,而pyAudio和Numpy依然非常活跃,wxPython也早已浴火重生,迎来了phoenix版。

最近有闲,整理了一下思路,依然使用pyAudio+Numpy+wxPython,重新设计了一个极具质感的音频存储示波器软件,希望能给正在学习电磁学的孩子和家长带来一些启发和帮助。倘若有机构或学校有兴趣在物理实验中使用这套工具软件,我愿意提供更多的协助。获取本项目完整的代码和资源文件请移步至GitHub

在这里插入图片描述

新版音频存储示波器界面

2 原理和架构

2.1 采样定理

音频信号属于模拟信号,经过声卡的信号采集和处理后成为计算机能够处理的数字化信息。模拟信号的数字化以采样定理为理论基础。采样定理,又称香农采样定理或奈奎斯特采样定理,描述起来非常简单:采样频率大于或等于有效信号最高频率的两倍,采样值就可以包含原始信号的所有信息,被采样的信号就可以不失真地还原成原始信号。采样定理暗含了两个基本概念,即采样频率和量化精度。

2.1.1 采样频率

对于声卡而言,采样频率是指每秒钟从连续的音频信号中采集并组成离散信号的采样个数,以赫兹(Hz)为单位。采样频率越高,声卡输出的采样数据就越多,对信号波形的表示也越精确。声卡常用的采样频率为22050Hz和44100Hz,能够复现的最大频率是10KHz和20KHz,分别对应调频广播级别的音质和CD级别的音质。

2.1.2 量化精度

声卡采集到的每一个数据点都以整型数据保存,整型数据的位数就是量化精度。常用的量化精度有8位、16位和24位等多种形式。以16位量化精度为例,每个采样数据占两个字节,表示的信号强度在-32768~32767之间。

2.2 软件架构

假设音频示波器的采样频率为44100Hz,量化精度选择16位,声卡每秒钟将产生88.2KB的数据,这些数据需要实时地显示在音频示波器的屏幕上。这里,声卡作为数据的生产者,音频示波器的屏幕作为数据的消费者,各自独立地从事自己的工作,又同时保持着严格的时序关系。这就是典型的生产者-消费者模式,这个模式的核心就是数据队列。

在这里插入图片描述

生产者-消费者模式

3 部件设计和装配

3.1 采样器

pyAudio是Python麾下一款历史悠久性能卓越的音频处理模块,尤其擅长声音采集。AudioSampler是基于pyAudio模块自定义的音频采集器类,默认采样频率为44100Hz,实例化时需要提供一个队列作为参数。采样器实例工作时,数据块被连续不断地写入队列。每个数据块大小默认为1024个采样点。采样器支持两种工作模式:实时模式和触发。所谓实时模式,就是输出所有的采样数据块;所谓触发模式,就是一个数据块内信号幅度超过触发阈值的采样点数量超过触发数量时才会输出,否则就丢弃。完整的采样器代码如下。

sample.py

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

import pyaudio
import numpy as np

class AudioSampler:
    """音频采样器"""
    
    def __init__(self, dq, rate=44100):
        """构造函数"""
        
        self.dq = dq                                # 数据队列
        self.rate = rate                            # 采样频率
        self.chunk = 1024                           # 数据块大小
        self.mode = 1                               # 模式开关:0 - 触发模式,1 - 实时模式
        self.level = 16                             # 触发模式下的触发阈值
        self.over = 1                               # 触发模式下的触发数量
        self.running = False                        # 采样器工作状态
        
    def set_args(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 start(self):
        """音频采集"""
        
        pa = pyaudio.PyAudio()
        stream = pa.open(
            format              = pyaudio.paInt16,  # 量化精度(16位,动态范围:-32768~32767)
            channels            = 1,                # 通道数
            rate                = self.rate,        # 采样频率
            frames_per_buffer   = self.chunk,       # pyAudio内部缓存的数据块大小
            input               = True
        )
        
        self.running = True
        self.dq.queue.clear()
        
        while self.running:
            data = stream.read(self.chunk)
            data = np.fromstring(data, dtype=np.int16)
            
            if self.mode or np.sum([data > self.level, data < -self.level]) > self.over:
                self.dq.put(data)
        
        stream.close()
        pa.terminate()
        
    def stop(self):
        """停止采集"""
        
        self.running = False

3.2 显示屏

Matplotlib是一个不错的绘图模块,但不适合绘制动态数据——尽管它有animation子模块,怎奈刷新速度跟不上每秒80KB的数据生产速度,只能另寻他途。这里,我用wx.DC配合Numpy的高效数据处理,以近乎“手工”的方式构造了一个示波器显示屏幕,可以轻松应对每秒80KB的数据的数据流量,毫无迟滞感。完整的示波器显示屏幕代码如下。

screen.py

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

import wx
import numpy as np

class Screen(wx.Panel):
    """示波器显示屏幕"""
    
    def __init__(self, parent, rate=44100):
        """构造函数"""
        
        wx.Panel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
        self.SetBackgroundColour(wx.Colour(0, 0, 0))
        self.SetDoubleBuffered(True)
                
        self.parent = parent                        # 父级控件
        self.rate = rate                            # 采样频率
        self.scale = 1024                           # 信号幅度基准
        self.tw = 32                                # 以ms为单位的时间窗口宽度
        self.pos = 0                                # 时间窗口左侧在数据流上的位置
        self.k = int(self.tw*self.rate/1000)        # 时间窗口覆盖的数据点数
        self.leftdown = False                       # 鼠标左键按下
        self.mpos = wx._core.Point()                # 鼠标位置
        self.data = np.array([], dtype=np.int16)    # 音频数据
        self.scrsize = self.GetSize()               # 示波器屏幕宽度和高度
        self.args = self._update()                  # 绘图参数
        self.font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, 'Courier New')
        
        self.Bind(wx.EVT_SIZE, self.on_size)
        self.Bind(wx.EVT_PAINT, self.on_paint)
        self.Bind(wx.EVT_MOUSEWHEEL, self.on_wheel)
        self.Bind(wx.EVT_LEFT_DOWN, self.on_left_down)
        self.Bind(wx.EVT_LEFT_UP, self.on_left_up)                
        self.Bind(wx.EVT_MOTION, self.on_mouse_motion)
    
    def _update(self):
        """更新绘图参数"""
        
        u_padding, v_padding, gap = 80, 50, 5           # 示波器屏幕左右留白、上下留白、边框间隙
            
        args = {
    
            
            'b_left': u_padding,                        # 示波器边框左侧坐标
            'b_top': v_padding,                         # 示波器边框顶部坐标
            'b_right': self.scrsize[0] - u_padding,     # 示波器边框右侧坐标
            'b_bottom': self.scrsize[1] - v_padding,    # 示波器边框底部坐标
            'w': self.scrsize[0] - 2*(u_padding+gap),   # 示波器有效区域宽度
            'h': self.scrsize[1] - 2*(v_padding+gap),   # 示波器有效区域高度
            'mid': self.scrsize[1]/2,                   # 水平中心线高度坐标
            'up': v_padding + gap,                      # 示波器有效区域顶部坐标
            'down': self.scrsize[1] - v_padding - gap,  # 示波器有效区域底部坐标
            'left': u_padding + gap,                    # 示波器有效区域左侧坐标
            'right': self.scrsize[0] - u_padding - gap  # 示波器有效区域右侧坐标
        }
        
        x = np.linspace(args['left'], args['right'], self.k)
        y = args['mid'] + (args['h']/2)*self.data[self.pos:self.pos+self.k]/self.scale
        skip = max(self.k//args['w'], 1)
        
        if x.shape[0] > y.shape[0]:
            x = x[:y.shape[0]]
        
        if skip > 1:
            y = y[::skip]
            x = x[::skip]
        
        if y.shape[0] == 0:
            y = np.array([args['mid']])
            x = np.array([u_padding + gap])
        else:
            y = np.where(y < args['up'], args['up'], y)
            y = np.where(y > args['down'], args['down'], y)
        
        args.update({
    
    'points':np.stack((x, y), axis=1), 'gu':args['w']/10, 'gv':args['h']/8})
        
        return args
    
    def _check_pos(self):
        """时间窗口位置校正"""
        
        if self.pos < 0 or self.data.data.shape[0] <= self.k:
            self.pos = 0
            self.parent.slider.SetValue(0)
        elif self.pos > self.data.data.shape[0] - self.k:
            self.pos = self.data.data.shape[0] - self.k
            self.parent.slider.SetValue(1000)
        else:
            self.parent.slider.SetValue(int(1000*self.pos/(self.data.data.shape[0] - self.k)))
    
    def on_wheel(self, evt):
        """响应鼠标滚轮调整波形幅度"""
        
        self.scale = self.scale*0.8 if evt.WheelRotation > 0 else self.scale*1.2
        if self.scale < 32:
            self.scale = 32
        if self.scale > 32768:
            self.scale = 32768
        
        self.parent.vknob.SetValue(10 * (np.log2(self.scale)-5))
        self.args = self._update()
        self.Refresh()
    
    def on_left_down(self, evt):
        """响应鼠标左键按下事件"""
        
        self.leftdown = True
        self.mpos = evt.GetPosition()
        
    def on_left_up(self, evt):
        """响应鼠标左键弹起事件"""
        
        self.leftdown = False
        
    def on_mouse_motion(self, evt):
        """响应鼠标移动事件"""
        
        if evt.Dragging() and self.leftdown:
            pos = evt.GetPosition()
            dx, dy = pos - self.mpos
            self.mpos = pos
            
            self.pos -= int(self.k * dx / self.scrsize[0])
            self._check_pos()
            self.args = self._update()
            self.Refresh()
            
    def on_size(self, evt):
        """响应窗口大小变化"""
        
        self.scrsize = self.GetSize()
        self.args = self._update()
        self.Refresh()
    
    def on_paint(self, evt):
        """响应重绘事件"""
        
        dc = wx.PaintDC(self)
        self.plot(dc)
    
    def set_amplitude(self, value):
        """设置幅度缩放比例"""
        
        self.scale = pow(2, 5 + value/10)
        self.args = self._update()
        self.Refresh()
    
    def set_time_width(self, value):
        """设置时间窗口宽度"""
        
        center = self.pos + self.k//2
        self.tw = 0.1 * pow(1.1220184543019633, value)
        self.k = int(self.tw*self.rate/1000)
        self.pos = center - self.k//2
        self._check_pos()
        self.args = self._update()
        self.Refresh()
    
    def append_data(self, data):
        """追加数据"""
        
        self.data = np.hstack((self.data, data))
        self.pos = max(0, self.data.data.shape[0] - self.k)
        self.args = self._update()
        self.Refresh()
    
    def set_pos(self, pos):
        """设置时间窗口位置"""
        
        length = self.data.shape[0] - self.k
        self.pos = int(length*pos/1000) if length > 0 else 0
        self.args = self._update()
        self.Refresh()
        
        if self.pos == 0:
            self.parent.slider.SetValue(0)
    
    def clear(self):
        """清除数据"""
        
        self.data = np.array([], dtype=np.int16)
        self.pos = 0
        self.args = self._update()
        self.Refresh()
    
    def plot(self, dc):
        """绘制屏幕"""
        
        # 绘制中心水平线
        dc.SetPen(wx.Pen(wx.Colour(0,224,0), 1))
        dc.DrawLine(self.args['left'], self.args['mid'], self.args['right'], self.args['mid'])
        
        # 绘制网格
        dc.SetPen(wx.Pen(wx.Colour(64,64,64), 1))
        dc.DrawLineList([(self.args['left']+i*self.args['gu'], self.args['up'], self.args['left']+i*self.args['gu'], self.args['down']) for i in range(0,11)])
        dc.DrawLineList([(self.args['left'], self.args['up']+i*self.args['gv'], self.args['right'], self.args['up']+i*self.args['gv']) for i in [0,1,2,3,5,6,7,8]])
        
        # 绘制数据
        dc.SetPen(wx.Pen(wx.Colour(32,96,255), 1))
        dc.DrawLines(self.args['points'])
        dc.DrawCircle(self.args['points'][-1], 3)
        
        # 绘制外边框
        dc.SetPen(wx.Pen(wx.Colour(224,0,0), 1))
        dc.DrawLines([
            (self.args['b_left'], self.args['b_top']), 
            (self.args['b_right'], self.args['b_top']), 
            (self.args['b_right'], self.args['b_bottom']), 
            (self.args['b_left'], self.args['b_bottom']), 
            (self.args['b_left'], self.args['b_top'])
        ])
        
        # 标注
        dc.SetTextForeground(wx.Colour(224,255,255))
        dc.SetFont(self.font)
        
        top = 100 * self.scale / 32768
        step = top / 4
        for i in range(9):
            label = '%.2f%%'%(top-i*step)
            label_left = label.rjust(8)
            label_right = label.ljust(8)
            dc.DrawText(label_left, self.args['b_left']-70, self.args['up']+i*self.args['gv']-8)
            dc.DrawText(label_right, self.args['b_right']+5, self.args['up']+i*self.args['gv']-8)
        
        start = 1000 * self.pos / self.rate
        step = self.tw / 10
        for i in range(11):
            label = '%.2fms'%(start+i*step)
            label = label.center(12)
            dc.DrawText(label, self.args['left']+i*self.args['gu']-40, self.args['b_top']-25)
            dc.DrawText(label, self.args['left']+i*self.args['gu']-40, self.args['b_bottom']+10)

3.3 旋钮

早期wxPython提供了很多标新立异的控件,比如旋钮控件knobctrl,如下图所示,虽说不够美观,但也勉强可用。最新版的wxPython中,knobctrl依旧存在,却无法设置背景色了,控件游离于界面之外,惨不忍睹。

在这里插入图片描述

远古时期的旋钮控件knobctrl长这个模样

无奈之下,只好自己造旋钮。下面的代码,演示了如何用wx.DC构建控件、如何自定义事件。自造的旋钮效果如下图所示。

在这里插入图片描述

自造的旋钮控件knob长这个模样

完整的旋钮控件代码如下。

knob.py

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

import wx
import numpy as np

wxEVT_KNOB_ANGLE_CHANGED = wx.NewEventType()
EVT_KNOB_ANGLE_CHANGED = wx.PyEventBinder(wxEVT_KNOB_ANGLE_CHANGED, 1)

class KnobEvent(wx.CommandEvent):
    """自定义旋钮事件类"""

    def __init__(self, eventType, eventId=1):
        """构造函数"""

        wx.CommandEvent.__init__(self, eventType, eventId)
    
    def SetValue(self, value):
        """设置当前值"""

        self._value = value
    
    def GetValue(self):
        """返回当前值"""

        return self._value

class Knob(wx.Panel):
    """旋钮"""
    
    def __init__(self, parent, id=wx.ID_ANY, value=50, pos=wx.DefaultPosition, size=(150,150)):
        """构造函数"""
        
        wx.Panel.__init__(self, parent, id, pos, size, style=wx.NO_FULL_REPAINT_ON_RESIZE)
        
        self.bmp_wp = wx.Bitmap('res/wpoint.png', wx.BITMAP_TYPE_ANY)
        self.bmp_bp = wx.Bitmap('res/bpoint.png', wx.BITMAP_TYPE_ANY)
        self.bmp_rp = wx.Bitmap('res/rpoint.png', wx.BITMAP_TYPE_ANY)
        self.bmp_core = wx.Bitmap('res/knob.png', wx.BITMAP_TYPE_ANY)
        
        self._state = 0
        self._value = value
        self._angle = -60, 240
        self.args = self._update()
        self.Refresh()
        
        self.Bind(wx.EVT_SIZE, self.on_size)
        self.Bind(wx.EVT_PAINT, self.on_paint)
        self.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse_event)
    
    def _update(self):
        """更新重绘参数"""
        
        w, h = self.GetSize()
        r = min(w, h)/2 - 5
        origin = w/2, h/2
        
        theta = np.radians(np.linspace(*self._angle, 15))
        x = r * np.cos(theta) + origin[0]
        y = -r * np.sin(theta) + origin[1]
        
        a = np.radians(self._angle[1] - (self._angle[1] - self._angle[0]) * self._value / 100)
        ax = r * np.cos(a) * 0.5 + origin[0]
        ay = -r * np.sin(a) * 0.5 + origin[1]
        
        k = np.where(theta >= a)[0][0]
        if self._value == 0:
            k += 1
        
        return {
    
    'points': np.stack((x, y), axis=1), 'origin': origin, 'r': r, 'curr': (ax, ay), 'k': k}
    
    def SetValue(self, value):
        """设置当前值"""
        
        self._value = value
        self.args = self._update()
        self.Refresh()
    
    def GetValue(self):
        """返回当前值"""
        
        return self._value
    
    def on_mouse_event(self, evt):
        """响应鼠标事件"""
        
        if self._state == 0 and evt.Entering():
            self._state = 1
        elif self._state >= 1 and evt.Leaving():
            self._state = 0
        elif self._state == 2 and evt.LeftUp():
            self._state = 1
        elif self._state == 1 and evt.LeftDown() or self._state == 2 and evt.LeftIsDown():
            self._state = 2
            x, y = evt.GetPosition()
            dx, dy = x - self.args['origin'][0], self.args['origin'][1] - y
            
            if dx == 0:
                angle = 90 if dy > 0 else -90
            else:
                angle = np.degrees(np.arctan(dy/dx)) if dx > 0 else np.degrees(np.arctan(dy/dx)) + 180
            
            if (self._angle[0]-10) < angle < self._angle[0]:
                angle = self._angle[0]
            
            if self._angle[1] < angle < (self._angle[1]+10):
                angle = self._angle[1]
            
            if self._angle[0] <= angle <= self._angle[1]:
                self._value = 100 * (self._angle[1] - angle) / (self._angle[1] - self._angle[0])
                self.args = self._update()
                self.Refresh()
                
                event = KnobEvent(wxEVT_KNOB_ANGLE_CHANGED, self.GetId())
                event.SetEventObject(self)
                event.SetValue(self._value)
                self.GetEventHandler().ProcessEvent(event)
    
    def on_size(self, evt):
        """响应控件改变大小"""
        
        self.args = self._update()
        self.Refresh()
    
    def on_paint(self, evt):
        """响应重绘事件"""
        
        dc = wx.PaintDC(self)
        
        for i in range(self.args['points'].shape[0]):
            if i < self.args['k']:
                dc.DrawBitmap(self.bmp_bp, self.args['points'][i][0]-5, self.args['points'][i][1])
            else:
                dc.DrawBitmap(self.bmp_wp, self.args['points'][i][0]-5, self.args['points'][i][1])
        
        dc.DrawBitmap(self.bmp_core, self.args['origin'][0]-60, self.args['origin'][1]-60)
        dc.DrawBitmap(self.bmp_rp, self.args['curr'][0]-5, self.args['curr'][1])
        
        dc.DrawText('MIN', self.args['points'][-1][0]-35, self.args['points'][-1][1]-5)
        dc.DrawText('MAX', self.args['points'][0][0]+10, self.args['points'][0][1]-5)

3.4 模式切换开关

和旋钮控件类似,完整的模式切换开关代码如下。

onoff.py

# -*- coding: utf-8 -*-
import wx

wxEVT_SWITCH_CHANGED = wx.NewEventType()
EVT_SWITCH_CHANGED = wx.PyEventBinder(wxEVT_SWITCH_CHANGED, 1)

class SwitchEvent(wx.CommandEvent):
    """自定义开关事件类"""

    def __init__(self, eventType, eventId=1):
        """构造函数"""

        wx.CommandEvent.__init__(self, eventType, eventId)
    
    def SetValue(self, value):
        """设置当前值"""

        self._value = value
    
    def GetValue(self):
        """返回当前值"""

        return self._value

class Switch(wx.Panel):
    """开关"""
    
    def __init__(self, parent, id=wx.ID_ANY, value=0, pos=wx.DefaultPosition, size=(150,60)):
        """构造函数"""
        
        wx.Panel.__init__(self, parent, id, pos, size, style=wx.NO_FULL_REPAINT_ON_RESIZE)
        
        self.bmp_s0 = wx.Bitmap('res/switch_0.png', wx.BITMAP_TYPE_ANY)
        self.bmp_s1 = wx.Bitmap('res/switch_1.png', wx.BITMAP_TYPE_ANY)
        
        self._value = value
        self.csize = self.GetSize()
        self.Refresh()
        
        self.Bind(wx.EVT_SIZE, self.on_size)
        self.Bind(wx.EVT_PAINT, self.on_paint)
        self.Bind(wx.EVT_LEFT_UP, self.on_lefte_up)
    
    def SetValue(self, value):
        """设置当前值"""
        
        self._value = value
        self.args = self._update()
        self.Refresh()
    
    def GetValue(self):
        """返回当前值"""
        
        return self._value
    
    def on_lefte_up(self, evt):
        """响应鼠标事件"""
        
        x, y = evt.GetPosition()
        if x < self.csize[0]/2 and self._value == 1 or x >= self.csize[0]/2 and self._value == 0:
            self._value = 0 if self._value == 1 else 1
            self.Refresh()
            
            event = SwitchEvent(wxEVT_SWITCH_CHANGED, self.GetId())
            event.SetEventObject(self)
            event.SetValue(self._value)
            self.GetEventHandler().ProcessEvent(event)
    
    def on_size(self, evt):
        """响应控件改变大小"""
        
        self.csize = self.GetSize()
        self.Refresh()
    
    def on_paint(self, evt):
        """响应重绘事件"""
        
        dc = wx.PaintDC(self)
        p = self.csize[0]/2 - 60, self.csize[1]/2 - 27
        
        if self._value == 0:
            dc.DrawBitmap(self.bmp_s0, *p)
        else:
            dc.DrawBitmap(self.bmp_s1, *p)

3.5 启动停止按钮

和旋钮控件类似,完整的启动停止按钮代码如下。

startstop.py

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

import wx

wxEVT_SS_CHANGED = wx.NewEventType()
EVT_SS_CHANGED = wx.PyEventBinder(wxEVT_SS_CHANGED, 1)

class StartStopEvent(wx.CommandEvent):
    """自定义启停开关事件类"""

    def __init__(self, eventType, eventId=1):
        """构造函数"""

        wx.CommandEvent.__init__(self, eventType, eventId)
    
    def SetValue(self, value):
        """设置当前值"""

        self._value = value
    
    def GetValue(self):
        """返回当前值"""

        return self._value

class StartStop(wx.Panel):
    """启停开关"""
    
    def __init__(self, parent, id=wx.ID_ANY, value=0, pos=wx.DefaultPosition, size=(150,150)):
        """构造函数"""
        
        wx.Panel.__init__(self, parent, id, pos, size, style=wx.NO_FULL_REPAINT_ON_RESIZE)
        
        self.bmp_start = wx.Bitmap('res/start.png', wx.BITMAP_TYPE_ANY)
        self.bmp_stop = wx.Bitmap('res/stop.png', wx.BITMAP_TYPE_ANY)
        
        self._value = value
        self.csize = self.GetSize()
        self.Refresh()
        
        self.Bind(wx.EVT_SIZE, self.on_size)
        self.Bind(wx.EVT_PAINT, self.on_paint)
        self.Bind(wx.EVT_LEFT_UP, self.on_lefte_up)
    
    def SetValue(self, value):
        """设置当前值"""
        
        self._value = value
        self.args = self._update()
        self.Refresh()
    
    def GetValue(self):
        """返回当前值"""
        
        return self._value
    
    def on_lefte_up(self, evt):
        """响应鼠标事件"""
        
        self._value = 0 if self._value == 1 else 1
        self.Refresh()
        
        event = StartStopEvent(wxEVT_SS_CHANGED, self.GetId())
        event.SetEventObject(self)
        event.SetValue(self._value)
        self.GetEventHandler().ProcessEvent(event)
    
    def on_size(self, evt):
        """响应控件改变大小"""
        
        self.csize = self.GetSize()
        self.Refresh()
    
    def on_paint(self, evt):
        """响应重绘事件"""
        
        dc = wx.PaintDC(self)
        p = self.csize[0]/2 - 60, self.csize[1]/2 - 60
        
        if self._value == 0:
            dc.DrawBitmap(self.bmp_start, *p)
        else:
            dc.DrawBitmap(self.bmp_stop, *p)

3.6 装配和测试

有了上面这些部件,按照生产者-消费者模式去构建一个应用程序就是水到渠成的事情了。下面是音频存储示波器的主程序代码,为便于阅读,我删掉了自适应屏幕、清除数据、屏幕截图、保存数据为文件和打开保存的数据文件等几项非核心功能的代码。如果需要完整的代码,请移步至GitHub。如果觉得有帮助,请别忘了帮我点亮一颗小星星。

vaso.py

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

import os
import wx
import queue
import threading
from PIL import ImageGrab

from sample import AudioSampler
from screen import *
from knob import *
from onoff import *
from startstop import *

class MainFrame(wx.Frame):
    """主窗口类"""
    
    def __init__(self, parent):
        """构造函数"""
        
        wx.Frame.__init__(self, parent, -1,style=wx.DEFAULT_FRAME_STYLE)
        
        if wx.DisplaySize()[0] > 1920:
            self.SetSize((1920, 1080))
            self.Center()
        else:
            self.Maximize()
        
        self.SetTitle('音频存储示波器')
        self.SetIcon(wx.Icon('res/wave.ico'))
        self.SetBackgroundColour(wx.Colour(240, 240, 240))
        
        self.works = os.path.join(os.path.dirname(__file__), 'data')
        if not os.path.exists(self.works):
            os.mkdir(self.works)
        
        # 实例化采样器
        self.sample_thread = None
        self.dq = queue.Queue()
        self.sampler = AudioSampler(self.dq)
        
        # 实例化示波器屏幕
        self.screen = Screen(self)
        
        # 创建滑块
        self.slider = wx.Slider(self, -1, 0, 0, 1000, size=wx.DefaultSize, style=wx.SL_HORIZONTAL)
        self.slider.Bind(wx.EVT_SCROLL, self.on_slider)
        
        # 创建宽度调整旋钮
        self.lab_hknob = wx.StaticText(self, -1, '窗口宽度调整', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        self.hknob = Knob(self, value=50)
        self.hknob.Bind(EVT_KNOB_ANGLE_CHANGED, self.on_time_width)
        
        # 创建幅度调整旋钮
        self.lab_vknob = wx.StaticText(self, -1, '波形幅度调整', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        self.vknob = Knob(self, value=50)
        self.vknob.Bind(EVT_KNOB_ANGLE_CHANGED, self.on_amplitude)
        
        # 创建模式开关
        lab_mode = wx.StaticText(self, -1, '实时模式      触发模式', style=wx.ALIGN_CENTER|wx.ST_NO_AUTORESIZE)
        self.sw_mode = Switch(self)
        self.sw_mode.Bind(EVT_SWITCH_CHANGED, self.on_switch_mode)
        
        # 触发电平和触发数量
        self.level_rb = wx.RadioBox(self, -1, label='触发电平', choices=['0.05%', '0.1%', '0.2%', '0.5%'], majorDimension=2, style=wx.RA_SPECIFY_COLS, name='level')
        self.over_rb = wx.RadioBox(self, -1, label='触发数量', choices=['1', '2', '5', '10', '20', '50'], majorDimension=3, style=wx.RA_SPECIFY_COLS, name='over')
        self.level_rb.SetSelection(0)
        self.over_rb.SetSelection(0)
        self.level_rb.Enable(False)
        self.over_rb.Enable(False)
        self.Bind(wx.EVT_RADIOBOX, self.on_radio_box)
        
        # 生成启停按钮
        self.btn_star_stop = StartStop(self)
        self.btn_star_stop.Bind(EVT_SS_CHANGED, self.on_star_stop)
        
        # 生成清除|保存|截屏文本按钮
        t_clear = wx.StaticText(self, -1, '清除', name='clear')
        t_s1 = wx.StaticText(self, -1, ' | ')
        t_capture = wx.StaticText(self, -1, '截屏', name='capture')
        t_s2 = wx.StaticText(self, -1, ' | ')
        t_save = wx.StaticText(self, -1, '保存', name='save')
        t_s3 = wx.StaticText(self, -1, ' | ')
        t_open = wx.StaticText(self, -1, '打开', name='open')
        
        t_clear.Bind(wx.EVT_MOUSE_EVENTS, self.on_text_button)
        t_save.Bind(wx.EVT_MOUSE_EVENTS, self.on_text_button)
        t_capture.Bind(wx.EVT_MOUSE_EVENTS, self.on_text_button)
        t_open.Bind(wx.EVT_MOUSE_EVENTS, self.on_text_button)
        
        # 创建布局管理控件
        sizer_max = wx.BoxSizer()                       # 最顶层的布局控件,水平布局
        sizer_left = wx.BoxSizer(wx.VERTICAL)           # 左侧区域布局控件,垂直布局
        sizer_right = wx.BoxSizer(wx.VERTICAL)          # 右侧区域布局控件,垂直布局
        sizer_text = wx.BoxSizer()                      # 右侧底部文本控件,水平布局
        
        # 部件组装
        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_text.Add(t_clear, 0, wx.ALL, 0)
        sizer_text.Add(t_s1, 0, wx.ALL, 0)
        sizer_text.Add(t_capture, 0, wx.ALL, 0)
        sizer_text.Add(t_s2, 0, wx.ALL, 0)
        sizer_text.Add(t_save, 0, wx.ALL, 0)
        sizer_text.Add(t_s3, 0, wx.ALL, 0)
        sizer_text.Add(t_open, 0, wx.ALL, 0)
        
        sizer_right.Add(self.hknob, 0, wx.TOP, 0)
        sizer_right.Add(self.lab_hknob, 0, wx.EXPAND|wx.TOP, 5)
        sizer_right.Add(self.vknob, 0, wx.TOP, 20)
        sizer_right.Add(self.lab_vknob, 0, wx.EXPAND|wx.TOP, 5)
        sizer_right.Add(self.sw_mode, 0, wx.TOP, 30)
        sizer_right.Add(lab_mode, 0, wx.EXPAND|wx.TOP, 5)
        sizer_right.AddSpacer(15)
        sizer_right.Add(self.level_rb, 0, wx.EXPAND|wx.ALL, 10)
        sizer_right.Add(self.over_rb, 0, wx.EXPAND|wx.ALL, 10)
        sizer_right.Add(wx.Panel(self), 1, wx.ALL, 0)
        sizer_right.Add(self.btn_star_stop, 0, wx.TOP, 10)
        sizer_right.Add(sizer_text, 0, wx.ALIGN_CENTER_HORIZONTAL|wx.TOP, 10)
        
        sizer_max.Add(sizer_left, 1, wx.EXPAND|wx.ALL, 0)
        sizer_max.Add(sizer_right, 0, wx.EXPAND|wx.ALL, 20)
        
        self.SetSizer(sizer_max)
        self.SetAutoLayout(True)
        
        # 启动线程:以阻塞方式从队列中读出数据
        read_thread = threading.Thread(target=self.read_data)
        read_thread.setDaemon(True)
        read_thread.start()
        
        self.Bind(wx.EVT_SIZE, self.on_size)            # 绑定窗口尺寸改变事件
        self.Bind(wx.EVT_CLOSE, self.on_close)          # 绑定窗口关闭事件
    
    def read_data(self):
        """读数据队列的线程函数"""
        
        while True:
            self.screen.append_data(self.dq.get())
        
    def on_close(self, evt):
        """关闭窗口"""
        
        if self.sample_thread and self.sample_thread.isAlive():
            self.sampler.stop()
        
        if self.sample_thread:
            self.sample_thread.join()
        
        self.Destroy()
            
    def on_size(self, evt):
        """响应窗口大小变化"""
        
        pass
    
    def on_star_stop(self, evt):
        """启动停止"""
        
        if self.sampler.running:
            self.sampler.stop()
            self.slider.Enable(True)
        else:
            self.slider.SetValue(1000)
            self.slider.Enable(False)
            
            self.sample_thread = threading.Thread(target=self.sampler.start)
            self.sample_thread.setDaemon(True)
            self.sample_thread.start()
    
    def on_slider(self, evt):
        """拖动滑块"""
        
        self.screen.set_pos(self.slider.GetValue())
    
    def on_amplitude(self, evt):
        """改变幅度缩放比例"""
        
        self.screen.set_amplitude(evt.GetValue())
    
    def on_time_width(self, evt):
        """改变时间窗口宽度"""
        
        self.screen.set_time_width(evt.GetValue())
    
    def on_switch_mode(self, evt):
        """改变模式"""
        
        mode = not bool(evt.GetValue())
        self.sampler.set_args(mode=mode)
        
        if mode:
            self.level_rb.Enable(False)
            self.over_rb.Enable(False)
        else:
            self.level_rb.Enable(True)
            self.over_rb.Enable(True)
        
    def on_radio_box(self, evt):
        """改变触发电平和触发数量"""
        
        objName = evt.GetEventObject().GetName()
        if objName == 'level':
            self.level = self.sampler.set_args(level=[16,32,64,160][evt.GetInt()])
        else:
            self.over = self.sampler.set_args(over=[1,2,5,10,20,50][evt.GetInt()])
    
    def on_text_button(self, evt):
        """响应清除、截屏、保存和打开操作"""
        
        pass

if __name__ == '__main__':
    app = wx.App()
    frame = MainFrame(None)
    frame.Show()
    app.MainLoop()

启动存储示波器程序,确保你的麦克可用,点击“启动”按钮,顺利的话就会看到屏幕上有蓝色的波形在上下跳动。滚动鼠标滚轮或点击幅度旋钮,可以改变波形的显示幅度。点击“停止”按钮,声音的采集就会终止。切换到触发模式,再次点击“启动”按钮,屏幕应该没有任何反应,拍掌或者吹口哨,此时屏幕上应该显示出一段剧烈变化的波形。

4 存储示波器在电磁学实验中的应用

4.1 制作示波器探针

现在,这个示波器软件还只能从声卡上采集音频信号。要想测量电磁学实验中的感应电流,输入信号应当从声卡的MIC输入端口接入。不过,大多数情况下声卡的MIC输入和耳机输出共用一个端口,而声卡能够自动检测耳机和MIC,因此想从MIC输入电磁信号并不容易。我曾经尝试将一个耳麦的连线剪短,将连接MIC的两端引出,用来接入电磁信号,结果却无法被计算机识别为MIC,实验以失败而告终。后来我猜想,也许在引出的两端之间并接一个1K欧姆电阻,再加一个隔直电容,也许就可以骗过计算机了。具体原理请参考下图。

在这里插入图片描述

声卡接口说明

我最终的解决方案是花费35块钱在网上购买了一只MIC端口和耳机端口分离的外置USB声卡,又买了一只三芯的3.5mm音频插头,切割磁场的导线两端连接插头靠近根部的两个端点即可。这只简陋的示波器探针总费用大约50块钱,其中包括12元运费。具体接线如下图所示。

在这里插入图片描述

示波器探针接线示意图

在这里插入图片描述

我买的外置USB声卡长这个模样

4.2 导线切割磁力线实验

对了,刚才购买USB声卡的时候忘记提醒了:如果手头没有实验用的条形磁铁、U型磁铁等材料的话,请一并购买吧。

在这里插入图片描述

这是我网购的条形磁铁

启动音频存储示波器软件,切换到触发模式,点击“启动按钮”,用上图所示的条形磁铁和示波器探针上的铜导线做相对运动,我得到了这样的波形。
在这里插入图片描述

导体切割磁力线产生的几组波形

换用强度不同的磁铁,多做几次实验,仔细揣摩一下,不难发现感应电流的大小和方向与切割运动的方向和速度之间的关系。

  1. 感应电流方向使用右手定则:伸开右手,让磁力线垂直穿过掌心,使大拇指指向导体切割磁力线的运动方向,其余四指指向就表示感应电动势方向。据此,可以给自制的示波器探针标定正负极。
  2. 磁场强弱不变时,导体在磁场中切割磁感线运动的速度越大,感应电流越大。
  3. 导体在磁场中切割磁感线运动的速度相同,磁场越强,感应电流越大。

猜你喜欢

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