Pygame 播放视频 / 音视频同步 / 播放多段短连续音频产生爆音

最近突然想起手上乱写的一个项目,拿pygame实现各种功能的组件,其中就有一个视频组件没做好,卡在心头,便想办法重新弄下

该组件没使用opencv太麻烦了,用了moviepy方便

播放视频

至于播放视频就很简单了,直接把窗口帧率改成视频的帧率,每秒进行绘画即可:

def draw(self, x=None, y=None):
        if not self.isPlaying:
            if self.surface:
                self._BlitOnScreen(self.surface, (x, y))
                return
            if self.coverSurface == None:
                self.coverPic = self.videoClip.get_frame(0)
                self.coverSurface = pygame.surfarray.make_surface(self.coverPic.swapaxes(0, 1))
                self.coverSurface = pygame.transform.scale(self.coverSurface, (self.width, self.height))

            self._BlitOnScreen(self.coverSurface, (x, y))
            return
        
        threading.Thread(target=self._playAudioPerFrame).start()  # 播放音频
        
        frame      = self.videoClip.get_frame(self.frames[self.nowIndex])
        self.nowIndex += 1
        surface = pygame.surfarray.make_surface(frame.swapaxes(0, 1))
        surface = pygame.transform.scale(surface, (self.width, self.height))
        self._BlitOnScreen(surface, (x, y))
        
        self.audioFinished.wait()  # 等待该帧的音频播放完毕

上述代码只是该组件的一部分,每次窗口刷新时会调用该函数,判断该组件是否处于播放状态,若是播放状态则获取当前视频帧

self.videoClip.get_frame(self.frames[self.nowIndex]) 用于获取视频帧

其中 self.videoClip = VideoFileClip(path)

from moviepy.editor import VideoFileClip

self.nowIndex用于记录当前播放到的视频帧数)

并且在绘制图像前先启动了线程进行音频的播放,并在绘制完成等待音频播放完成。(self.audioFinished只是一个threading.Event用于线程间互相等待,而绘制过程于主线程完成,音频播放另起新线程)

至于控制窗口刷新帧率,在主循环中 time.sleep(1/video_fps) 即可

音视频同步 – 1

上面提到了使用threading.Event控制了音频播放线程和视频播放的同步,然而这并不足够。

不得不说moviepy的功能真完善,在你使用 VideoFileClip 导入视频后,该对象就有了一个 audio 属性,即一个 AudioClip 的对象,可以通过时刻来获取音频帧。

在音视频同步前,不得不讲一下高中信息技术的内容了:

音频采样率和基本信息

音频文件有一个独特的属性,音频采样率,这个东西和视频的帧率差不多是一个概念,即每秒有多少个音频数据。(大多为一个 [int, int] 类型的数据为一个采样)

pygame 支持通过 numpy 做中间商来将一个 [[int, int], [int, int], …] 类型的数组(即某个时间段的采样)转变为pygame的 Sound 对象:

pygame.sndarray.make_sound(array)

moviepyAudioClip 可以通过 to_soundarray 方法来获取某个时间或时间数组时(即 [time1, time2, time3, …] 类型的数组)的采样:

AudioClip.to_soundarray(self, tt=None, fps=None, quantize=False, nbytes=2, buffersize=50000)

其中tt就是上述的时间或时间的数组。

相辅相成!

音视频同步 – 2

既知如此,我们只需算出每帧播放多少个采样即可。

11=

即:

$\frac{\text{音频采样率}}{\text{视频帧率}} = \text{每视频帧播放音频采样数}$

然后在每次帧刷新后开一个线程与绘制过程并发进行播放操作即可。

具体实现:

def _playAudioPerFrame(self):
        self.audioFinished.clear()

        nowVideoFrame = self.audioPlayPerVideoFrame * self.nowIndex
        tovideoFrame  = nowVideoFrame + self.audioPlayPerVideoFrame

        pos = numpy.array(range(nowVideoFrame, tovideoFrame))
        frames = self.audio.to_soundarray(1/self.audio.fps*pos, nbytes=self._nbytes, quantize=True)

        chunk = pygame.sndarray.make_sound(frames)
        chunk.set_volume(self.getVolume())

        n = 0
        while self.audioChannel.get_queue():
            time.sleep(0.001)
            n+=0.001
            if n >= 0.01:
                print("timeout")
                self.audioChannel.stop()
                break
        
        self.audioChannel.queue(chunk)

        self.audioFinished.set()

其中 nowVideoFrame 是当前播放起始点采样,而 tovideoFrame 是播放终点的采样,就可以通过 range 算出所有采样的时刻(即 pos 变量)转为 numpy 数组。

例如第0~1/60秒(即一帧)的 pos 变量此时的值为: [0, 1, …, 734] (假设采样率为44100Hz)

意味着我们需要获取这些ID的采样。而 to_soundarray 需要一个秒为单位的时间数组。这时只需拿 (1/音频采样率) * pos 即可得到每个采样对应的时刻

上述代码中通过 self.audio.fpsAudioClip.fps 属性获取音频采样率,具体可参考 moviepy文档。

注意:只有 numpy 的数组支持将数据乘进去,Python 的原生列表只会像字符串那样将列表重复n遍:

至于为什么上述代码又设置了一个 while self.audioChannel.get_queue() 循环,是因为为了防止前一个音频采样还未播放完就继续播放下一个音频,但 pygamemixer.Channel 对象有时会因为其他音频的播放卡住,所以在循环内又设置了一个超时检测,防止其他音频卡住 Channel

至于为什么不使用 Channel.play 来播放采样,是因为直接使用 play 播放会中断未播放的采样,导致声音会变卡、刺耳。而 Channel.queue 会将未播放的采样跟在正在播放的采样的后面,等待前一个采样播放完成再播放下一采样。

最后代码结尾放下 audioFinished 的标记,使得视频帧停止等待,继续绘制。

爆音问题 – Another solution

不知为何,使用该方法播放音频时总会导致随机的爆音,初步分析时觉得是采样率的问题,但是无论是改 pygame.mixer 的播放频率还是 to_soundarray 的采样率,44100、22050 的采样率甚至更低。

在网上搜了一下,发现是由于使用 queue 导致声道音频的采样波形断崖下坠至 0 产生的问题(新的知识),”可怕的音频噪音悬崖“

python – Prevent “popping” at end of sound in Pygame.mixer – Stack Overflow

Queuing very short sounds creates stuttering · Issue #694 · pygame/pygame · GitHub

Pygame Audio Popping/Crackling on Raspberry Pi 3 : r/pygame (reddit.com)

moviepy 的 preview 产生爆音更小是因为它不像我们启用多个线程而是一个线程一直播放一个音频,我们尝试在一个线程中不考虑音视频同步的问题(因为同步会导致上述问题),发现噪音消失了。

因为音频播放的速率不变,基本是准确的,我们考虑从视频播放端控制延迟(前面的内容对于播放一个要求不高的视频已经很好了),这就很简单了,只需将音频播放到的帧同步到视频应该播放的帧即可。

(上面第56行的 self.nowIndex += 1 也可以删去了,这样子不管窗口帧率是多少,都能以固定速率播放视频。)

这个方案是最好的,音频端无需考虑同步问题,因为扬声器已经帮你处理了(通过设置音频采样率)。

发表回复