在上篇文章中,我们使用的是 Android 自带的 AudioTrack 来音频播放的,现在我们要切换成 OpenSL ES 来播放,那为什么要换成这个呢 ?
一个方面是因为 OpenSL ES 性能会好一些,且都是在 Native 中处理的,而 AudioTrack 是在 Android Java 层处理的,交互涉及到 Jni,相互之间调用也比较麻烦,性能也有额外开销(基本很小)
所以本篇来使用 OpenSL ES 来播放
OpenSL ES 是什么?
OpenSL ES 是一个针对嵌入式系统的开放硬件音频加速库,也可以将其视为一套针对嵌入式平台的音频标准,全称为: Open Sound Library for Embedded Systems ,它提供了一套高性能、 低延迟的音频功能实现方法,并且实现了软硬件音频性能的跨平台部署,大大降低了上层处理音频应用的开发难度
在 Android 开发中,Google 官方从 Android 2.3 (API 9)开始,便支持了 OpenSL ES 标准 ,并且对其进行了扩展。
示例
可以看下 android 中文官网。里面也有一些demo
OpenSL ES 的一些基本概念
OpenSL ES 是基于 c 语言实现的,但其提供的接口是采用面向对象的方式实现,OpenSL ES 的大多数 API 是通过对象来调用的。一般都会通过 object 来调用 Realize 实例化对象,然后通过 GetInterface 获取对应接口的能力,一般都是这样配套使用的
对象和接口概念
Object 和 Interface OpenSL ES 中的两大基本概念,可以类比为 Java 中的对象和接口。
- Objects (对象):
- Objects 是 OpenSL ES 中表示音频对象或组件的实例。每个 Object 都实现了一个或多个接口 (Interfaces), 并且具有自己的生命周期
常见的 Objects 包括:
- Player Object: 用于音频播放
- AudioRecorder Object: 用于音频录制
- OutputMix Object: 用于混合多个音频流输出
- MIDI Object: 用于 MIDI 音乐播放
Interfaces (接口):
- Interfaces 定义了 Objects 所支持的功能集合。每个 Interface 由一组预定义的方法(函数)和数据结构组成,Objects 需要实现这些接口才能提供相应的功能
常见的 Interfaces 包括:
- Player Interface: 定义了音频播放的基本功能, 如播放、暂停、停止等
- AudioRecorder Interface: 定义了音频录制的基本功能
- EngineInterface: 定义了 OpenSL ES 引擎的配置和控制功能
- MetadataTraversal Interface: 定义了访问和操作元数据的功能
使用流程
先简单介绍一下整体的流程,本文还是使用 ffmpeg 来进行解码,只是将解码好的数据给 OpenSL ES,有一些流程基本和 ffmpeg + audioTrack 的使用区别不大,不过本文使用了多线程的技术,一边加载压缩数据,一边进行播放,
首先还是在 Java 层级,将 url 传入到 native 层,区别是这里是异步加载了,等待加载好了,然后再去调用 play 方法,非同步行为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
class Player {
companion object {
init { System.loadLibrary("player") } // 加载 so 库
}
private var url: String = ""
// ......
fun setDataSource(url: String) { this.url = url }
fun play() { nPlay(url) }
private external fun nPlay(url: String)
fun prepare() {
if (url.isBlank()) error("url is null")
nPrepare(url) // 准备
}
private external fun nPrepare(url: String)
fun prepareAsync() {
if (url.isBlank()) error("url is null")
nPrepareAsync(url) // 准备
}
private external fun nPrepareAsync(url: String)
}
// MainActivity 类
class MainActivity : ComponentActivity() {
private val play by lazy { Player() }
private val mMusicFile: File = File(Environment.getExternalStorageDirectory(), "input.mp3")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
XXPermissions.with(this)
.permission(Permission.MANAGE_EXTERNAL_STORAGE)
.request { permissions, allGranted -> }
PlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
play.setDataSource(mMusicFile.absolutePath)
play.setOnPrepareListener(object : MediaPreparedListener {
override fun onPrepared() {
play.play()
}
})
play.prepareAsync()
}
}
}
}
}
|
然后到了 native 层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
/** 异步去准备 **/
extern "C"
JNIEXPORT void JNICALL
Java_com_fang_player_Player_nPrepareAsync(JNIEnv *env, jobject instance, jstring url_) {
//获取 java 中的字符串,jstring 转换回 c 中的字符来使用
const char *url = env->GetStringUTFChars(url_, nullptr);
if (fFmpegHelper == nullptr) {
myJniCall = new MyJNICall(pJavaVM, env, instance);
fFmpegHelper = new FFmpegHelper(myJniCall, url); // 注意这里的方法执行完成了,栈内存就释放了,url 就会被释放掉,所以内部如果使用到 url 的话,需要重新分配内存
fFmpegHelper->prepareAsync(); // 准备
}
env->ReleaseStringUTFChars(url_, url);
}
|
这里创建了 myJniCall 和 fFmpegHelper 对象,其两个对象的构造中都只是储存了一些引用,并没有太多逻辑,不再展示,然后调用的 prepareAsync 方法
1
2
3
4
5
6
7
8
9
10
11
12
|
void *threadPrepare(void *context) {
auto *pFFmpeg = (FFmpegHelper *) context;
pFFmpeg->prepare(THREAD_CHILD);
return nullptr;
}
void FFmpegHelper::prepareAsync() {
// 创建一个线程去播放,多线程编解码边播放
pthread_t prepareThreadT;
pthread_create(&prepareThreadT, nullptr, threadPrepare, this);
pthread_detach(prepareThreadT);
}
|
然后在 prepareAsync 方法中,创建了线程,在子线程中,创建了 ffmpeg 相关实例
初始化 FFmpeg
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
void FFmpegHelper::prepare(ThreadMode threadMode) {
av_register_all(); // 初始化所有的辩解码器
avformat_network_init();
int formatOpenInputRes = 0;
int formatFindStreamInfoRes = 0;
formatOpenInputRes = avformat_open_input(&pFormatContext, url, nullptr, nullptr);
// 第一步
if (formatOpenInputRes != 0) { // 打开读取文件失败
// 失败,需要告知 java 层,需要释放流资源
LOGE("format open input error: %s", av_err2str(formatOpenInputRes));
callPlayerJniError(threadMode, formatOpenInputRes, av_err2str(formatOpenInputRes));
return; // 因为 return 了,之前打开的相关操作,需要释放
}
// 第二部,读取流信息,并将读取到的信息,封装在 pFormatContext 中
formatFindStreamInfoRes = avformat_find_stream_info(pFormatContext, NULL);
if (formatFindStreamInfoRes < 0) {
LOGE("format find stream info error: %s", av_err2str(formatFindStreamInfoRes));
callPlayerJniError(threadMode, formatFindStreamInfoRes, av_err2str(formatFindStreamInfoRes));
return;
}
// 第三部:查找音频流的 index, 为什么要找 ?因为一个文件中,含有多一个流
int audioStreamIndex = av_find_best_stream(pFormatContext, AVMediaType::AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
if (audioStreamIndex < 0) {
LOGE("format audio stream error: %d, %s", audioStreamIndex, av_err2str(audioStreamIndex));
callPlayerJniError(threadMode, audioStreamIndex, av_err2str(audioStreamIndex));
return;
}
pAudio = new AudioManager(audioStreamIndex, pJniCall, pFormatContext);
pAudio->analysisStream(threadMode, pFormatContext->streams);
// ---------- 重采样 end ----------
// 回调到 Java 告诉他准备好了
pJniCall->callPlayerPrepared(threadMode);
}
|
然后最后一步,通过 Jni 的调用,告知 Java 层准备好了,在上面的代码中,我们可以知道,会调用 play 方法,可以开始播放了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
void AudioManager::play() {
// 多线程,边解码边播放
// 一个线程不断的加载数据,一个线程不断的读取解码播放
// 加载压缩数据
pthread_t readPacketThreadT;
pthread_create(&readPacketThreadT, nullptr, threadReadPacket, this);
pthread_detach(readPacketThreadT);
// 解码数据
pthread_t playThreadT;
pthread_create(&playThreadT, nullptr, threadPlay, this);
pthread_detach(playThreadT);
}
|
这里从代码就可以看的出来,创建了两个线程,一个线程不断的加载数据,一个线程不断的读取解码播放,为什么这么做呢?假设说我们只有一个线程去处理的话,要做的事情是压缩数据,然后解码数据去播放,逻辑是加载一帧的数据,然后就去解码播放,同步行为,压力较大,可能会造成,播放端的视频已经播放完成了,而可能会遇到加载数据可能并没有准备好数据的情况(网络拥堵)。声音卡顿,所以我们需要使用两个线程,加载数据和解码,各自的线程去处理,这样压力就会小很多,甚至是加载的数据可以提前缓存一些,需要快进,播放流畅性都会更好。所以我们会使用到队列去缓存数据
缓存队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
PacketQueue::PacketQueue() {
// 创建对象
pPacketQueue = new std::queue<AVPacket *>;
pthread_mutex_init(&packetMutex, nullptr);
pthread_cond_init(&packetCond, nullptr);
}
PacketQueue::~PacketQueue() {
if (pPacketQueue != nullptr) {
clear();
delete pPacketQueue;
pPacketQueue = nullptr;
}
pthread_mutex_destroy(&packetMutex);
pthread_cond_destroy(&packetCond);
}
void PacketQueue::push(AVPacket *packet) {
pthread_mutex_lock(&packetMutex);
pPacketQueue->push(packet);
pthread_cond_signal(&packetCond); // 有数据了,通知另外一个线程可以读取, 因为 pop 是阻塞的,可能获取为空的时候,阻塞住了
pthread_mutex_unlock(&packetMutex);
}
AVPacket *PacketQueue::pop() {
AVPacket *pPacket;
pthread_mutex_lock(&packetMutex);
while (pPacketQueue->empty()) { // 如果为空,阻塞在这里
pthread_cond_wait(&packetCond, &packetMutex);
}
pPacket = pPacketQueue->front(); // 取队列最前面的那个流数据
// 出队
pPacketQueue->pop();
pthread_mutex_unlock(&packetMutex);
return pPacket;
}
void PacketQueue::clear() {
pthread_mutex_lock(&packetMutex); // 操作队列数据,防止其他线程在别的地方操作
if (pPacketQueue) {
while (!pPacketQueue->empty()) {
AVPacket* packet = pPacketQueue->front();
av_packet_free(&packet); // 释放
pPacketQueue->pop(); // 出队
}
}
pthread_mutex_unlock(&packetMutex);
}
int PacketQueue::getSize() const {
return pPacketQueue->size();
}
|
注意这里的缓存队列是利用 queue 来缓存 AVPacket 数据的,使用到了 packetMutex 和 packetCond,其实就像 Java 中的 Lock 锁,还有唤醒锁的行为,在对数据进行增删改查的时候,防止多线程去同时操作数据而发生数据错乱了!
子线程加载压缩数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
void *threadReadPacket(void *context) { // 加载压缩数据
auto *audio = (AudioManager *) context;
const int MAX_QUEUE_SIZE = 600; // 设置最大队列大小
while (audio->pPlayerStatus != nullptr && !audio->pPlayerStatus->isExit) {
// 检查队列大小
if (audio->pPacketQueue->getSize() >= MAX_QUEUE_SIZE) { // 固定大小不合适,这里只是一种思路,生产环境不一定是这样
LOGE("队列已满,暂停加载");
usleep(100000); // 暂停100毫秒
continue;
}
// 开始解码
AVPacket *pPacket = av_packet_alloc();
if (av_read_frame(audio->pFormatContext, pPacket) >= 0) {
if (pPacket->stream_index == audio->audioStreamIndex) { // 判断是否是音频流,因为一个文件中,可能会有多个流
audio->pPacketQueue->push(pPacket);
} else {
// 1. 解引用数据 data , 2. 销毁 pPacket 结构体内存 3. pPacket = NULL
av_packet_free(&pPacket);
}
} else {
// 1. 解引用数据 data , 2. 销毁 pPacket 结构体内存 3. pPacket = NULL
av_packet_free(&pPacket);
}
}
return nullptr;
}
|
这里的代码就是使用 ffmpeg 通过 av_read_frame 去加载 AVPacket 数据了,然后将加载好的数据放在 pPacketQueue 队列当中,后续如果要播放的话,就去这个队列里面取,这里做了一个逻辑是假定了一个队列的阈值,当缓存了一定的数量后,就睡眠一会不再去加载压缩数据了,一个原因是我们播放是比较慢的,而且我们不一定能够播放完整个音频,如果不设置的话,这个线程一直不断的在加载,可能整个音频都已经被你加载完了,如果是使用流量的话,那耗费的流量比较多了,再一个是耗费 CPU 资源,所以我们只提前缓存一点点,能够保证流畅播放就行
这里只是在测试环境中,一种很粗陋的优化,实际在生产环境中,问了一下 OpenAi 可能会有更好的方案,比如
- 自适应缓冲区大小
- 许多现代流媒体系统使用自适应缓冲区大小。这意味着缓冲区大小会根据网络条件、设备性能和用户行为动态调整
- 时间基础的缓冲
- 与其使用固定数量的数据包,更常见的做法是基于时间来设置缓冲区。例如,缓冲30秒或60秒的内容。这样可以更好地适应不同比特率的流
- 分段加载
- 大多数流媒体协议(如HLS、DASH)使用分段加载。内容被分割成小段(通常是几秒到几分钟长),客户端一次只加载几个段
- 预测性缓冲
- 一些高级系统会预测用户行为。例如,如果用户经常跳过片头,系统可能会减少对片头的缓冲
- 多级缓冲
- 实现一个小的前端缓冲区用于即时播放,和一个较大的后端缓冲区用于平滑播放
- 网络条件适应
- 根据网络带宽和延迟调整缓冲策略。在网络条件好的时候加载更多,条件差时减少加载
更加具体的优化方案就不展开了,到了用到的时候再说吧,然后是另外一个线程去解码了
子线程解码数据播放
以上的逻辑我们使用了 ffmpeg 解封装了数据,到了这一步我们只需要将数据解码,然后丢给 OpenSL ES 就可以了,所以我们还有 2 个流程,初始化 OpenSL ES 和使用 ffmpeg 解码数据,首先看下 OpenSL ES 的初始化
OpenSL ES 的创建初始化流程,在网上找了一张图,基本上是根据如下图流程进行创建,涉及到代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
// 初始化 OpenSLES
void AudioManager::initCrateOpenSLES() {
// 3.1 创建引擎接口对象
SLObjectItf engineObject = nullptr;
SLEngineItf engineEngine;
// 创建引擎对象
slCreateEngine(&engineObject, 0, nullptr, 0, nullptr, nullptr);
// 实例化
(*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
// // 获取引擎对象接口
(*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineEngine);
// 3.2 设置混音器
static SLObjectItf outputMixObject = nullptr;
const SLInterfaceID ids[1] = {SL_IID_ENVIRONMENTALREVERB};
const SLboolean req[1] = {SL_BOOLEAN_FALSE};
(*engineEngine)->CreateOutputMix(engineEngine, &outputMixObject, 1, ids, req);
// 初始化混音器
(*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);
SLEnvironmentalReverbItf outputMixEnvironmentalReverb = nullptr;
(*outputMixObject)->GetInterface(outputMixObject, SL_IID_ENVIRONMENTALREVERB,
&outputMixEnvironmentalReverb);
SLEnvironmentalReverbSettings reverbSettings = SL_I3DL2_ENVIRONMENT_PRESET_STONECORRIDOR;
(*outputMixEnvironmentalReverb)->SetEnvironmentalReverbProperties(outputMixEnvironmentalReverb,
&reverbSettings);
// 3.3 创建播放器
SLObjectItf pPlayer = nullptr;
SLPlayItf pPlayItf = nullptr;
// 数据源简单缓冲队列定位器
SLDataLocator_AndroidSimpleBufferQueue simpleBufferQueue = {
SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 2};
SLDataFormat_PCM formatPcm = {
SL_DATAFORMAT_PCM, // 格式类型
2, // 通道数
SL_SAMPLINGRATE_44_1, //采样率
SL_PCMSAMPLEFORMAT_FIXED_16, // 位宽
SL_PCMSAMPLEFORMAT_FIXED_16,
SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT, // 通道屏蔽
SL_BYTEORDER_LITTLEENDIAN}; // 字节顺序
// 数据源
SLDataSource audioSrc = {&simpleBufferQueue, &formatPcm};
// 针对数据接收器的输出混合定位器(混音器)
SLDataLocator_OutputMix outputMix = {SL_DATALOCATOR_OUTPUTMIX /*定位器类型*/, outputMixObject /*输出混合*/};
// 输出
SLDataSink audioSnk = {&outputMix, nullptr};
//需要的接口 操作队列的接口
// SL_IID_BUFFERQUEUE表示缓冲区队列接口,用于传入待播放的音频数据
// SL_IID_VOLUME表示音量控制接口,用于设置播放音量
// SL_IID_PLAYBACKRATE表示播放速率接口,用于控制播放速度
SLInterfaceID interfaceIds[3] = {SL_IID_BUFFERQUEUE, SL_IID_VOLUME, SL_IID_PLAYBACKRATE}; // 如果想要环绕立体声音,可以增加 SL_IID_SURROUND
// 定义了一个包含3个布尔值的数组,表示上面三个接口是否为必需接口。这里全部设置为SL_BOOLEAN_TRUE,表示这三个接口都是必需的
SLboolean interfaceRequired[3] = {SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE}; // 也可以设置 false,设置 false 的话,表示的意思就是,设置false即使创建失败了,也可以继续创建播放器对象
// 创建播放器
(*engineEngine)->CreateAudioPlayer(engineEngine, &pPlayer, &audioSrc, &audioSnk, 3,
interfaceIds, interfaceRequired);
// 实现(初始化)刚创建的播放器对象。
(*pPlayer)->Realize(pPlayer, SL_BOOLEAN_FALSE);
// 获取播放器对象的Play接口,用于控制播放器的播放、暂停等操作
(*pPlayer)->GetInterface(pPlayer, SL_IID_PLAY, &pPlayItf);
// 3.4 设置缓存队列和回调函数
SLAndroidSimpleBufferQueueItf playerBufferQueue;
(*pPlayer)->GetInterface(pPlayer, SL_IID_BUFFERQUEUE, &playerBufferQueue);
(*playerBufferQueue)->RegisterCallback(playerBufferQueue, playerCallback, this);
// 3.5 设置播放状态
(*pPlayItf)->SetPlayState(pPlayItf, SL_PLAYSTATE_PLAYING);
// 3.6 调用回调函数 - 手动激活回调函数
playerCallback(playerBufferQueue, this);
}
|
这里初始化的就是最先开始提到的,每个 object 需要使用 Realize 实例化,然后通过 GetInterface 获取接口的能力,最后需要注意的是需要手动调用 playerCallback 激活回调函数,这里好像是比较奇怪了,一般 Java 只需要设置回调函数,好像并没有手动激活这么一说
回调函数
1
2
3
4
5
6
7
8
|
void playerCallback(SLAndroidSimpleBufferQueueItf caller, void *pContext) { // void *pContext 是一个指针,传入的就是 AudioManager
if (pContext != nullptr) {
auto *pFFmepg = (AudioManager *) pContext;
int dataSize = pFFmepg->resampleAudio(); // 解码,去播放,dataSize 是一帧的大小
(*caller)->Enqueue(caller, pFFmepg->resampleOutBuffer, dataSize); // 去播放
}
}
|
手动激活回调函数后,就会被调用到这个 playerCallback 方法中,这个方法中就是剩下的解码了,通过 resampleAudio 方法去解码,然后得到的数据通过 caller 调用 Enqueue() 方法,这个就是丢给了 OpenSL ES 了,注意这里当播放完成后,会再次回调这个方法解码播放,一直这样重复,所以它能够播放,然后看看解码的方法做了什么
解码音频数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
int AudioManager::resampleAudio() {
int dataSize = 0;
AVPacket *pPacket = nullptr;// 压缩数据
AVFrame *pFrame = av_frame_alloc(); // 解码出来的数据
while (pPlayerStatus != nullptr && !pPlayerStatus->isExit) {
pPacket = pPacketQueue->pop(); // 取压缩的数据,去解码
int codecSendPacketRes = avcodec_send_packet(pCodecContext, pPacket); // 将压缩数据发送给解码器
if (codecSendPacketRes == 0) {
int codecReceiveFrameRes = avcodec_receive_frame(pCodecContext, pFrame);
if (codecReceiveFrameRes == 0) {
// 第一个参数: 分配好的 SwrContext 上下文,它包含了转换操作所需的所有参数(如输入输出的声道布局、采样格式和采样率
// 第二个参数: 输出缓冲区,用来存储转换后的音频数据。如果是打包音频数据,则只需要设置第一个缓冲区
// 第三个参数:每个声道中可用的输出空间,单位为样本数。例如,如果你希望输出 1024 个样本,你将设置 out_count 为 1024。
// 第四个参数:输入缓冲区,包含待转换的音频数据。如果打包音频数据,则只需要设置第一个缓冲区。
// 第五个参数:每个声道中可用的输入样本数。这代表待转换音频数据的数量。
// 返回的是实际转换的输出样本数
// 计算一帧的大小: 总大小=样本数×每个样本的字节数×通道数
dataSize = swr_convert(swrContext, &resampleOutBuffer, pFrame->nb_samples,
(const uint8_t **) pFrame->data, pFrame->nb_samples);
dataSize = dataSize * 2 * 2; // 一帧的大小, 解释:2 * 2 表示的是 每个样本的字节数×通道数(或者叫声道数,比如说立体声就是2)
break;
}
}
// 解引用
av_packet_unref(pPacket);
av_frame_unref(pFrame);
}
// 1. 解引用数据 data , 2. 销毁 pPacket 结构体内存 3. pPacket = NULL
av_packet_free(&pPacket);
av_frame_free(&pFrame);
return dataSize;
}
|
这里的步骤就是使用 ffmpeg 去解码数据,然后重采样,至于重采样,可以看下上篇文章的解释,然后计算好的 dataSize 返回给 Enqueue,这里的 dataSize 是一帧的大小,如果少了或者多了,可能会导致播放不完整,最后丢给 OpenSL ES 就能够正常播放了。好了,整体的核心的代码就是这些,其他的一些相关代码如果有需要多可以看下 github 项目。
参考:
https://cloud.tencent.com/developer/article/1832836
https://blog.csdn.net/ta893115871/article/details/117650780