OpenSL ES 编程注意事项

警告:OpenSL ES 已弃用。开发者应使用开源 Oboe 库,该库可在 GitHub 上获取。Oboe 是一个 C++ 封装器,提供了与 AAudio 非常相似的 API。当 AAudio 可用时,Oboe 会调用 AAudio;如果 AAudio 不可用,则会回退到 OpenSL ES。

本节中的注意事项是对 OpenSL ES 1.0.1 规范的补充。

对象和接口初始化

OpenSL ES 编程模型的两个方面,对于新开发者来说可能不太熟悉,它们是对象和接口之间的区别,以及初始化顺序。

简单来说,OpenSL ES 对象类似于 Java 和 C++ 等编程语言中的对象概念,不同之处在于 OpenSL ES 对象只能通过其关联的接口来访问。这包括所有对象的初始接口,称为 SLObjectItf。对象本身没有句柄,只有对象的 SLObjectItf 接口的句柄。

OpenSL ES 对象首先被创建,这会返回一个 SLObjectItf,然后被实现 (realized)。这类似于常见的编程模式:首先构造对象(除了内存不足或参数无效外,这不应该失败),然后完成初始化(这可能会因资源不足而失败)。“实现”步骤为实现层提供了一个逻辑位置,以便在需要时分配额外的资源。

作为创建对象 API 的一部分,应用指定了一个它计划稍后获取的所需接口数组。请注意,此数组不会自动获取接口;它仅仅表示将来有意获取它们。接口分为隐式显式。如果稍后需要获取显式接口,则必须将其列在数组中。隐式接口无需列在对象创建数组中,但将其列出也没有害处。OpenSL ES 还有另一种接口称为动态接口,它无需在对象创建数组中指定,可以在对象创建后稍后添加。Android 实现提供了一个方便的功能来避免这种复杂性,如在对象创建时使用动态接口中所述。

对象创建并实现后,应用应使用初始 SLObjectItf 上的 GetInterface 来获取所需各项功能的接口。

最后,对象可通过其接口使用,但请注意,某些对象需要进一步设置。特别是,具有 URI 数据源的音频播放器需要更多的准备工作来检测连接错误。有关详情,请参阅下文的音频播放器预取部分。

应用使用完对象后,应明确销毁它;请参阅下文的销毁部分。

音频播放器预取

对于具有 URI 数据源的音频播放器,Object::Realize 会分配资源,但不会连接到数据源(准备)或开始预取数据。这些操作会在播放器状态设置为 SL_PLAYSTATE_PAUSEDSL_PLAYSTATE_PLAYING 后发生。

在此序列的相对后期,某些信息可能仍未知。特别是,最初 Player::GetDuration 返回 SL_TIME_UNKNOWN,而 MuteSolo::GetChannelCount 要么成功返回零通道数,要么返回错误结果 SL_RESULT_PRECONDITIONS_VIOLATED。一旦这些信息已知,这些 API 就会返回正确的值。

其他最初未知的属性包括采样率以及通过检查内容标头确定的实际媒体内容类型(与应用指定的 MIME 类型和容器类型相反)。这些信息也在准备/预取期间稍后确定,但没有 API 可以检索它们。

预取状态接口对于检测所有信息何时可用非常有用,或者你的应用可以定期轮询。请注意,某些信息,例如流式传输 MP3 的时长,可能永远无法得知。

预取状态接口也适用于检测错误。注册回调并至少启用 SL_PREFETCHEVENT_FILLLEVELCHANGESL_PREFETCHEVENT_STATUSCHANGE 事件。如果这两个事件同时发生,并且 PrefetchStatus::GetFillLevel 报告级别为零,同时 PrefetchStatus::GetPrefetchStatus 报告 SL_PREFETCHSTATUS_UNDERFLOW,则表明数据源发生了不可恢复的错误。这包括无法连接到数据源,因为本地文件名不存在或网络 URI 无效。

下一个版本的 OpenSL ES 预计会增加对处理数据源错误的更明确支持。然而,为了将来的二进制兼容性,我们打算继续支持当前报告不可恢复错误的方法。

