当前位置: 首页 > 工具软件 > PyAudio > 使用案例 >

PyAudio模块的基本使用,阻塞式/非阻塞式地录制/播放音频

翟缪文
2023-12-01

pyaudio库:音频处理

pyaudio文档,大多数变量和接口的定义还是在C版本的PortAudio文档
PyAudio对象只负责播放音频,不负责从文件中读取二进制数据,所以读取要在外面进行,给到它的是二进制数据,一般会结合wave库一起使用,wave库负责读数据以及获取音频的一些基本信息。下面是一些用例:

import wave
import pyaudio
audio = pyaudio.PyAudio()  # 新建一个PyAudio对象
# wave.open跟python内置的open有点类似,从wf中可以获取音频基本信息
with wave.open(path, "rb") as wf:
    stream = audio.open(format=pyaudio.paInt16,  # 指定数据类型是int16,也就是一个数据点占2个字节;paInt16=8,paInt32=2,不明其含义,先不管
                        channels=wf.getnchannels(),  # 声道数,1或2
                        rate=wf.getframerate(),  # 采样率,44100或16000居多
                        frames_per_buffer=1024,  # 每个块包含多少帧,详见下文解释
                        output=True)  # 表示这是一个输出流,要对外播放的
    # getnframes获取整个文件的帧数,readframes读取n帧,两者结合就是读取整个文件所有帧
    stream.write(wf.readframes(wf.getnframes()))  # 把数据写进流对象
    stream.stop_stream()  # stop后在start_stream()之前不可再read或write
    stream.close()  # 关闭这个流对象
audio.terminate()  # 关闭PyAudio对象

需要注意的是,在write之后stream就会阻塞,开始播放音频,如果这个音频文件有三分钟,那么程序就会停在write这三分钟,直到把整个音频播放完毕,再继续往下执行。然而在很多应用中,我们需要的是实时问答,中途可以打断对话,也就是说不应该一次性把一整段话都write进来,应该分批写入。pyaudio自然也考虑到了这个问题,它已经有了可用的解决方案——回调函数callback。回调函数有固定的格式:

callback(in_data,      # 如果input=True,in_data就是录制的数据,否则为None
         frame_count,  # 帧的数量,表示本次回调要读取几帧的数据
         time_info,    # 一个包含时间信息的dict,略
         status_flags): # 状态标志位,略
    pass

回调函数由pyaudio新开一个线程来执行,因此不会阻塞。由于回调函数只有固定的4个参数,诸如文件指针等参数并不在列,因此只能通过类数据成员的方式传给callback函数(全局变量好像不行,因为callback是在另外一个线程执行的)。在给出例子之前,需要明确一些概念(部分解释来自自己的理解,其他来自参考链接):

rate: 采样率,即每秒的帧frame
frame: 帧,指一个时刻上的数据点,多个声道在同一时刻的数据点的集合称为frame
frames_per_buffer: 每个缓冲区保存多少帧frame,一个buffer也称为一个块chunk,每次调用回调函数都会读/写一个块chunk的数据(具体实现还是我们自己写,不是自动的)

因此,当使用int16类型保存数据点时,单声道1个数据点占用2字节,双声道2个数据点占用4个字节,所以双声道1帧就占用4个字节。frames_per_buffer通常为1024,那么1个块就占用4096个字节。那我们接着给出带回调函数的音频播放例子(上一个例子有讲解过的语句这里就不再放注释了):

import pyaudio
import wave
import time

path = "background.wav"
wf = wave.open(path, "rb")
wav_data = wf.readframes(wf.getnframes())
meta = {"seek": 0}  # int变量不能传进函数内部,会有UnboundLocalError,所以给它套上一层壳

def callback(in_data, frame_count, time_info, status):
    # 这是针对流式录制,只有二进制数据没有保存到本地的wav文件时的做法,通过文件指针偏移读取数据
    start = meta["seek"]
    meta["seek"] += frame_count * pyaudio.get_sample_size(pyaudio.paInt16) * wf.getnchannels()
    data = wav_data[start: meta["seek"]]
    # 如果有保存成wav文件,直接用文件句柄readframes就行,不用像上面那么麻烦
#     data = wf.readframes(frame_count)
    return (data, pyaudio.paContinue)

audio = pyaudio.PyAudio()
stream = audio.open(format=audio.get_format_from_width(wf.getsampwidth()),
                channels=wf.getnchannels(),
                rate=wf.getframerate(),
                output=True,
                stream_callback=callback)

stream.start_stream()

