最近因为在学习pyqt5,结合模式识别课程的语音分类作业的需要,就想着用qt自己写一个能实现录音时实时显示音频波形图的UI,网上这方面的资料都很零散,所以一番摸索之后勉强成功了,就寻思着把它放到博客里来,以便和大家一起交流学习

回到正题:
大致思路:

  1. 先实现一个FigureCanvas(实际上该控件也继承自Widget)控件命名为MyMplCanvas用于显示matplotlib的绘图区

  2. 再用一个Widget控件命名为MatplotlibWidget将之前实现的FigureCanvas封装起来(其中动态波形图绘制的逻辑以函数的形式封装在该Widget中)

  3. 用Qt Designer设计主窗口myMainWindow(QMainWindow)的布局,并实现widget的提升操作,生成.ui文件

  4. 使用eric6将ui文件编译为Ui_matplotlib_pyqt.py文件,再生成对话框代码继承自Ui.py中的主窗口类(界面与逻辑分离)

  5. 在最后的逻辑窗口中调用按钮触发录音开始和结束

实现的细节和源码:

先实现一个FigureCanvas(实际上该控件也继承自Widget)控件命名为MyMplCanvas用于显示matplotlib的绘图区,并使用一个Widget控件命名为MatplotlibWidget将其封装起来,保存为一个MatplotlibWidget.py文件


MatplotlibWidget.py
import sys
import matplotlib
matplotlib.use("Qt5Agg")
from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QSizePolicy, QWidget
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
import matplotlib.pyplot as plt

# 这是一些与音频信号有关的常量
CHUNK = 1024
WIDTH = 2
CHANNELS = 2
RATE = 44100

class MyMplCanvas(FigureCanvas):
    """FigureCanvas的最终的父类其实是QWidget。"""
    
    def __init__(self, parent=None, width=5, height=4, dpi=100):

        # 配置中文显示
        plt.rcParams['font.family'] = ['SimHei']  # 用来正常显示中文标签
        plt.rcParams['axes.unicode_minus'] = False  # 用来正常显示负号
        
        # 此处初始化子图一定要在初始化函数之前
        self.fig = plt.figure()
        # 这里坐标范围的设置是为了满足波形信号的波动范围
        self.rt_ax = plt.subplot(111,xlim=(0,CHUNK*2), ylim=(-20000,20000))
        
        # 关闭坐标显示
        plt.axis('off')
        
        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)
        

        '''定义FigureCanvas的尺寸策略,这部分的意思是设置FigureCanvas,使之尽可能的向外填充空间。'''
        FigureCanvas.setSizePolicy(self,
                                   QSizePolicy.Expanding,
                                   QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)
        
    
class MatplotlibWidget(QWidget):
    def __init__(self, parent=None):
        super(MatplotlibWidget, self).__init__(parent)
        self.initUi()

    def initUi(self):
        self.layout = QVBoxLayout(self)
        self.mpl = MyMplCanvas(self, width=15, height=15, dpi=100)
        self.layout.addWidget(self.mpl)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ui = MatplotlibWidget()
    ui.show()
    sys.exit(app.exec_())

现在我们得到了一个封装好了可供matplotlib绘图区域的widget类,下面需要做的是将它放进MainWindow里去,但在这之前需要使用Qt Designer先设计出主窗口的布局


这里写图片描述

设计好之后需要将界面中选中的那个widget提升为之前封装的MatplotlibWidget,具体操作步骤是:
 选中界面的widget点击鼠标右键>promoted to(提升为),然后像下图这样设置
这里写图片描述

之后依次点击Add>Promote即可。之后保存该界面为ui文件放到与MatplotlibWidget.py相同的文件夹下,之后就能用eric6创建一个项目,把该ui文件和MatplotlibWidget.py文件都导入项目中去

这里写图片描述

现在,就可以开始编译ui文件生成窗体代码了,在eric6中右键点击项目中的ui文件>编译窗体,就能看到项目代码文件栏中生成了Ui_matplotlib_pyqt.py文件了,里面的内容如下图:
(对eric6+Qt Designer不太熟悉的可以先去参考其他关于这方面的教程)


Ui_matplotlib_pyqt.py
from PyQt5 import QtCore, QtGui, QtWidgets

