实时音频编程(一)

系列文章目录


简介

初入音频坑时,对于"实时音频编程"并无基本认识,也从未意识到音频编程在某些场景下是”较为特殊的“,只是觉得写出来的代码没有 bug、没有内存泄露、接口易用就已经满足要求了,至于 real-time safe 是啥,根本没听过。直到开始接触音频播放系统,才开始慢慢接触到了 real-time 的概念,周边的同事也分享了更多关于实时音频编程的知识。

网络上对于实时音频编程的内容不多,主要有:

以上两份资料之前也只是草草看过,学习了一些基本概念,最近在 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 无法满足确定性,它不是实时系统。


实时系统的分类

根据响应时间,以及错误响应时间所产生的影响,可以将实时系统分为三类:

  1. 强实时系统。强实时系统必须是对即时的事件作出反应,绝对不能错过事件处理时限。若有任务实例未在截止期限内完成,则会对系统造成不可估量的损失。例如测控领域就是要求强或接近强实时系统
  2. 准实时系统。允许系统偶尔超时,但这可能会降低系统的服务质量。但若任务超时,该任务的计算结果没有任何意义。
  3. 弱实时系统。通常是指允许任务超时,但超时后的计算结果仍有一定的意义,并且其意义随着超时时间的增加而下降。

实时音频系统

数字音频的工作原理是向声卡或音频接口的数模转换器(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 只需要两步就能进行音频播放:

  1. 编写回调函数,在回调函数中将需要播放的数据填入 Buffer 中
  2. 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 等操作,或者任何分配内存的函数,以及任何可能调用这些函数的程序。原因有三:

  1. 内存分配器可能有锁,用于同步不同线程之间的数据。
  2. 内存分配器可能不得不向操作系统要更多的内存,操作系统可能有自己的锁,或者更糟糕的是,它决定从硬盘中获取内存,这导致你不得不等待更久。
  3. 内存分配器使用的算法需要的时间无法预测。

关于内存,有一些明确的解决方案

  1. 预先分配所有内存
  2. 只在非实时线程中执行动态分配
  3. 预先分配一大块内存,然后实现自己的动态内存分配器,只在音频线程中调用

等待硬件或”外部“时间

你可能没有直接写等待硬件的代码,但你可能会写磁盘 I/O 相关的代码,磁盘 I/O 需要等待磁盘头找到正确的问题,这可能需要一些时间(平均 8ms)。这意味着不能再音频回调中执行文件 I/O 操作。类似的规则也适用于其他任务,例如显卡上的垂直中断同步或者网络 I/O。正如前面所说,如果你不知道这要花多长时间,那么就不要做。


总结

总结全文,我们得出在实时音频回调中执行的代码的几条经验法则:

  1. 不要申请或者释放内存
  2. 不要使用锁
  3. 不要进行文件读写,或者其他方式的 I/O(这包括任何 print 或者 NSLog,或者 GUI API)
  4. 不要调用那些可能造成阻塞的系统 api
  5. 不要运行那些执行时间不确定,或者最坏时间复杂度有激增的代码
  6. 不要调用任何有上述行为的代码
  7. 不要调用任何你不信任的代码

在可能的情况下,有几件事你应该做:

  1. 使用最坏时间复杂度友好的算法
  2. 在许多音频采样中摊销计算,以平滑CPU的使用,而不是使用偶尔有长处理时间的 "突发 "算法。
  3. 在一个非实时线程中预先分配或预先计算数据
  4. 采用非共享的、仅在音频回调中使用的数据结构,这样你就不需要考虑共享、并发和锁的问题。

参考资料

  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Windows 音频编程可以通过使用 Windows Multimedia API (MME) 或者 Windows Core Audio API 来实现。以下是使用 MME 的基本步骤: 1. 初始化 MME 库:使用 `waveInOpen` 或 `waveOutOpen` 函数初始化 MME 库。 2. 配置音频设备:使用 `waveInGetDevCaps` 或 `waveOutGetDevCaps` 函数获取音频设备的信息,并使用 `waveInOpen` 或 `waveOutOpen` 函数配置音频设备。 3. 读取或写入音频数据:使用 `waveInStart` 或 `waveOutWrite` 函数开始读取或写入音频数据。 4. 处理音频数据:使用相应的回调函数处理音频数据。 5. 停止音频设备:使用 `waveInStop` 或 `waveOutPause` 函数停止音频设备。 6. 关闭音频设备:使用 `waveInClose` 或 `waveOutClose` 函数关闭音频设备。 使用 Windows Core Audio API 的基本步骤如下: 1. 初始化 Core Audio API:使用 `CoInitialize` 函数初始化 Core Audio API。 2. 获取音频设备:使用 `IMMDeviceEnumerator` 接口获取音频设备。 3. 配置音频设备:使用 `IAudioClient` 接口配置音频设备。 4. 读取或写入音频数据:使用 `IAudioCaptureClient` 或 `IAudioRenderClient` 接口读取或写入音频数据。 5. 处理音频数据:使用相应的回调函数处理音频数据。 6. 停止音频设备:使用 `IAudioClient` 接口停止音频设备。 7. 关闭音频设备:使用 `IAudioClient` 接口关闭音频设备。 以上是 Windows 音频编程的基本步骤,具体实现需要根据具体需求进行调整。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值