说明
一,背景
本项目起于研一上学期专业课的学期任务——用Python编写一个简易的音频播放程序,实现基本的音频播放、波形显示等功能。制作过程中,这一程序的功能目标逐渐转向了对于音频的分析及其可视化,播放只是附带。目前的阶段功能比较基础,持续开发中。
GUI库的选择 - 初步尝试了Tkinter、PyGame后,最终定在了第三个选择上:PyQt。三种工具库都是目前Python环境下的主流GUI库。Tkinter由于已纳入Python标准库,有其方便、简易等优点,但它的功能非常局限,只能用于最基本的UI设计而缺乏数据处理方面的函数。PyGame作为python环境下游戏开发最主要的库,功能全面、可实现音视频播放等功能,但由于时间紧迫,且有关的学习资源都侧重游戏开发,因此暂且放弃。PyQt是Qt框架的Python绑定,Qt最初是作为C++的跨平台应用程序开发框架而设计的。作为一个相对历史比较悠久的C++库,它的功能相当丰富,但也十分复杂、庞大。宏观的模块就有约五十多种,最常用的模块中,每种都包含了上百个不同的类,每个类中又含有几十至上百个函数。这一工具库的庞杂使得学习的门槛较高,其内部逻辑也并不完美,很多类名和函数名非常近似、逻辑关系不明晰,难以理解。在长久的迭代过程中,一代代的版本更新和函数新增使得这个库变得越来越繁琐。尽管如此,在Python环境下,对于一般功能的程序开发(除了游戏),个人认为PyQt是最佳选择。Qt官方提供了便捷实用的Qt Creator,这一程序套件中包含的Qt Designer是一个可视化UI设计程序,方便开发人员高效完成GUI层面的代码。
目前Qt已发展到第六代,本项目使用的是PyQt的第五代成熟版本。唯一的缺点是,PyQt非原生Python库,只是Qt的一种“延伸”,Qt官方工具基于C++进行转译,实际工作中需要面对一些C++文件,这对于不熟悉C的用户比较不友好。而且,Qt Creator似乎很多年没有更新它的Qt Creator软件,在当下的软硬件环境中有部分隐性的兼容问题,Qt自带的UI图标库也已经是近二十年前的审美风格(当然对于专业开发人员,美术设计都是自定义的)。
二,目标需求
除了基本的音频播放,这个程序的开发更多是为了与“面向音乐的音频信号处理”的学习的自然衔接。
软件的基本需求可以整理为如下几项:
调取程序对话框、文件选择
播放音频,可调整音量、调整进度条等
音频文件的基本信息显示(文件名、时长、采样率)
音频文件的波形
音频文件的频谱图
基本波形图的其他信息(面向包络分析的起音阶段、音频的RMS计算等)
三,程序设计
简单的IPO图:
基本功能与处理流程
数据的起始点为用户已操作“文件选择”后,在这之前程序执行对系统对话框的调取,用于读取磁盘存储中的文件。待用户选择格式正确的音频文件后,程序将执行左图所示的一系列数据的计算和处理。
获取文件路径 - 获取音频全部采样信息 - 通过计算,获得时长等信息并输出到GUI - 调取系统播放器 - 计算&绘制波形图 - FFT&绘制频谱
其他变化型波形图:在基本波形图的基础上增加其他计算(RMS、Peak等)或截取所需的片段(时域截取)
四,程序实现
基于OOP编程思路,主要用到如下库:
GUI - PyQt5;
数据处理、科学计算库 - Numpy、Scipy
绘图 - Matplotlib(基本的pyplot和专用于PyQt GUI的Qt5后端模块 FigureCanvasQtAgg)
系统音频处理 - 标准库 subprocess
系统响应 - 标准库 sys、os
代码
主窗口对应三个.py文件,后来又增加了帮助窗口,因此多了两个.py文件(一个窗口模块,一个UI,无其他函数需求,只有UI)。帮助窗口很简单,基本逻辑和主窗口一致,就不复制在这里了。
三个关键的python文件分别对应主程序、主窗口、ui,是互相调用的关系,最底层的是ui:
# -*- coding: utf-8 -*-
from PyQt5 import QtCore, QtWidgets #,QtGui
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas #专门用于qt的canvas模块
class Ui_MainWindow(object):
def setupUi(self, MainWindow): # MainWindow作为入参,the greatest parent
MainWindow.setObjectName("MainWindow")
window_width = 720
window_height = 550
MainWindow.resize(window_width, window_height)
desktop = QtWidgets.QDesktopWidget() #建立桌面实例
my_scr = desktop.screenGeometry() #当下显示器屏幕尺寸
MainWindow.move(my_scr.left(),my_scr.top())
self.menuBar = QtWidgets.QMenuBar(MainWindow)
self.menuBar.setGeometry(QtCore.QRect(0, 0, 743, 23))
self.menu_File = QtWidgets.QMenu(self.menuBar) #一级目录
self.menu_Contrl = QtWidgets.QMenu(self.menuBar)
self.menu_Help = QtWidgets.QMenu(self.menuBar)
MainWindow.setMenuBar(self.menuBar)
self.menuBar.addAction(self.menu_File.menuAction()) #menu对象(menuaction)加入到menubar中
self.menuBar.addAction(self.menu_Contrl.menuAction())
self.menuBar.addAction(self.menu_Help.menuAction())
self.actFile_Open = QtWidgets.QAction(MainWindow) #建立QAction对象 二级目录
self.actWin_Close = QtWidgets.QAction(MainWindow)
self.actMedia_play = QtWidgets.QAction(MainWindow)
# self.actMedia_stop = QtWidgets.QAction(MainWindow)
self.actUserGuide = QtWidgets.QAction(MainWindow)
self.menu_File.addAction(self.actFile_Open) #把二级目录加入到一级目录中
self.menu_File.addAction(self.actWin_Close)
self.menu_Contrl.addAction(self.actMedia_play)
# self.menu_Contrl.addAction(self.actMedia_stop)
self.menu_Help.addAction(self.actUserGuide)
self.figure1 = plt.figure(figsize=(9,4)) #建立图表对象
self.figure1.tight_layout()
self.canvas1 = FigureCanvas(self.figure1) #建立qt框架内的FigureCanvas对象
self.canvas1.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding,)
self.centralWidget = QtWidgets.QWidget(MainWindow)
self.centralWidget.setObjectName("centralWidget")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.centralWidget)
self.verticalLayout_2.setContentsMargins(1, 1, 1, 1)
self.verticalLayout_2.setSpacing(1)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.groupBox = QtWidgets.QGroupBox(self.centralWidget)
self.groupBox.setObjectName("groupBox")
self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox)
self.verticalLayout.setContentsMargins(2, 2, 2, 2)
self.verticalLayout.setSpacing(2)
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setSpacing(6)
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
#plot buttons
self.btnWavePlot = QtWidgets.QPushButton(self.groupBox)
self.btnWavePlot.setObjectName("btnWavePlot")
self.horizontalLayout_3.addWidget(self.btnWavePlot)
self.btnADSRPlot = QtWidgets.QPushButton(self.groupBox)
self.btnADSRPlot.setObjectName("btnADSRPlot")
self.horizontalLayout_3.addWidget(self.btnADSRPlot)
self.btnSpecPlot = QtWidgets.QPushButton(self.groupBox)
self.btnSpecPlot.setObjectName("btnSpecPlot")
self.horizontalLayout_3.addWidget(self.btnSpecPlot)
self.btnPeakRMS = QtWidgets.QPushButton(self.groupBox)
self.btnPeakRMS.setObjectName("btnPeakRMS")
self.horizontalLayout_3.addWidget(self.btnPeakRMS)
self.verticalLayout.addLayout(self.horizontalLayout_3)
self.mediaInfoLab = QtWidgets.QLabel(self.groupBox)
#按窗口宽度计算label宽度:
self.mediaInfoLab.setMinimumSize(QtCore.QSize(int(window_width*0.9),60))
self.verticalLayout.addWidget(self.mediaInfoLab)
self.verticalLayout.addWidget(self.canvas1) #canvas加入到外层layout中
# self.verticalLayout.addWidget(self.canvas2)
self.verticalLayout_2.addWidget(self.groupBox)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setSpacing(6)
self.horizontalLayout.setObjectName("horizontalLayout")
self.btnOpen = QtWidgets.QPushButton(self.centralWidget) #open button
self.btnOpen.setFixedSize(60,30)
self.btnOpen.setText("open")
self.btnOpen.setObjectName("btnOpen")
self.horizontalLayout.addWidget(self.btnOpen)
self.btnPlay = QtWidgets.QPushButton(self.centralWidget) #play button
self.btnPlay.setFixedSize(60,30)
self.btnPlay.setText("play")
self.btnPlay.setObjectName("btnPlay")
self.horizontalLayout.addWidget(self.btnPlay)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.line = QtWidgets.QFrame(self.centralWidget)
self.line.setFrameShadow(QtWidgets.QFrame.Plain)
self.line.setFrameShape(QtWidgets.QFrame.HLine)
self.line.setObjectName("line")
self.verticalLayout_2.addWidget(self.line)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setSpacing(9)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.verticalLayout_2.addLayout(self.horizontalLayout_2)
MainWindow.setCentralWidget(self.centralWidget)
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Basic Audio Displayer"))
self.groupBox.setTitle(_translate("MainWindow", "An audio displayer written by Zhang Hong"))
self.btnWavePlot.setText(_translate("MainWindow", "waveform"))
self.btnSpecPlot.setText(_translate("MainWindow", "spectrums"))
self.btnPeakRMS.setText(_translate("MainWindow", "Peak and RMS"))
self.btnADSRPlot.setText(_translate("MainWindow", "portion plot"))
self.menu_File.setTitle(_translate("MainWindow", "File"))
self.menu_Contrl.setTitle(_translate("MainWindow", "Control"))
self.menu_Help.setTitle(_translate("MainWindow", "Help"))
self.actFile_Open.setText(_translate("MainWindow", "Open"))
self.actWin_Close.setText(_translate("MainWindow", "Close"))
self.actMedia_play.setText(_translate("MainWindow", "Play"))
# self.actMedia_stop.setText(_translate("MainWindow", "Stop"))
self.actUserGuide.setText(_translate("MainWindow", "User Guide"))
一般来讲,这部分代码不会有人手工去打的,都是用QT Creator进行可视化设计,然后自动生成。由于我电脑里的Creator始终配置失败,所以我勉强复制到一部分QT教材里提供的模版,然后在其基础上手工修改,命名规则遵循自动生成的那些规律。
最关键、最重要的,也是最需要自己编程的模块则是主窗口模块:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Mon Nov 27 20:36:35 2023
@author: Sylvia
"""
import sys, os
from PyQt5.QtWidgets import QApplication, QMainWindow, QFileDialog #, QListWidgetItem
from PyQt5.QtCore import QUrl, QDir, QFileInfo, Qt #QModelIndex,pyqtSlot,
from PyQt5.QtMultimedia import QMediaPlayer, QMediaPlaylist, QMediaContent
import matplotlib.pyplot as plt
import numpy as np
import scipy.io.wavfile as wv
from ui_MainWindow import Ui_MainWindow
from UserGuide import UserGuideWindow
import subprocess
class MyMainWindow(QMainWindow): #主窗体
def __init__(self, parent=None):
super().__init__(parent)
self.ui=Ui_MainWindow() #构建窗体的ui
self.ui.setupUi(self) #建立GUI界面
self.player = QMediaPlayer(self)
self.playlist = QMediaPlaylist(self)
self.__duration=""
self.__curPos=""
#自定义ui中的menu, trigger信号与槽连接
self.ui.actFile_Open.triggered.connect(self.File_Open_triggered)
self.ui.actWin_Close.triggered.connect(self.Win_Close_triggered)
self.ui.actMedia_play.triggered.connect(self.Media_play_triggered)
self.ui.actUserGuide.triggered.connect(self.UserGuide_triggered)
self.x_axis = None
self.y_axis = None
self.y_data = None
self.whole_path = None
self.media = None
self.data = None
self.sr = None
self.dur = None
self.warningInfo = "PLEASE OPEN A FILE FIRST!"
#===================== 自定义trigger槽 ===================================
def File_Open_triggered(self):
print('【检测程序】the function File_Open_triggered is running')
curPath = QDir.currentPath() #os.gtcwd()
dlgTitle = "choose an audio file"
filt = "音频文件(*.mp3 *.wav *.wma)"
filename, _ = QFileDialog.getOpenFileName(self,dlgTitle,curPath,filt)
fileInfo = QFileInfo(filename) #获取用户选择文件的信息(建立QFileInfo对象)
folder_path = fileInfo.absolutePath() #获取文件夹绝对路径
#fileInfo.baseName() #basename without the path
file_name = os.path.basename(filename)
whole_path = folder_path+"/"+file_name
QDir.setCurrent(folder_path)
self.whole_path = whole_path
song = QMediaContent(QUrl.fromLocalFile(filename)) #建立mediacontent对象并加入medialist以及用于绘制
self.playlist.addMedia(song)
self.media = song
# s_name = fileInfo.baseName() #文件名 无后缀
try:
__sr,__y = wv.read(whole_path) #读取音频数据
except:
warning_text = "WARNING: data reading support for wav file only"
self.ui.mediaInfoLab.setText(warning_text)
pass
else:
self.playlist.clear()
self.ui.mediaInfoLab.clear()
if __y.ndim != 1:
y_mean = np.mean(__y,axis=1,dtype=__y.dtype) #转为一维
bit_dep = int(str(y_mean.dtype)[-2:]) #调取bit深度用以计算量化阶数
__y = y_mean/(2**bit_dep/2) #归一化
#用sampling rate计算时长
dur_sec = __y.size // __sr #秒数取整
dur_milisec = (__y.size % __sr)/__sr #浮点数
__dur = dur_sec+dur_milisec #original duration data
self.dur = __dur
self.data = __y
self.sr = __sr
dur_milisec_show = str(dur_milisec)[2:5] #小数点后三位(文本)
dur_show = "media duration: "+str(dur_sec)+dur_milisec_show+"ms"+". "
sr_show = "sampling rate: "+str(__sr)+"."
tip_show="Displaying waveform up to 2.0 seconds and two spectrums below 6 kHz and 2 kHz respectively."
infotext="--- "+file_name+" ---\n-> "+dur_show+sr_show+"\n-> "+tip_show
self.ui.mediaInfoLab.setText(infotext)
self.plot_waveform()
#set x/y axis data for waveform plots
def set_waveplot_xy(self):
if self.dur>2.0: # 截取2000ms时长
self.x_axis = np.linspace(0,2.0,num=self.sr*2)
trunc_data = self.data[:self.sr*2] #原数据切片
self.y_data = trunc_data/np.max(np.abs(trunc_data)) #振幅归一化 用于计算
self.y_axis = np.round(self.y_data,3) #用于实际显示
else:
self.x_axis = np.linspace(0,self.dur,self.data.size) #实际时长实际样点数
self.y_data = self.data/np.max(np.abs(self.data)) #实际data 未舍入 用于后续计算
self.y_axis = np.round(self.y_data,3)
def plot_waveform(self):
self.set_waveplot_xy()
self.ui.figure1.clear()
print("【检测程序】figure1 clearing......")
fig = self.ui.figure1.add_subplot(111)
fig.clear()
fig.plot(self.x_axis,self.y_axis,color='#00AEAE',linestyle='-',linewidth=0.3)
print("【检测程序】figure1 plotting......")
fig.set_xlabel('Time(s)')
fig.set_ylabel('Amp')
fig.set_title("Audio waveform")
fig.grid(True)
self.ui.canvas1.draw()
def ADSR_plot(self):
if len(self.data) >= (self.sr*0.2): #数据长度不小于200ms
pointA = int(0.1*self.sr) #十分之一采样率作为节点
attack_data = self.y_axis[:pointA] # 前100ms
sustain_data = self.y_axis[pointA:pointA*2] # 100~200ms
self.ui.figure1.clear()
fig1 = self.ui.figure1.add_subplot(211)
fig1.clear()
fig1_x = np.linspace(0,self.dur*0.1,num=pointA)
fig1.plot(fig1_x,attack_data,color='#35ACD9',linestyle='-',linewidth=1)
fig1.set_xlabel('Time(s)')
fig1.set_ylabel('Amp')
fig1.set_title("attack phase")
fig1.grid(True)
fig2 = self.ui.figure1.add_subplot(212)
fig2.clear()
fig2_x = np.linspace(self.dur*0.1, self.dur*0.2, num=pointA)
fig2.plot(fig2_x,sustain_data,color='#35ACD9',linestyle='-',linewidth=1)
fig2.set_xlabel('Time(s)')
fig2.set_ylabel('Amp')
fig2.set_title("sustain phase")
fig2.grid(True)
self.ui.figure1.subplots_adjust(hspace=0.4)
self.ui.canvas1.draw()
elif len(self.data)0) #去掉负值部分
freq = freq[mask]
X_abs = X_abs[mask]
freq1 = freq[freq<=6000] #过滤6k赫兹以上的部分
X_abs1 = X_abs[:len(freq1)]
X_normalized1 = X_abs1/np.max(X_abs)
freq2 = freq[freq<=2000] #过滤2k赫兹以上的部分
X_abs2 = X_abs[:len(freq2)]
X_normalized2 = X_abs2/np.max(X_abs)
self.ui.figure1.clear()
fig1 = self.ui.figure1.add_subplot(211)
fig2 = self.ui.figure1.add_subplot(212)
fig1.clear()
fig2.clear()
fig1.plot(freq1,X_normalized1,color='orange',linewidth=0.9)
fig1.set_xlabel('Freq(Hz)')
fig1.set_ylabel('Magnitude')
fig1.set_title("spectrum under 6kHz")
fig1.grid(True)
fig2.plot(freq2,X_normalized2,color='orange',linewidth=0.9)
fig2.set_xlabel('Freq(Hz)')
fig2.set_ylabel('Magnitude')
fig2.set_title("spectrum under 2kHz")
fig2.grid(True)
self.ui.figure1.subplots_adjust(hspace=0.4)
self.ui.canvas1.draw()
def plot_peak_RMS(self):
self.set_waveplot_xy()
self.ui.figure1.clear()
fig = self.ui.figure1.add_subplot(111)
fig.clear()
fig.plot(self.x_axis,self.y_axis,color='pink',linewidth=0.3,alpha=0.8)
fig.set_xlabel('Time(s)')
fig.set_ylabel('Amp')
fig.set_title("peak and RMS")
fig.grid(True)
peak_value = np.max(np.abs(self.y_axis))
rms_value = np.sqrt(np.mean(self.y_data**2))
plt.axhline(y=peak_value,linestyle='--',linewidth=1.2,color='r',label='Peak')
plt.axhline(y=rms_value,linestyle='--',linewidth=1.2,color='b',label='RMS')
plt.legend()
self.ui.canvas1.draw()
def sys_mediaplay(self):
subprocess.run(["open",self.whole_path])
def UserGuide_triggered(self):
help_win = UserGuideWindow(self)
help_win.setWindowFlag(Qt.Window, True)
help_win.show()
def Media_play_triggered(self):
self.player.setMedia(self.media)
self.player.play()
def Win_Close_triggered(self):
sys.exit()
# ======= connectSlotsByName()型函数 =============
# @pyqtSlot()
def on_btnWavePlot_clicked(self):
try:
self.plot_waveform()
except TypeError:
self.ui.mediaInfoLab.setText(self.warningInfo)
pass
def on_btnADSRPlot_clicked(self):
try:
self.ADSR_plot()
except TypeError:
self.ui.mediaInfoLab.setText(self.warningInfo)
pass
def on_btnSpecPlot_clicked(self):
try:
self.plot_spectrum()
except Exception as e:
self.ui.mediaInfoLab.setText(str(e)+"\n"+self.warningInfo)
pass
def on_btnPeakRMS_clicked(self):
try:
self.plot_peak_RMS()
except Exception as e:
self.ui.mediaInfoLab.setText(str(e)+"\n"+self.warningInfo)
pass
def on_btnPlay_clicked(self):
try:
self.sys_mediaplay()
except TypeError:
warningInfo = "PLEASE OPEN A FILE FIRST!"
self.ui.mediaInfoLab.setText(warningInfo)
pass
def on_btnOpen_clicked(self): # 为什么会二次触发???
self.File_Open_triggered()
#测试程序
if __name__ == "__main__":
app = QApplication(sys.argv)
form = MyMainWindow() #创建窗体
form.show()
sys.exit(app.exec_())
# curPath = QDir.currentPath()
# dlgTitle = "choose an audio file"
# filt = "音频文件(*.mp3 *.wav *.wma)"
# __filename, _ = QFileDialog.getOpenFileName(self,dlgTitle,curPath,filt)
# __fileInfo = QFileInfo(__filename) #获取用户选择文件的信息(建立QFileInfo对象)
# QDir.setCurrent(__fileInfo.absolutePath()) #将用户文件路径设为当前路径
# # print(__fileInfo.absolutePath())
# song = QMediaContent(QUrl.fromLocalFile(__filename))
# __s_name = __fileInfo.baseName() #文件名和后缀
# self.ui.listWidget.addItem(__s_name)
#事件处理
# def closeEvent(self,event):
# #事件函数 closeEvent(event),event是QCloseEvent类型
# if self.player.state()==QMediaPlayer.PlayingState:
# self.player.stop()
这部分直接调用UI模块,而第三个.py文件,即主程序模块则是调用这个主窗口模块,又是一个程式化的模块:
# GUI应用程序主程序入口,创建应用程序和主窗体,显示主窗体并运行应用程序
import sys
from PyQt5.QtWidgets import QApplication
from myMainWindow import MyMainWindow
app = QApplication(sys.argv) #创建主程序 【Qt应用程序的主要类之一】
#使用sys.argv获取命令行参数,并根据这些参数的值来配置应用程序的行为。
mainform=MyMainWindow() #创建主窗体 调用自建的类-MyMainWindow()
mainform.show() #显示主窗体
sys.exit(app.exec_()) #.exec_():QApplication类的一个方法,用于启动应用程序的主事件循环