# 对callback更进一步的理解见本文下一段的“根据文档原话学pyaudio”以及"根据实验结果推测pyaudio的内部实现"
# 划重点:start_stream()之后stream会开始调用callback函数并把得到的data进行stream.write(),
# 直到状态码变为paComplete就停止读取(一般是到达音频文件末尾)。由于回调函数是通过另开线程调用的,
# 它也需要像平常多线程代码一样有类似join的操作,否则主线程stop_stream()就没了。因此这里的while循环必不可少,
# 当stream仍处于活跃状态(应该就是音频文件还没读完)时,让主线程休眠,也就是让主线程等子线程执行完。
# 而我们又可以在while循环里添加一些条件判断,在整个音频播放完成之前提前跳出循环,结束播放,这样就实现了前面所说的实时问答,打断说话的效果
# stream初始化后到stop_stream()之前,is_active()都是True,stop_stream()之后变成False,start_stream()之后又变成True,它表示的是这个流是否开放读写,实际上也对应音频数据是否读完,因为数据一读完stream就会被stop
while stream.is_active():
    time.sleep(0.1)

stream.stop_stream()
stream.close()
wf.close()

audio.terminate()

另外,有些例子会在音频播放结束之后加一句休眠语句,以防音频由于设备延迟还没播完就被关闭了:

time.sleep(stream.get_output_latency())

get_output_latency()方法用于获取设备的输出时间延迟,该值与播放的音频内容无关,与播放音频的设备类型有关,可能与缓冲区大小有关,没有测试过。
输出时间延迟是指音频样本由应用生成到通过耳机插孔或内置扬声器播放之间的时间。
输入延迟时间是指音频信号由设备音频输入(如麦克风)接收到相同音频数据可被应用使用的时间。参考链接
不过实际上我自己不加这个sleep也没有感觉到被突然切断,所以这行代码作用有多大暂时不清楚。

根据文档原话学pyaudio
1.Start processing the audio stream using pyaudio.Stream.start_stream() (4), which will call the callback function repeatedly until that function returns pyaudio.paComplete. Note: stream_callback is called in a separate thread (from the main thread).
1*.在 stream对象调用start_stream()方法之后,会新开一个线程调用回调函数,不停地读取数据进行stream.write(),直到把回调函数返回pyaudio.paComplete之后就不再调用回调函数,接着把stream里头剩下的数据播放完毕。

2.out_data is a byte array whose length should be the (frame_count * channels * bytes-per-channel) if output=True or None if output=False. flag must be either paContinue, paComplete or paAbort (one of PortAudio Callback Return Code). When output=True and out_data does not contain at least frame_count frames, paComplete is assumed for flag.
2*.这里给出了回调函数应当返回的音频块长度(字节),以及何时返回paComplete何时返回paContinue。当音频块是一整块时,返回paContinue,不足一整块时(也即音频数据的最后一个块)返回paComplete。但是根据实验表明,返回paComplete时这个不完整的块不会被播放,但我们需要这一部分音频,因此我们依然要返回paContinue

根据实验结果推测pyaudio的内部实现
1.在回调函数里返回某个固定的块和paContinue。结果:重复播放这个块,陷入死循环
1*.与文档所述相同,只要有paContinue就会一直取数据

2.在回调函数中返回整个块和paComplete。结果:不播放这个块,结束。
2*.与文档所述不同,返回paComplete时这个块即使完整也不会被播放,但我们需要这一个块,因此我们实践中最后一个不完整的块依然要返回paContinue

3.在回调函数中返回不完整的块和paContinue。结果:不播放这个块,结束。
3*.可能代码内部是有做什么检查的,就算给了paContinue,块不是完整的也不给发,所以最后一个不完整的块需要补b"\x00"或者别的什么空数据补足一个块,返回了才会起作用。

