实时音频编程
Posted 芥末的无奈
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实时音频编程相关的知识,希望对你有一定的参考价值。
简介
初入音频坑时,对于"实时音频编程"并无基本认识,也从未意识到音频编程在某些场景下是”较为特殊的“,只是觉得写出来的代码没有 bug、没有内存泄露、接口易用就已经满足要求了,至于 real-time safe 是啥,根本没听过。直到开始接触音频播放系统,才开始慢慢接触到了 real-time 的概念,周边的同事也分享了更多关于实时音频编程的知识。
网络上对于实时音频编程的内容不多,主要有:
- Real-time audio programming 101: time waits for nothing,一篇 2011 文章,作者给出了实时音频编程的"铁则",并从原理上解释了为什么遵守这些规则。
- Fabian Renn-Giles & Dave Rowland 在 2019 JUCE 开发者大会上分享的 real-time 101 系列,作者分享了很多实时音频编程的技巧
以上两份资料之前也只是草草看过,学习了一些基本概念,最近在 review 代码时发现 real-time 的概念在组内没有很好的普及,于是打算重新整理相关资料并总结成文并分享出来。本文的主要内容是上述两份资料总结,添加一些具体例子帮助理解。有兴趣的同学推荐观看原文。
实时系统
我们首先要搞清楚 real-time 的含义,它意味着什么。
A system is said to be real-time if the total correctness of an operation depends not only upon its logical correctness, but also upon the time in which it is performed.[1].
如果一个操作的全部正确性不仅取决于其逻辑正确性,而且还取决于执行该操作的时间,那么这个系统就被称为实时系统 – wiki
一个实时系统是指计算的正确性不仅取决于程序的逻辑正确性,也取决于结果产生的时间,如果系统的时间约束条件得不到满足,则认为系统失效 – 百度百科
根据定义,在实时系统下,系统的正确性 = 逻辑正确 + 满足时间要求。一个实时操作系统面对变化的负载(从最小到最坏的情况)时必须确定性地保证满足时间要求。请注意,必须要满足确定性,而不是要求速度足够快!就拿 windows pc 机来举例,机器空闲的状态下,pc 响应时间非常快,但是一旦后台程序变多,cpu 负载增加后,响应速速会大大降低,甚至出现卡死等情况,所以说 windows 无法满足确定性,它不是实时系统。
实时系统的分类
根据响应时间,以及错误响应时间所产生的影响,可以将实时系统分为三类:
- 强实时系统。强实时系统必须是对即时的事件作出反应,绝对不能错过事件处理时限。若有任务实例未在截止期限内完成,则会对系统造成不可估量的损失。例如测控领域就是要求强或接近强实时系统
- 准实时系统。允许系统偶尔超时,但这可能会降低系统的服务质量。但若任务超时,该任务的计算结果没有任何意义。
- 弱实时系统。通常是指允许任务超时,但超时后的计算结果仍有一定的意义,并且其意义随着超时时间的增加而下降。
实时音频系统
数字音频的工作原理是向声卡或音频接口的数模转换器(DAC)播放持续的音频样本流。这些样本是以一个恒定的速率播放的,称为采样率。对于CD播放器,采样率是44100Hz,也就是每秒44100个采样点。每一秒钟都是相同的速率,不快也不慢,每秒刚刚有 44100 个采样点。如果你的声卡在DAC需要时没有下一个样本,你的音频就会出现 glitch。
在常用的操作系统中,软件通常不会向 DAC 发送单个采样,它将向驱动或者操作系统提供一个音频缓存 buffer。例如,这个 buffer 大小为 256,那么它可能以 179.26Hz(44100/256) 的速率处理音频缓存 buffer。然后,系统的底层以 44100Hz 的速度将 buffer 里的采样一个一个的送入 DAC 中。
关于播放,我们举个实际的例子(完整代码在 0_playback.cpp)使用 PortAduio 进行音频播放。PortAudio 是一个简洁的跨平台的音频 I/O 库,目前支持 Windows、Mac OSX、Linux(很遗憾,不支持 android)。它使用回调机制来处理音频请求。
PortAudio 只需要两步就能进行音频播放:
- 编写回调函数,在回调函数中将需要播放的数据填入 Buffer 中
- Pa_OpenStream 打开音频流,并注册回调函数。
在使用 Pa_OpenStream
时,我们需要指定音频 buffer 的大小,以上面 256 为例,我们的回调函数必须在不到 5.8ms(256/44100) 的时间内计算好每一个 buffer。无论你的代码被如何调用,它必须在限定的时间内提供这些音频采样点,否则,你就会听到刺耳的 glitch。我们在 1_playback_underrun.cpp 中模拟了这种情况,它回调函数中使用 sleep
使得无法在限定时间内给到 buffer,
这时候播放就出现了糟糕的杂音。
说个题外话,人耳对声音是非常敏感的,对于播放的音频,只要有一个是异常的,它都能分辨出来。例如 sine_glitch.wav,这是一个 sine 波,仅在 2s 处修改了一个采样的值,也就是这一个采样,你的耳朵明显能听出来这段音频是有瑕疵的。
什么会产生 glitch ?
人耳对 glitch 非常敏感,我们当然不希望出现 glitch,如果我们的缓冲 buffer 是 5ms 的话,那么代码必须在 5ms 内处理完音频。
glitch 产生的原因,归根结底就一个原因:代码处理时间比缓冲 buffer 还要长。 这可能是因为你的代码太慢了,无法实时运行,但我们的关注点并不在算法优化上。假设你的代码足够高效,可以实时运行,或者你有能力对它进行优化,使它足够快。
这里我们关注的是那些运行时间无法预测代码,也就是说你无法预测这个函数需要多久时间才能完成。或许是你选择的算法不合适,或者你不了解算法的行为。不管什么原因,结果都是一样的:你的代码运行时间比缓冲 buffer 要长,导致了 glitch。
因此,实时音频编程的基本规则可以简单地总结为:如果你不知道这要花多久时间,那就不要做。
那么有哪些操作会导致 glitch 呢?下面我们将详细的讨论它们。
阻塞
“不要做任何阻塞音频回调线程的事情”。做任何让你的音频代码在系统中等待其他东西的事情都是阻塞的。这可能是获取一个mutex,等待一个资源,如semaphore,等待其他线程或进程做一些事情,等待从磁盘上读取数据,等待一个网络套接字。很明显,其中一些等待时间并不明确,某些执行时间肯定比几毫秒长。下面我将更详细地讨论这些具体的阻塞类型。
记住,你不仅要避免直接编写阻塞的代码,关键是你要避免调用第三方或操作系统的代码,这些代码可能会在内部阻塞。
算法的最坏时间复杂度
假设一个理想的情况:音频回调中的每一行代码都是自己写的,没有调用任何可能阻塞 api 或第三方代码。
即便如此,你可能仍然有一个问题:软件效率通常是以平均时间复杂性来分析的。例如,在许多应用中,一个算法在99.9%的时间里运行得超快,但偶尔需要1000倍的时间,可能仍然被认为是"目前最快的算法"。如果你在音频回调中偶然发生了需要 1000 倍运行时间的情况,你可能会出现 glitch。出于这个原因,你应该总是考虑你的代码的最坏情况下的执行时间。
在这里需要记住的另一件事是,许多操作系统和库函数是使用平均情况下的优化算法实现的。在C++中,许多STL容器方法就属于这一类。通用的内存分配算法和垃圾回收器也有不可预测的时间行为,即使它们不使用锁。
锁
当你的音频程序需要与 GUI 界面、网络后者磁盘 IO 进行交互时,很难避免并发。例如,你的 GUI 程序以某种形式控制着音频算法的某个参数,你需要在 GUI 线程与音频线程之间通信;或者你想要实时的绘制音频波形图。
你可能非常自然地会想到通过一个锁来保护两个线程的共享数据,但你不应该在音频回调中使用它们。这里有三个原因。
不使用锁的第一个原因:优先级倒置
假设你的 GUI 线程与音频回调线程共享一个锁,为了让音频回调及时处理,你需要等待 GUI 线程释放该锁。GUI 线程的优先级比音频回调线程优先级低得多,所以它可能被系统上任意进程打断,音频回调将不得不等待其他线程完成,直到 GUI 线程释放锁。这一过程中,虽然音频回调有着最高优先级,但它却不得不等待其他线程完成,这就是优先级倒置。
实时操作系统实现了特殊的机制来避免优先级倒置。例如,通过暂时将锁持有者的优先级提升到等待锁的最高线程的优先级。在Linux上,这可以通过使用带有RT preempt补丁的内核来实现。但是,如果你希望你的代码可以移植到所有的通用操作系统上,那么你就不能依赖实时操作系统的功能。
不使用锁的第二个原因:执行时间可能超时
使用锁可能会导致优先级倒置的问题,但如果你还是在考虑使用锁来同步数据,那么要注意一点:音频回调将不得不等待所有被锁保护的代码完成,然后才能继续。实际上,除了上下文切换的开销外,这些被保护的关键代码可能相当的复杂,你知道这些代码的执行时间吗?记住,我们这里讨论的是最坏时间复杂度。例如,在 C++ 中,你不会想这么做:
mutex.lock()。
my_data_vector.push_back( 10 ); // 可能分配内存,并且复制大量数据
mutex.unlock()。
如果 my_data_vector 是一个std::vector,当 vector 的内部存储空间已满时,调用push_back()将导致内存被分配,所有现有元素被复制到新的内存中。这显然会导致处理时间的激增。大多数非实时代码在某些时候会有这样的表现。看起来简单的代码很容易出现非确定性的时间行为。
不使用锁的第三个原因:复杂的线程调度器
除了优先级倒置和关键代码执行时间无法估计外,线程调度器的复杂性也是我们应该避免使用锁的原因之一。
很少有人确切知道调度器是如何实现的,不管是什么操作系统,调度器的实现可能随着每个操作系统的发布而改变。这些通用的操作系统调度器并不要求或保证表现出实时行为。它们没有被认证用于航空电子系统或医疗设备。没有政府或司法机构对它们的实时性进行问责。
作者在这方面的一般立场是,应该避免与操作系统的线程调度器进行任何形式的互动。避免在你的音频回调中调用任何同步函数。调度器采用了复杂多样的算法,你不希望给他们额外的理由来取消实时音频线程的调度。
内存分配
不要音频线程中分配内存,禁止 new、delete、malloc、free 等操作,或者任何分配内存的函数,以及任何可能调用这些函数的程序。原因有三:
- 内存分配器可能有锁,用于同步不同线程之间的数据。
- 内存分配器可能不得不向操作系统要更多的内存,操作系统可能有自己的锁,或者更糟糕的是,它决定从硬盘中获取内存,这导致你不得不等待更久。
- 内存分配器使用的算法需要的时间无法预测。
关于内存,有一些明确的解决方案
- 预先分配所有内存
- 只在非实时线程中执行动态分配
- 预先分配一大块内存,然后实现自己的动态内存分配器,只在音频线程中调用
等待硬件或”外部“时间
你可能没有直接写等待硬件的代码,但你可能会写磁盘 I/O 相关的代码,磁盘 I/O 需要等待磁盘头找到正确的问题,这可能需要一些时间(平均 8ms)。这意味着不能再音频回调中执行文件 I/O 操作。类似的规则也适用于其他任务,例如显卡上的垂直中断同步或者网络 I/O。正如前面所说,如果你不知道这要花多长时间,那么就不要做。
总结
总结全文,我们得出在实时音频回调中执行的代码的几条经验法则:
- 不要申请或者释放内存
- 不要使用锁
- 不要进行文件读写,或者其他方式的 I/O(这包括任何 print 或者 NSLog,或者 GUI API)
- 不要调用那些可能造成阻塞的系统 api
- 不要运行那些执行时间不确定,或者最坏时间复杂度有激增的代码
- 不要调用任何有上述行为的代码
- 不要调用任何你不信任的代码
在可能的情况下,有几件事你应该做:
- 使用最坏时间复杂度友好的算法
- 在许多音频采样中摊销计算,以平滑CPU的使用,而不是使用偶尔有长处理时间的 "突发 "算法。
- 在一个非实时线程中预先分配或预先计算数据
- 采用非共享的、仅在音频回调中使用的数据结构,这样你就不需要考虑共享、并发和锁的问题。
参考资料
以上是关于实时音频编程的主要内容,如果未能解决你的问题,请参考以下文章