class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(579, 369)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.widget = MatplotlibWidget(self.centralwidget)
        self.widget.setGeometry(QtCore.QRect(10, 40, 421, 271))
        self.widget.setObjectName("widget")
        self.startButton = QtWidgets.QPushButton(self.centralwidget)
        self.startButton.setGeometry(QtCore.QRect(460, 70, 93, 81))
        self.startButton.setText("")
        icon = QtGui.QIcon()
        icon.addPixmap(QtGui.QPixmap("play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
        self.startButton.setIcon(icon)
        self.startButton.setIconSize(QtCore.QSize(80, 80))
        self.startButton.setObjectName("startButton")
        self.endButton = QtWidgets.QPushButton(self.centralwidget)
        self.endButton.setGeometry(QtCore.QRect(460, 200, 93, 81))
        self.endButton.setText("")
        icon1 = QtGui.QIcon()
        icon1.addPixmap(QtGui.QPixmap("stop.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
        self.endButton.setIcon(icon1)
        self.endButton.setIconSize(QtCore.QSize(80, 80))
        self.endButton.setObjectName("endButton")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 579, 26))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Audio"))

from MatplotlibWidget import MatplotlibWidget

if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())

现在,便可以使用eric6生成主窗体逻辑实现代码了,右键点击项目中的ui文件>生成对话框代码,点选两个button的clicked事件
这里写图片描述
之后就能在代码栏看到新生成的py文件了(与之前生成的Ui_matplotlib_pyqt.py文件不同,Ui_matplotlib_pyqt.py中的代码功能是生成界面布局,而刚才生成的matplotlib_pyqt.py文件继承了Ui_matplotlib_pyqt.py文件中的窗体,将一些逻辑放在该文件中,如果后续需要改动界面布局的话,只需重新生成Ui文件,而对逻辑文件的改动较小,从而实现了界面与逻辑分离)
这里eric6有一些小bug还需要将生成的代码做如下修改


matplotlib_pyqt.py
import sys
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QMainWindow, QApplication

from Ui_matplotlib_pyqt import Ui_MainWindow

class myMainWindow(QMainWindow, Ui_MainWindow):
    """
    Class documentation goes here.
    """
    def __init__(self, parent=None):
        """
        Constructor
        
        @param parent reference to the parent widget
        @type QWidget
        """
        super(myMainWindow, self).__init__(parent)
        self.setupUi(self)
    
    @pyqtSlot()
    def on_startButton_clicked(self):
        """
        Slot documentation goes here.
        """
        pass
        
    @pyqtSlot()
    def on_endButton_clicked(self):
        """
        Slot documentation goes here.
        """
        pass


if __name__ == "__main__":
    app = QApplication(sys.argv)
    ui = myMainWindow()
    ui.show()
    sys.exit(app.exec_())


这样,整个界面框架就搭建完毕了,现在只需在MatplotlibWidget.py中的MatplotlibWidget类中添加绘图逻辑即可

这里不得不先说明两个库的作用,一个是实现获取麦克风音频的[pyaudio][2]库,另一个是能够实现matplotlib图案动态显示的[animation][3]方法
[2]:http://people.csail.mit.edu/hubert/pyaudio/
[3]:https://matplotlib.org/api/animation_api.html

首先,参考animation的官方例子:


Matplotlib Animation Example
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import animation

fig = plt.figure()
fig.set_dpi(100)
fig.set_size_inches(7, 6.5)

ax = plt.axes(xlim=(0, 10), ylim=(0, 10))
patch = plt.Circle((5, -5), 0.75, fc='y')

# 用于初始化数据的函数
def init():
    patch.center = (5, 5)
    ax.add_patch(patch)
    return patch,

# 用于更新绘图数据的函数
def animate(i):
    x, y = patch.center
    x = 5 + 3 * np.sin(np.radians(i))
    y = 5 + 3 * np.cos(np.radians(i))
    patch.center = (x, y)
    return patch,


anim = animation.FuncAnimation(fig, animate,  # 传入之前定义的两个函数
                               init_func=init, 
                               frames=360, # 传入更新绘图数据的参数,此处代表animate函数的
                                           # 参数i从1变化到360
                               interval=20,  # 刷新速率
                               blit=True)  # 重叠区域不重绘(可提升效率)


plt.show()

该示例实现了一个圆围绕着点(5,5)做半径为3的匀速圆周运动 (我尝试了在spyder或jupyter notebook中无法展示动态信息,但在控制台的ipython或者pycharm中运行便可) 。效果如下所示:

这里写图片描述


另一方面,参考pyaudio官方无阻塞回调实现的源码:


PyAudio Wire Callback Example
"""
PyAudio Example: Make a wire between input and output (i.e., record a
few samples and play them back immediately).

This is the callback (non-blocking) version.
"""

import pyaudio
import time

WIDTH = 2
CHANNELS = 2
RATE = 44100

# 设置p为获取麦克风音频功能的对象
p = pyaudio.PyAudio() 

# 定义回调函数
def callback(in_data, frame_count, time_info, status):
    return (in_data, pyaudio.paContinue)
    
# 设置获取音频的参数
stream = p.open(format=p.get_format_from_width(WIDTH),
                channels=CHANNELS,
                rate=RATE,
                input=True,
                output=True,
                stream_callback=callback)

# 开始获取音频
stream.start_stream()

# 若获取还未结束,则停顿0.1s(一般这里是放在另一个线程中的)
while stream.is_active():
    time.sleep(0.1)

# 停止获取音频
stream.stop_stream()
stream.close()

p.terminate()

回调函数的作用就是每从麦克风获取到CHUNK帧(之前定义的常数)的音频就执行一次,从而能实现实时返回录取到音频的功能
而我们在本例中的方法就是利用回调函数每隔一定帧数便往一个队列中加入CHUNK帧的音频数据,然后在另一个线程中每隔一段时间(本例设为0.1s)从队列中取出一段数据来更新绘图数据,之后再用matplotlib的animation方法来不断将数据绘制在界面上

介绍完两个主要的功能,这里贴出完整的MatplotlibWidget.py文件内容


MatplotlibWidget.py (完整版)
import sys
import matplotlib
matplotlib.use("Qt5Agg")
from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QSizePolicy, QWidget
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib.pyplot as plt
import numpy as np  
import pyaudio
import threading
import queue
import matplotlib.lines as line
import matplotlib.animation as animation
from scipy import signal

CHUNK = 1024
WIDTH = 2
CHANNELS = 2
RATE = 44100


class MyMplCanvas(FigureCanvas):
    """FigureCanvas的最终的父类其实是QWidget。"""
    
    def __init__(self, parent=None, width=5, height=4, dpi=100):

        # 配置中文显示
        plt.rcParams['font.family'] = ['SimHei']  # 用来正常显示中文标签
        plt.rcParams['axes.unicode_minus'] = False  # 用来正常显示负号
        
        # 此处初始化子图一定要在初始化函数之前
        self.fig = plt.figure()
        self.rt_ax = plt.subplot(111,xlim=(0,CHUNK*2), ylim=(-20000,20000))
        plt.axis('off')
        
        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)
        

        '''定义FigureCanvas的尺寸策略,这部分的意思是设置FigureCanvas,使之尽可能的向外填充空间。'''
        FigureCanvas.setSizePolicy(self,
                                   QSizePolicy.Expanding,
                                   QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)
        
    
