Featured image of post 基于PyQt开发的脚本集合包(十三)

基于PyQt开发的脚本集合包(十三)

在本文中,我们将采用PyAudio库与matplotlib库来实现音频播放器

前言

通过上一篇文章,我们已经大概了解了PyAudio是一个什么样的库以及给出了相应的示例代码,那么在本文中,我们就要使用PyAudio库,来实现音频的播放,同时呢我们将加上matplotlib库,来绘制音频的波形。通过阅读本文,你将了解到:如何使用PyAudio,将音频文件传入PyAudio,如何绘制音频波形,以及音频波形与音频播放的同步,如何避免音频卡顿问题。

播放器思想

  • 首先,我们要通过PyAudio来实现音频的播放功能,通过读取PCM文件,将音频输出给外设(耳机,扬声器等)。
  • 此外,在音频输出的过程中,同时绘制当前读取数据块的折线图。
  • 在当前数据块被读取时,折线图将绘制完成,当数据块被读取完时,数据块所表示的音频将被播放完。
  • 将下一数据块的音频数据读取,然后重复上面的操作
  • __init__:在对象初始化时,将一些PyAudio的参数初始化完成,比如采样率,声道,采样点大小,数据块大小等。并创建画布
  • play:播放方法,关闭上一个音频流,如果有的话,打开音频流,开一个线程来执行播放与绘画
  • load_pcm_audio:从pcm文件中读取数据
  • play_audio:在音频流中写入数据块,并重新绘制折线图
  • update_parameters:更新播放器的参数,如采样率,声道等
  • closeEvent:关闭音频流,释放系统资源

代码展示

下面是 音频播放器的代码,而非播放器UI的代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
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 threading


class 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)

        # 初始化PyAudio
        self.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 = None
        self.index = 0
        self.stream = None

    def play(self,filename):
        if self.file != filename:
            self.file = filename
            self.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.paInt16
        elif self.sample_width == 4:
            samplewith = pyaudio.paInt32
        self.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_size
            self.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_rate
        self.channels = channels
        self.sample_width = sample_width
    
    def 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)
  1. f.read()
  • f 是一个文件对象,通常是通过 open 打开文件后得到的。
  • f.read()读取文件内容,并将其作为一个字节串(bytes)返回。
    • 如果文件是二进制文件(比如 .wav.mp3 格式的音频文件),f.read() 会读取文件的所有字节内容。
    • f.read() 返回的数据是一个包含音频原始二进制数据的字节序列。
  1. np.frombuffer()
  • np.frombuffer()NumPy 提供的一个函数,用于从缓冲区(字节串、字节流等)中创建一个 NumPy 数组。
  • 它将原始的二进制数据解释为指定数据类型的数组。
  • 该函数通常用于将文件中的二进制数据(如音频文件)转换为 NumPy 数组,方便进一步处理。
  1. dtype=np.int16
  • dtype参数指定了生成的NumPy数组的数据类型。在这里,dtype=np.int16表示将字节数据转换为 16 位整数(int16)。
    • np.int16 表示每个数据元素是一个 16 位有符号整数(即每个值占 2 个字节)。
    • 16 位整数常用于表示音频数据,因为音频信号通常是通过这种方式存储的,特别是当音频使用 PCM(脉冲编码调制)格式时。
  1. self.audio_data
  • 这段代码将np.frombuffer()返回的NumPy数组赋值给self.audio_dataself.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方法中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    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_size
            self.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方法时不重复创建,在结束时被释放。

written by LyricalWander
使用 Hugo 构建
主题 StackJimmy 设计