总而言之,推荐的代码序列如下:

  1. Engine::CreateAudioPlayer
  2. Object:Realize
  3. Object::GetInterface 用于获取 SL_IID_PREFETCHSTATUS
  4. PrefetchStatus::SetCallbackEventsMask
  5. PrefetchStatus::SetFillUpdatePeriod
  6. PrefetchStatus::RegisterCallback
  7. Object::GetInterface 用于获取 SL_IID_PLAY
  8. Play::SetPlayState 设置为 SL_PLAYSTATE_PAUSEDSL_PLAYSTATE_PLAYING

注意: 准备和预取在此处发生;在此期间,你的回调函数会收到周期性的状态更新。

销毁

退出应用时,请务必销毁所有对象。对象应按创建顺序的逆序销毁,因为销毁具有任何依赖对象的对象是不安全的。例如,按以下顺序销毁:音频播放器和录音机、输出混音器,最后是引擎。

OpenSL ES 不支持自动垃圾回收或接口的引用计数。调用 Object::Destroy 后,所有源自关联对象的现有接口都将变为未定义。

Android OpenSL ES 实现不会检测此类接口的错误使用。在对象销毁后继续使用此类接口可能会导致你的应用崩溃或行为不可预测。

我们建议你在对象销毁序列中明确地将主对象接口及所有关联接口设置为 NULL,这可以防止意外误用过时的接口句柄。

立体声声像调整

当使用 Volume::EnableStereoPosition 启用单声道源的立体声声像调整时,总声功率级会降低 3 dB。这是必需的,以便在声源从一个通道调整到另一个通道时总声功率级保持恒定。因此,仅在你需要时才启用立体声定位。有关更多信息,请参阅维基百科关于音频声像调整的文章。

回调和线程

回调处理程序通常在实现检测到事件时同步调用。这一点相对于应用是异步的,因此你应该使用非阻塞同步机制来控制对应用和回调处理程序之间共享的任何变量的访问。在示例代码中,例如对于缓冲区队列,我们为了简单起见要么省略了这种同步,要么使用了阻塞同步。然而,适当的非阻塞同步对于任何生产代码都至关重要。

回调处理程序从未附加到 Android 运行时的内部非应用线程调用,因此它们不适合使用 JNI。由于这些内部线程对于 OpenSL ES 实现的完整性至关重要,回调处理程序也不应阻塞或执行过多工作。

如果你的回调处理程序需要使用 JNI 或执行与回调不相关的任务,处理程序应该改为发布事件供另一个线程处理。可接受的回调工作负载示例包括渲染和将下一个输出缓冲区入队(对于 AudioPlayer),处理刚刚填满的输入缓冲区并入队下一个空缓冲区(对于 AudioRecorder),或者简单的 API,例如大部分 Get 系列。关于工作负载,请参阅下文的性能部分。

请注意,相反的情况是安全的:已进入 JNI 的 Android 应用线程可以直接调用 OpenSL ES API,包括那些会阻塞的 API。但是,不建议在主线程中进行阻塞调用,因为它们可能导致应用无响应 (ANR)。

关于调用回调处理程序的线程的确定,很大程度上取决于实现。这种灵活性的原因是为了允许未来的优化,特别是在多核设备上。

回调处理程序运行所在的线程不保证在不同的调用中具有相同的身份。因此,不要依赖 pthread_self() 返回的 pthread_tgettid() 返回的 pid_t 在不同调用中保持一致。出于同样的原因,不要从回调中调用线程局部存储 (TLS) API,例如 pthread_setspecific()pthread_getspecific()

实现保证同一对象、同一类型的回调不会并发发生。但是,同一对象、不同类型的回调可能在不同线程上并发发生。

性能

由于 OpenSL ES 是一个原生 C API,调用 OpenSL ES 的非运行时应用线程没有与运行时相关的开销,例如垃圾回收暂停。除了下文描述的一个例外情况外,使用 OpenSL ES 没有其他额外的性能优势。特别是,使用 OpenSL ES 并不能保证比平台通常提供的更低的音频延迟和更高的调度优先级等增强功能。另一方面,随着 Android 平台和特定设备实现的不断发展,OpenSL ES 应用有望受益于未来任何系统性能的改进。