4.把块长度调整为5s音频,令程序在加载完第2个块后退出。结果:停顿了几秒钟才开始播音,播了几秒钟就结束了。更细致的实验:计算程序运行时间,令程序在加载完第2个块后退出,用时10.4s,手机测得从程序开始运行到有声音大约需要5秒;令程序在加载完第4个块后退出,用时20.5s。在回调函数中截取数据所需时间非常之短。
使用一段24.5s的音频跟一段21s的音频令其在最后一个块给出paComplete,运行时间相同;令二者在最后一个块给出paContinue,运行时间不变;令二者补足至完整块长度并返回paContinue,音频可以播放完整,运行时间多了一个块长度。每0.5秒输出一次is_active()的值,发现对于上述两段音频,该值都是在第30秒也就是播音结束的时刻(5+25)才变为False
4*.初步推测是代码把回调函数发来的第一个音频块完全加载进流内部之后才开始播音,而且这个加载所需的时间跟按配置的采样率播放完这段音频的时间完全一致在回调函数中停留的时间非常短,但又隔了很久才开始播音,因此时间应该花在把这个块的数据加载进某个地方上了,可能是设备缓冲区啥的。在回调函数调用结束之后,如果收到paComplete,则结束调用死循环,执行stop_stream(),此时is_active()结果将变为False,主线程的sleep死循环被打破。
首先简记播放一个块所需的时间为T,我猜是这样的,有一个缓冲区,代码内部的循环会每隔T时间检测一次缓冲区,如果有数据就拿走播放,没有就不播放(等价于播放b"\x00"序列)。最开始的时候缓冲区为空,因此前T时间设备没有声响;在设备播放从缓冲区中拿到的数据时,线程通过回调函数拿到了下一个T对应的音频数据,放进缓冲区;缓冲区的大小是T时间对应的块长度,也就是只能容纳一个块。设备播放完一个块的数据后又到缓冲区拿数据播放,所以听起来是连续的;缓冲区的数据被拿走后回调函数再次启动,前去拿回一个块的数据放到缓冲区,也不拿多,可能是觉得太多比较占用内存,而且拿肯定比播放的速度快,没有必要拿那么多一直放在那。调用回调函数的程序应该也是一个阻塞的过程,当缓冲区为空时才会启动回调函数,否则就在那干等着。在播放完一个块的音频,准备去缓冲区取数据之前,检查pa状态,如果是paComplete则停止播放,流对象也stop_stream(),退出子线程。如果回调函数给的是paContinue但数据却不是完整的块,状态变量仍会被置为paComplete。在最开始的时候,pa状态是初始值paContinue,去缓冲区取数据,缓冲区数据应该是全b"\x00",所以没有声音;在最后的时候,pa状态变成paComplete,所以不会去播放那个不完整的块。程序是否播放音频,看的并不是缓冲区是否为空,因为内存空间一定有值,它没有“空”的概念,应该是由状态变量pa来控制播不播放。
总结:只有块长度完整且返回值是paContinue的块才会被放入缓冲区;只要被放进缓冲区,一定会被取走播放;在最后一个块播放结束之后,stream.is_active()才会被置为False
因此,正常情况下流式播放并不需要time.sleep(stream.get_output_latency()),因为出while循环的时候就已经播放完毕了。播放的音频最后有缺失一定不是因为sleep得不够久,而一定是回调函数没有补齐最后一个块并给出paContinue的。

搞懂了回调函数的原理之后,录音部分就很简单了:

# 阻塞式录制
import pyaudio
import wave
record_seconds = 3  # 录制时长/秒
pformat = pyaudio.paInt16
channels = 1
rate = 16000  # 采样率/Hz

audio = pyaudio.PyAudio()
stream = audio.open(format=pformat,
                    channels=channels,
                    rate=rate,
                    input=True)

wav_data = stream.read(int(rate * record_seconds))
with wave.open("tmp.wav", "wb") as wf:
    wf.setnchannels(channels)
    wf.setsampwidth(pyaudio.get_sample_size(pformat))
    wf.setframerate(rate)
    wf.writeframes(wav_data)

stream.stop_stream()
stream.close()
audio.terminate()
# 非阻塞式录音,省略import语句
path = "record.wav"
data_list = []  # 录制用list会好一点,因为bytes是常量,+操作会一直开辟新存储空间,时间开销大

def callback(in_data, frame_count, time_info, status):
    data_list.append(in_data)
    # output=False时数据可以直接给b"",但是状态位还是要保持paContinue,如果是paComplete一样会停止录制
    return b"", pyaudio.paContinue

record_seconds = 3  # 录制时长/秒
pformat = pyaudio.paInt16
channels = 1
rate = 16000  # 采样率/Hz

audio = pyaudio.PyAudio()
stream = audio.open(format=pformat,
                    channels=channels,
                    rate=rate,
                    input=True,
                    stream_callback=callback)

stream.start_stream()

t1 = time.time()
# 录制在stop_stream之前应该都是is_active()的,所以这里不能靠它来判断录制是否结束
while time.time() - t1 < record_seconds:
    time.sleep(0.1)

wav_data = b"".join(data_list)
with wave.open("tmp.wav", "wb") as wf:
    wf.setnchannels(channels)
    wf.setsampwidth(pyaudio.get_sample_size(pformat))
    wf.setframerate(rate)
    wf.writeframes(wav_data)

stream.stop_stream()
stream.close()
audio.terminate()

如果对文中内容有疑问或者建议,欢迎在评论区跟我交流讨论~

 类似资料: