系类往期文章:
PyQt5实战——多脚本集合包,前言与环境配置(一)
PyQt5实战——多脚本集合包,UI以及工程布局(二)
PyQt5实战——多脚本集合包,程序入口QMainWindow(三)
PyQt5实战——操作台打印重定向,主界面以及stacklayout使用(四)
PyQt5实战——UTF-8编码器UI页面设计以及按钮连接(五)
PyQt5实战——UTF-8编码器功能的实现(六)
PyQt5实战——翻译器的UI页面设计以及代码实现(七)
PyQt5实战——翻译的实现,第一次爬取微软翻译经验总结(八)
PyQt5实战——翻译的实现,成功爬取微软翻译(可长期使用)经验总结(九)
PyQt实战——使用python提取JSON数据(十)
PyQt实战——随机涂格子的特色进度条(十一)
PyQt实战——实现编码器与进度条之间的通信,使进度条反映编码进度(十二)
前言
通过上一篇文章,我们已经大概了解了PyAudio是一个什么样的库以及给出了相应的示例代码,那么在本文中,我们就要使用PyAudio库,来实现音频的播放,同时呢我们将加上matplotlib库,来绘制音频的波形。通过阅读本文,你将了解到:如何使用PyAudio,将音频文件传入PyAudio,如何绘制音频波形,以及音频波形与音频播放的同步,如何避免音频卡顿问题。
展示
播放器思想
- 首先,我们要通过
PyAudio
来实现音频的播放功能,通过读取PCM文件,将音频输出给外设(耳机,扬声器等)。 - 此外,在音频输出的过程中,同时绘制当前读取数据块的折线图。
- 在当前数据块被读取时,折线图将绘制完成,当数据块被读取完时,数据块所表示的音频将被播放完。
- 将下一数据块的音频数据读取,然后重复上面的操作
__init__
:在对象初始化时,将一些PyAudio的参数初始化完成,比如采样率,声道,采样点大小,数据块大小等。并创建画布play
:播放方法,关闭上一个音频流,如果有的话,打开音频流,开一个线程来执行播放与绘画load_pcm_audio
:从pcm文件中读取数据play_audio
:在音频流中写入数据块,并重新绘制折线图update_parameters
:更新播放器的参数,如采样率,声道等closeEvent
:关闭音频流,释放系统资源
代码展示
下面是 音频播放器的代码,而非播放器UI的代码:
from random import sample
import sys
import numpy as np
import pyaudio
import matplotlib.pyplot as plt
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import threadingclass AudioPlayer(QWidget):def __init__(self,QWidget):super().__init__()self.file = ''self.layout = QVBoxLayout()self.setLayout(self.layout)self.figure = plt.Figure()self.canvas = FigureCanvas(self.figure)self.layout.addWidget(self.canvas)# 初始化PyAudioself.p = pyaudio.PyAudio()# 设置播放参数self.sample_rate = 16000 # 采样率self.channels = 1 # 单声道self.sample_width = 2 # 16位深度self.chunk_size = 1024 # 每次播放的PCM数据块大小# 波形更新self.x_data = np.arange(self.chunk_size)self.y_data = np.zeros(self.chunk_size)self.plot = self.figure.add_subplot(111)self.line, = self.plot.plot(self.x_data, self.y_data)# 去掉坐标轴self.plot.axis('off')# 显式设置坐标轴范围,确保波形线延伸到整个画布self.plot.set_xlim(0, self.chunk_size) # 设置x轴范围为数据块大小self.plot.set_ylim(-1, 1) # 设置y轴范围为[-1, 1],16-bit PCM数据的常规范围self.audio_data = Noneself.index = 0self.stream = Nonedef play(self,filename):if self.file != filename:self.file = filenameself.load_pcm_audio(filename)self.index = 0 # 重置播放进度# 如果audio_data为空,或者没有选择音频,直接返回if self.audio_data is None:print("请先选择一个音频文件!")return# 每次播放前重置播放进度和音频流self.index = 0# 如果stream已经存在且正在播放,先停止它if self.stream is not None and self.stream.is_active():self.stream.stop_stream()self.stream.close()# 打开音频流if self.sample_width == 2:samplewith = pyaudio.paInt16elif self.sample_width == 4:samplewith = pyaudio.paInt32self.stream = self.p.open(format=samplewith,channels=self.channels,rate=self.sample_rate,output=True,frames_per_buffer=self.chunk_size)# 启动一个单独的线程来播放音频threading.Thread(target=self.play_audio, daemon=True).start()def load_pcm_audio(self, filename):# 读取PCM音频文件with open(filename, 'rb') as f:self.audio_data = np.frombuffer(f.read(), dtype=np.int16)def play_audio(self):while self.index < len(self.audio_data):chunk = self.audio_data[self.index:self.index + self.chunk_size]if len(chunk) < self.chunk_size and len(chunk) > 0:chunk = np.pad(chunk, (0, self.chunk_size - len(chunk)), 'constant')self.index += self.chunk_sizeself.stream.write(chunk.tobytes())# 更新波形数据self.y_data = chunk / 32768.0 # 16-bit PCM音频数据范围 [-1, 1]self.line.set_ydata(self.y_data)self.canvas.draw()def update_parameters(self, sample_rate, channels, sample_width):self.sample_rate = sample_rateself.channels = channelsself.sample_width = sample_widthdef closeEvent(self, event):if self.stream is not None:self.stream.stop_stream()self.stream.close()self.p.terminate()event.accept()
下面给出代码的详细解释:
_init_
- 创建画布,绘制图像,并将画布加入到
layout
中 - 初始化PyAudio
- 设置播放参数,默认16K采样率,单声道,16比特采样点,数据块大小为1024
- 初始化坐标轴,在画布中,X轴不变,Y会随着数据块的更新而变化,每次更新折线图便会发生一次改变
- 去掉坐标轴(美观)
- X轴的范围是[0,1024],即一个数据块的大小,Y轴的范围是[-1,1],这是16bit PCM数据的常规范围
- 初始化音频数据变量,索引值,音频流等
play
- 如果当前的文件与重新选择的文件不一致,则会重新加载音频文件,如果没有音频文件,则会返回错误
- 重置播放进度
- 如果当前有音频流存在,暂停并结束它
- 重新打开音频流
- 单独开一个线程来执行播放音频和绘制波形
load_pcm_audio
- 从文件中读取数据,我们来详细解释一下
self.audio_data = np.frombuffer(f.read(), dtype=npint16)
f.read()
f
是一个文件对象,通常是通过open
打开文件后得到的。f.read()
读取文件内容,并将其作为一个字节串(bytes)返回。- 如果文件是二进制文件(比如
.wav
或.mp3
格式的音频文件),f.read()
会读取文件的所有字节内容。 f.read()
返回的数据是一个包含音频原始二进制数据的字节序列。
- 如果文件是二进制文件(比如
np.frombuffer()
np.frombuffer()
是NumPy
提供的一个函数,用于从缓冲区(字节串、字节流等)中创建一个NumPy
数组。- 它将原始的二进制数据解释为指定数据类型的数组。
- 该函数通常用于将文件中的二进制数据(如音频文件)转换为
NumPy
数组,方便进一步处理。
dtype=np.int16
dtype
参数指定了生成的NumPy
数组的数据类型。在这里,dtype=np.int16
表示将字节数据转换为 16 位整数(int16
)。np.int16
表示每个数据元素是一个 16 位有符号整数(即每个值占 2 个字节)。- 16 位整数常用于表示音频数据,因为音频信号通常是通过这种方式存储的,特别是当音频使用 PCM(脉冲编码调制)格式时。
self.audio_data
- 这段代码将
np.frombuffer()
返回的NumPy
数组赋值给self.audio_data
。self.audio_data
用于存储读取的音频数据。- 该数组的元素是从音频文件中读取的 PCM 数据,每个元素是一个 16 位整数,表示音频样本的幅度值。
play_audio
- 判断
index
当前音频的进度,如果还没读取完数据,则将audio_data
中的数据分块传给chunk
- 如果
chunk
的长度小于1024
,说明audio_data
已经到了最后一个数据块,且大小不等于1024
,因此需要在chunk
后面补零 - 更新
index
音频播放进度 - 将
chunk
数据块的数据写入音频流中 - 将
chunk
数据缩放到[-1,1]中 - 绘制折线图
这里值得注意的是,为什么当chunk
不足1024时,需要啊在chunk
后面补零呢,是画布的X轴大小为1024,如果Y轴的数据没有1024个,则无法完成绘画
update_parameters
- 更新参数方法,供上层UI界面调用
closeEvent
- 关闭音频流且释放系统资源
值得注意的是,closeEvent
方法通常是在窗口关闭事件发生时自动被调用的,它是与窗口或界面关闭相关联的事件处理函数。
event.accept
方法是用来标记事件已被处理,表示允许窗口关闭(即立即销毁窗口并退出程序)。如果你不调用event.accept
窗口,窗口的关闭可能会被组织或无效。
音频波形与音频播放的同步
如果要实现音频的可视化,音频播放与波形绘制的同步时必不可少的操作,在这里,我们通过PyAudio
的流式操作,将音频数据分成数据块来读取并播放,这样的操作思想为实现波形绘制与音频播放的同步奠定了基础。
在每一个音频数据块被读取时,我们将数据块交给PyAudio
播放的同时,也制作数据进行波形绘制。这样确保了绘制与播放操作的是一个数据块,不会出现速度不一的情况。
主要的实现在play_audio
方法中:
def play_audio(self):while self.index < len(self.audio_data):chunk = self.audio_data[self.index:self.index + self.chunk_size]if len(chunk) < self.chunk_size and len(chunk) > 0:chunk = np.pad(chunk, (0, self.chunk_size - len(chunk)), 'constant')self.index += self.chunk_sizeself.stream.write(chunk.tobytes())# 更新波形数据self.y_data = chunk / 32768.0 # 16-bit PCM音频数据范围 [-1, 1]self.line.set_ydata(self.y_data)self.canvas.draw()
如何避免音频卡顿
在笔者第一次使用PyAudio
的功能时,为了实现播放功能,而没有深入了解PyAudio
的运行原理,导致在第一次播放时正常,再重复播放几次后会出现卡顿情况,播放次数越多卡顿越厉害,到最后,音频的波形只有几帧了,随后笔者开始对如何实现PyAudio
展开了优化,上面是优化后的代码,下面给出优化的心路历程。
UI线程与音频线程的阻塞问题
音频播放是一个需要实时更新的过程,可能会造成UI线程和音频线程的冲突。特别是音频播放涉及IO操作,如果UI更新阻塞了音频播放,可能会导致卡顿。
修改后,通过开子线程的方式,将UI线程与音频线程区分开,使音频播放独立于UI线程执行。
内存管理问题
在之前的代码中,每一次点击播放,都会重新调用一次load_pcm_audio
,会重新将整个PCM文件保存在self.audio_data
中,如果文件较大,这会消耗大量的内存,尤其是当多次播放时,音频数据会反复加载,可能会导致内存积累,引起卡顿。
修改后,仅有当检测读取文件与上一次读取文件不一致时,才会调用load_pcm_audio
,否则将不重新加载PCM文件,直接使用已有数据,使用self.index
来控制播放进度。
PyAudio流的管理问题
在之前的代码中,初始化self.p = pyaudio.PyAudio()
是放在play
方法中,这会导致,每次按键调用play
方法时,stream
对象就会被重复创建且播放时没有正确地停止和清理,导致音频流的资源没有得到释放,影响性能。
修改后,stream
对象在初始化时便被创建,往后在play
方法时不重复创建,在结束时被释放。