其中一项演进是支持降低音频输出延迟。降低输出延迟的基础功能首次包含在 Android 4.1 (API level 16) 中,随后在 Android 4.2 (API level 17) 中持续改进。对于宣称支持功能 android.hardware.audio.low_latency 的设备实现,这些改进可通过 OpenSL ES 使用。如果设备未宣称支持此功能,但支持 Android 2.3 (API level 9) 或更高版本,则仍可使用 OpenSL ES API,但输出延迟可能更高。仅当应用请求与设备原生输出配置兼容的缓冲区大小和采样率时,才会使用较低的输出延迟路径。这些参数是设备特定的,应按如下所述获取。

从 Android 4.2 (API level 17) 开始,应用可以查询设备主输出流的平台原生或最优输出采样率和缓冲区大小。结合前面提到的功能测试,应用现在可以根据支持低延迟的设备适当配置自身,以实现更低的输出延迟。

对于 Android 4.2 (API level 17) 及更早版本,需要两个或更多缓冲区计数才能实现更低的延迟。从 Android 4.3 (API level 18) 开始,一个缓冲区计数就足以实现更低的延迟。

所有用于输出效果的 OpenSL ES 接口都排除了较低延迟路径。

推荐的序列如下:

  1. 检查 API level 是否为 9 或更高,以确认是否可以使用 OpenSL ES。
  2. 使用以下代码检查 android.hardware.audio.low_latency 功能:

    Kotlin

    import android.content.pm.PackageManager
    ...
    val pm: PackageManager = context.packageManager
    val claimsFeature: Boolean = pm.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY)

    Java

    import android.content.pm.PackageManager;
    ...
    PackageManager pm = getContext().getPackageManager();
    boolean claimsFeature = pm.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY);
  3. 检查 API level 是否为 17 或更高,以确认是否可以使用 android.media.AudioManager.getProperty()
  4. 使用以下代码获取此设备主输出流的原生或最优输出采样率和缓冲区大小:

    Kotlin

    import android.media.AudioManager
    ...
    val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
    val sampleRate: String = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)
    val framesPerBuffer: String = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)

    Java

    import android.media.AudioManager;
    ...
    AudioManager am = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
    String sampleRate = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
    String framesPerBuffer = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER);
    请注意,sampleRateframesPerBuffer字符串。首先检查是否为 null,然后使用 Integer.parseInt() 转换为 int。
  5. 现在,使用 OpenSL ES 创建一个使用 PCM 缓冲区队列数据定位器的 AudioPlayer。

注意:你可以使用 音频缓冲区大小测试应用来确定音频设备上 OpenSL ES 音频应用的原生缓冲区大小和采样率。你还可以访问 GitHub 查看 audio-buffer-size 示例。

低延迟音频播放器的数量有限。如果你的应用需要多个音频源,请考虑在应用层面进行音频混合。请务必在 Activity 暂停时销毁音频播放器,因为它们是与其他应用共享的全局资源。

为避免可听见的故障,缓冲区队列回调处理程序必须在小的、可预测的时间窗口内执行。这通常意味着不会在互斥量、条件变量或 I/O 操作上进行无限期阻塞。相反,考虑使用尝试锁定 (try locks)、带有超时的锁定和等待,以及非阻塞算法

对于 AudioPlayer,渲染下一个缓冲区所需的计算;或者对于 AudioRecord,处理上一个缓冲区所需的计算,在每次回调中应花费大致相同的时间。避免使用执行时间不确定或计算具有突发性的算法。如果任何给定回调中花费的 CPU 时间显著大于平均值,则回调计算是突发性的。总之,理想情况下,处理程序的 CPU 执行时间方差接近于零,并且处理程序不会无限期阻塞。

只有以下输出才可能实现较低延迟音频:

  • 设备扬声器。
  • 有线耳机。
  • 有线头戴式耳机。
  • 线路输出。
  • USB 数字音频.