class MatplotlibWidget(QWidget):
    def __init__(self, parent=None):
        super(MatplotlibWidget, self).__init__(parent)
        self.initUi()
        self.initariateV()
        
    # 初始化成员变量
    def initariateV(self):
        self.p = None
        self.q = queue.Queue()
        self.t = None
        self.ad_rdy_ev = None
        self.stream = None
        self.window = None
        self.ani = None
        self.rt_line = line.Line2D([],[])  # 直线对象
        self.rt_x_data=np.arange(0,CHUNK*2,1) 
        self.rt_data=np.full((CHUNK*2, ), 0) 
        self.rt_line.set_xdata(self.rt_x_data)# 初始化横坐标
        self.rt_line.set_ydata(self.rt_data) # 初始化纵坐标
    
    def initUi(self):
        self.layout = QVBoxLayout(self)
        self.mpl = MyMplCanvas(self, width=15, height=15, dpi=100)
        self.layout.addWidget(self.mpl)
    
    # 开始录制触发函数
    def startAudio(self, *args, **kwargs):
        self.mpl.fig.suptitle('波形曲线')
        self.ani = animation.FuncAnimation(self.mpl.fig, self.plot_update,
                              init_func=self.plot_init, 
                              frames=1,
                              interval=30,
                              blit=True)
        # 其实animation方法的实质是开启了一个线程更新图像
        
        #麦克风开始获取音频
        self.p = pyaudio.PyAudio()
        self.stream = self.p.open(format=pyaudio.paInt16,
                channels=CHANNELS,
                rate=RATE,
                input=True,
                output=False,
                frames_per_buffer=CHUNK,
                stream_callback=self.callback)
        self.stream.start_stream()
        
        # 正态分布数组,与音频数据做相关运算可保证波形图两端固定
        self.window = signal.hamming(CHUNK*2)
        
        # 初始化线程
        self.ad_rdy_ev=threading.Event()  # 线程事件变量
        self.t = threading.Thread(target=self.read_audio_thead,  
                  args=(self.q, self.stream, self.ad_rdy_ev))  # 在线程t中添加函数read_audio_thead
        self.t.start() # 线程开始运行
        self.mpl.draw()
    
	# animation的更新函数
    def plot_update(self, i):
        self.rt_line.set_xdata(self.rt_x_data)
        self.rt_line.set_ydata(self.rt_data)
        return self.rt_line,
    
    # animation的初始化函数
    def plot_init(self):
        self.mpl.rt_ax.add_line(self.rt_line)
        return self.rt_line,
    
    #pyaudio的回调函数
    def callback(self, in_data, frame_count, time_info, status):
        global ad_rdy_ev
        self.q.put(in_data)
        return (None, pyaudio.paContinue)
    
    
    def read_audio_thead(self, q, stream, ad_rdy_ev):
    # 获取队列中的数据
        while stream.is_active():
            self.ad_rdy_ev.wait(timeout=0.1) # 线程事件,等待0.1s
            if not q.empty():
                data=q.get()
                while not q.empty(): # 将多余的数据扔掉,不然队列会越来越长
                    q.get()
                self.rt_data = np.frombuffer(data,np.dtype('<i2'))
                self.rt_data = self.rt_data * self.window   # 这样做的目的是将曲线的两端固定,以免出现曲线整体发生波动
            self.ad_rdy_ev.clear()
    
    def endAudio(self):
        # 停止获取音频信息
        self.stream.stop_stream()
        self.stream.close()
        self.p.terminate()
        # 清空队列
        while not self.q.empty(): 
            self.q.get()
        # 重新初始化变量
        self.initariateV()
        
if __name__ == '__main__':
    app = QApplication(sys.argv)
    ui = MatplotlibWidget()
    ui.startAudio()
    ui.show()
    sys.exit(app.exec_())

上面已经完成了主要的功能,现在只需在matplotlib_pyqt.py逻辑文件的按钮点击事件中调用MatplotlibWidget的函数即可:


matplotlib_pyqt.py(完整版)
"""
Module implementing myMainWindow.
"""
import sys
from PyQt5.QtCore import pyqtSlot , QThread
from PyQt5.QtWidgets import QMainWindow, QApplication, QMessageBox

from Ui_matplotlib_pyqt import Ui_MainWindow
import Recorder

class myMainWindow(QMainWindow, Ui_MainWindow):
    """
    Class documentation goes here.
    """
    def __init__(self, parent=None):
        """
        Constructor
        
        @param parent reference to the parent widget
        @type QWidget
        """
        super(myMainWindow, self).__init__(parent)
        self.setupUi(self)
        self.widget.setVisible(False)  # 绘图区域初始化为不可见
    
    @pyqtSlot()
    def on_startButton_clicked(self):
        """
        Slot documentation goes here.
        """
        self.widget.setVisible(True)
        self.widget.startAudio()   # 触发MatplotlibWidget的startAudio函数
        
    @pyqtSlot()
    def on_endButton_clicked(self):
        """
        Slot documentation goes here.
        """
        self.widget.setVisible(False)
        self.widget.endAudio()      # 触发MatplotlibWidget的endAudio函数

if __name__ == "__main__":
    app = QApplication(sys.argv)
    ui = myMainWindow()
    ui.show()
    sys.exit(app.exec_())

现在整个程序就已经大功告成了,以下是博主对着麦克风吹气的程序效果:
这里写图片描述

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