在某些设备上,由于扬声器校正和保护的数字信号处理,扬声器延迟高于其他路径。

从 Android 5.0 (API Level 21) 开始,部分设备支持较低延迟音频输入。要利用此功能,请首先确认是否如上所述提供了较低延迟输出。较低延迟输出功能是较低延迟输入功能的先决条件。然后,创建一个 AudioRecorder,其采样率和缓冲区大小与输出相同。OpenSL ES 输入效果接口排除了较低延迟路径。要实现较低延迟,必须使用录音预设 SL_ANDROID_RECORDING_PRESET_VOICE_RECOGNITION;此预设会禁用可能增加输入路径延迟的设备特定数字信号处理。有关录音预设的更多信息,请参阅上文的Android 配置接口部分。

对于同时输入和输出,两端使用单独的缓冲区队列完成处理程序。即使两端使用相同的采样率,也无法保证这些回调的相对顺序或音频时钟的同步。你的应用应通过适当的缓冲区同步来缓冲数据。

潜在的独立音频时钟带来的一个后果是需要异步采样率转换。一种简单(尽管不利于音频质量)的异步采样率转换技术是在零交点附近根据需要复制或丢弃样本。更复杂的转换也是可能的。

性能模式

从 Android 7.1 (API Level 25) 开始,OpenSL ES 引入了一种指定音频路径性能模式的方法。选项如下:

  • SL_ANDROID_PERFORMANCE_NONE:无特定性能要求。允许使用硬件和软件效果。
  • SL_ANDROID_PERFORMANCE_LATENCY:优先考虑延迟。无硬件或软件效果。这是默认模式。
  • SL_ANDROID_PERFORMANCE_LATENCY_EFFECTS:在允许硬件和软件效果的同时,优先考虑延迟。
  • SL_ANDROID_PERFORMANCE_POWER_SAVING:优先考虑节省电量。允许使用硬件和软件效果。

注意:如果你不需要低延迟路径,并且希望利用设备的内置音频效果(例如改善视频播放的音质),则必须明确将性能模式设置为 SL_ANDROID_PERFORMANCE_NONE

要设置性能模式,必须使用 Android 配置接口调用 SetConfiguration,如下所示:

  // Obtain the Android configuration interface using a previously configured SLObjectItf.
  SLAndroidConfigurationItf configItf = nullptr;
  (*objItf)->GetInterface(objItf, SL_IID_ANDROIDCONFIGURATION, &configItf);

  // Set the performance mode.
  SLuint32 performanceMode = SL_ANDROID_PERFORMANCE_NONE;
    result = (*configItf)->SetConfiguration(configItf, SL_ANDROID_KEY_PERFORMANCE_MODE,
                                                     &performanceMode, sizeof(performanceMode));

安全和权限

就谁能做什么而言,Android 中的安全是在进程级别完成的。Java 编程语言代码不能比原生代码做更多的事情,原生代码也不能比 Java 编程语言代码做更多的事情。它们之间唯一的区别是可用的 API。

使用 OpenSL ES 的应用必须请求与类似的非原生 API 所需的权限。例如,如果你的应用录制音频,则需要 android.permission.RECORD_AUDIO 权限。使用音频效果的应用需要 android.permission.MODIFY_AUDIO_SETTINGS。播放网络 URI 资源的应用需要 android.permission.NETWORK。有关更多信息,请参阅处理系统权限

根据平台版本和实现情况,媒体内容解析器和软件编解码器可能在调用 OpenSL ES 的 Android 应用的上下文中运行(硬件编解码器是抽象的,但依赖于设备)。旨在利用解析器和编解码器漏洞的恶意内容是一种已知的攻击向量。我们建议你仅播放来自可信来源的媒体,或者将你的应用进行分区,以便处理来自不可信来源媒体的代码在相对沙盒化的环境中运行。例如,你可以将来自不可信来源的媒体在单独的进程中处理。尽管这两个进程仍将在同一 UID 下运行,但这种分离确实使攻击变得更加困难。