在之前的文章中,我们已经编译好了 ffmpeg so 库了,本章中,我们开始使用它进行编解码音频播放,需要先了解一些前置知识

音视频前置知识和之前写的一些 C 代码

其实,简单总结的概括就是,我们要播放的音频文件,是一个压缩封装好的一种音频文件,需要对他进行解封装,这一步的目的就是查看音频文件格式,码率,一些解码规则和算法等信息,然后对其进行解码, 然后重采样,然后将解码好的数据丢给硬件去播放,大概就是这个流程,对应到 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
41
42
43
44
45
46
47
48
49
50
class Player {
    companion object {
        init { System.loadLibrary("player") }
    }

    private var url: String = ""

    // 实现回调方法
    private var mErrorListener: MediaErrorListener? = null

    fun setOnErrorListener(mErrorListener: MediaErrorListener?) {
        this.mErrorListener = mErrorListener
    }

    // called from jni  让 JNI 调用的方法
    private fun onError(code: Int, msg: String) {
        if (mErrorListener != null) {
            mErrorListener!!.onError(code, msg)
        }
    }

    fun setDataSource(url: String) {
        this.url = url
    }

    fun play() {
        if (url.isBlank()) error("url is null")
        nPlay(url)
    }

    private external fun nPlay(url: String) // 调用 native 方法
}

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 {

            PlayerTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    play.setDataSource(mMusicFile.absolutePath)
                    play.play()
                }
            }
        }
    }
}

这里的步骤大概是读取 mp3 文件,加载 so 库,将 url 丢给 native 中去处理,然后拿到 url 后,就是 jni 中去处理了

 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
#include <jni.h>
// 因为当前是 .cpp 是 C++ 的编译环境,而 ffmpeg 是 c 写的,所以这里是告诉 C++ 编译器按照 C 语言的规则来进行编译
extern "C" {
#include "libavformat/avformat.h"
}

// 创建全局变量
MyJNICall *myJniCall;
FFmpegHelper *fFmpegHelper;

extern "C"
JNIEXPORT void JNICALL
Java_com_fang_player_Player_nPlay(JNIEnv *env, jobject instance, jstring url_) {

    myJniCall = new MyJNICall(nullptr, env, instance); 

    //获取 java 中的字符串,jstring 转换回 c 中的字符来使用
    const char *url = env->GetStringUTFChars(url_, nullptr);

    fFmpegHelper = new FFmpegHelper(myJniCall, url);

    fFmpegHelper->play();

    env->ReleaseStringUTFChars(url_, url); // 释放副本占用的内存
}

首先这里创建了几个全局变量,注意,他是通过 new 创建的,使用完成之后,需要 delete,然后创建 FFmpegHelper 对象,然后再调用 play 方法,还有需要注意的点是这里的 url 是通过 GetStringUTFChars 来获取的(或者通过GetStringChars) 获取了一个 java 的字符串在c里面使用,其实这里是拷贝了一个字符串的副本,所以记得需要调用 ReleaseStringUTFChars 来释放这块副本所占用的内存

播放

  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
void FFmpegHelper::play() {
    av_register_all(); // 初始化所有的编解码器
    avformat_network_init();

    int formatOpenInputRes = 0;
    int formatFindStreamInfoRes = 0;
    int audioStreamIndex = -1;
    AVCodecParameters *pCodecParameters;
    AVCodec *pCodec = nullptr;
    int codecParametersToContextRes = -1;
    int codecOpenRes = -1;
    int index = 0;
    AVPacket *pPacket = nullptr;
    AVFrame *pFrame = nullptr;

    // 打开输入流,读取文件信息,因为音视频前置知识中了解到,一个音视频的组成,包含了这些,要先读取这些信息,才能知道如何解码,所以这是第一步需要做的事情,读取到的信息都会存储在 AVFormatContext 中, 内部说明,需要调用 close 释放资源
    formatOpenInputRes = avformat_open_input(&pFormatContext, url, nullptr, nullptr);

    // 第一步
    if (formatOpenInputRes != 0) { // 打开读取文件失败
        // 失败,需要告知 java 层,需要释放流资源
        LOGE("format open input error: %s", av_err2str(formatOpenInputRes));
        callPlayerJniError(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(formatFindStreamInfoRes, av_err2str(formatFindStreamInfoRes));
        return;
    }

    // 第三步:查找音频流的 index, 为什么要找 ?因为一个文件中,含有多一个流 (可能是视频流,可能是音频流,这里我们要找的就是音频流)
    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(audioStreamIndex, av_err2str(audioStreamIndex));
        return;
    }


    // 4. 查找解码器
    pCodecParameters = pFormatContext->streams[audioStreamIndex]->codecpar;
    pCodec = avcodec_find_decoder(pCodecParameters->codec_id);

    if (pCodec == nullptr) {
        LOGE("codec find audio decoder error");
        // 使用自定义的错误码
        callPlayerJniError(CODEC_FIND_DECODER_ERROR_CODE, "codec find audio decoder error");
        return;
    }

    // 打开解码器
    pCodecContext = avcodec_alloc_context3(pCodec);
    if (pCodecContext == nullptr) {
        LOGE("codec alloc context error");
        callPlayerJniError(CODEC_ALLOC_CONTEXT_ERROR_CODE, "codec alloc context error");
        return;
    }

    // 设置编码器的上下文
    codecParametersToContextRes = avcodec_parameters_to_context(pCodecContext, pCodecParameters);
    if (codecParametersToContextRes < 0) {
        LOGE("codec parameters to context error: %s", av_err2str(codecParametersToContextRes));
        callPlayerJniError(codecParametersToContextRes, av_err2str(codecParametersToContextRes));
        return;
    }

    // 打开编码器
    codecOpenRes = avcodec_open2(pCodecContext, pCodec, nullptr);
    if (codecOpenRes != 0) {
        LOGE("codec audio open error: %s", av_err2str(codecOpenRes));
        callPlayerJniError(codecOpenRes, av_err2str(codecOpenRes));
        return;
    }

    // 重采样, 从采样是什么 ?如果不重采样,播放的音频,会有呲呲呲的声音
    // 音频重采样(audio resampling)是指将音频信号从一个采样率转换为另一个采样率的过程。采样率决定了每秒记录的采样点数量,通常以赫兹 (Hz) 表示,例如 44.1kHz、48kHz 等

    // 开始进行 音频重采样 
    // 输出采样率
    int64_t out_ch_layout = AV_CH_LAYOUT_STEREO; // 立体声
    enum AVSampleFormat out_sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_S16; // 采样格式
    int out_sample_rate = AUDIO_SAMPLE_RATE; // 采样率

    // 输入采样率,从解码器中读取文件信息
    int64_t in_ch_layout = pCodecContext->channel_layout;  // 输入音频的声道布局
    enum AVSampleFormat in_sample_fmt = pCodecContext->sample_fmt; // 输入音频的采样格式
    int in_sample_rate = pCodecContext->sample_rate; // 输入音频的采样率

    // 重采样方法参数设置:
    swrContext = swr_alloc_set_opts(nullptr, out_ch_layout, out_sample_fmt, out_sample_rate, in_ch_layout, in_sample_fmt, in_sample_rate, 0, nullptr);

    if (swrContext == nullptr) {
        // 提示错误
        callPlayerJniError(SWR_ALLOC_SET_OPTS_ERROR_CODE, "swr alloc set opts error");
        return;
    }

    if (swr_init(swrContext) < 0) {
        callPlayerJniError(SWR_CONTEXT_INIT_ERROR_CODE, "swr context swr init error");
        return;
    }


    // 设置数组要存储的采样率数据大小,可以理解为就是一帧的数据 (根据输出采样率来设置)
    int outChannels = av_get_channel_layout_nb_channels(out_ch_layout);
    int dataSize = av_samples_get_buffer_size(nullptr, outChannels, pCodecParameters->frame_size, out_sample_fmt, 0);
    resampleOutBuffer = (uint8_t *) malloc(dataSize);

    jbyteArray jPcmByteArray = pJniCall->jniEnv->NewByteArray(dataSize);
    jbyte *jPcmData = pJniCall->jniEnv->GetByteArrayElements(jPcmByteArray, NULL); // 数据回调给 java audioTrack 的数据,在循环外创建,防止循环中多次创建数组,导致内存上涨

    pPacket = av_packet_alloc(); // 压缩数据
    pFrame = av_frame_alloc(); // 解码出来的数据

    // 开始解码
    while (av_read_frame(pFormatContext, pPacket) >= 0) { 
        if (pPacket->stream_index == audioStreamIndex) { // 判断是否是音频流
            // Packet 包,压缩的数据,解码成 pcm 数据
            // 有个中间缓存,一个解码的放在里面,一个在不断的读取
            int codecSendPacketRes = avcodec_send_packet(pCodecContext, pPacket); // 做什么的 --> 将压缩数据发送给解码器
            if (codecSendPacketRes == 0) {
                int codecReceiveFrameRes = avcodec_receive_frame(pCodecContext, pFrame); // 做什么的, 将压缩数据转为frame
                if (codecReceiveFrameRes == AVERROR(EAGAIN) || codecReceiveFrameRes == AVERROR_EOF) {
                    LOGE("解码出现错误");
                }

                if (codecReceiveFrameRes == 0) {
                    // AVPacket -> AVFrame
                    index++;

                    // 第一个参数: 分配好的 SwrContext 上下文,它包含了转换操作所需的所有参数(如输入输出的声道布局、采样格式和采样率
                    // 第二个参数: 输出缓冲区,用来存储转换后的音频数据。如果是打包音频数据,则只需要设置第一个缓冲区
                    // 第三个参数:每个声道中可用的输出空间,单位为样本数。例如,如果你希望输出 1024 个样本,你将设置 out_count 为 1024。
                    // 第四个参数:输入缓冲区,包含待转换的音频数据。如果打包音频数据,则只需要设置第一个缓冲区。
                    // 第五个参数:每个声道中可用的输入样本数。这代表待转换音频数据的数量。
                    swr_convert(swrContext, &resampleOutBuffer, pFrame->nb_samples,
                                (const uint8_t **) pFrame->data, pFrame->nb_samples);

                    memcpy(jPcmData, resampleOutBuffer, dataSize); // 数据拷贝到 jPcmData 中
                    LOGE("解码第 %d 帧,大小 = %d", index, dataSize);

                    // 0 把 c 的数组的数据同步到 jbyteArray , 然后释放native数组 (这里涉及到是数据同步)
                    // JNI_COMMIT 会同步数据给 jPcmByteArray ,但是不会释放 jPcmData
                    pJniCall->jniEnv->ReleaseByteArrayElements(jPcmByteArray, jPcmData, JNI_COMMIT);
                    pJniCall->callAudioTrackWrite(jPcmByteArray, 0, dataSize); // 写入 audioTrack,调用 write 方法
                }
            }
        }
        // 解引用:解引用是什么意思, 意思就是解除引用,才能回收
        av_packet_unref(pPacket);
        av_frame_unref(pFrame);
    }

    // 1. 解引用数据 data , 2. 销毁 pPacket 结构体内存  3. pPacket = NULL
    av_packet_free(&pPacket);
    av_frame_free(&pFrame);

    // 解除 jPcmDataArray 的持有,让 javaGC 回收
    pJniCall->jniEnv->ReleaseByteArrayElements(jPcmByteArray, jPcmData, 0);
    pJniCall->jniEnv->DeleteLocalRef(jPcmByteArray);
}

这里的代码就是整个音频播放的核心代码了,基本上需要调用的方法,就是上面图中需要调用的几个方法,需要什么参数再往前推就可以了,需要注意的是,这里需要对音频进行重新采样,如果不采样的话,播放的声音 可能是呲呲呲的,那为什么要进行重采样呢 ?

  • 什么是音频重采样?采样率是什么 ?

想象你在录制一段声音,比如你在唱歌。录音设备会在每秒钟收集很多点来代表这段声音,这些点就像一系列快照。这些每秒钟收集的点的数量就是采样率。例如: 44100Hz:每秒钟收集 44100 个点 48000Hz:每秒钟收集 48000 个点 音频重采样就是把这个“每秒钟收集点的数量”从一个数换成另一个数。

  • 为什么要重采样?

兼容性:不同的设备和软件有时候只能处理特定的采样率。比如,你的麦克风可能录制的是 44100Hz,但你的音频编辑软件可能要求文件是 48000Hz。 优化效果:有时候在处理音频时,我们希望用更高的采样率来获得更好的效果,然后再转换回需要的采样率。 文件大小:更高的采样率会产生更大的文件,有时候我们会需要降低采样率以减小文件大小。

所以,这里我们要保证输出采样率是能够让硬件能够播放的,可以理解为格式转换,然后在 while 中进行解码,解码好的数据丢给 AudioTrack 进行播放即可,需要注意的是 这里是 while 中,注意不要在循环中创建数组,不然会导致内存频繁的上涨,可以在循环外创建通过 GetByteArrayElements 创建 java 的数据,然后循环内配合 ReleaseByteArrayElements 将 数据同步给 Java 的数组

相关的 AudioTrack 播放代码如下

 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
MyJNICall::MyJNICall(JavaVM *javaVM, JNIEnv *jniEnv, jobject jPlayerObj) {
    this->javaVM = javaVM;
    this->jniEnv = jniEnv;
    this->jPlayerObj = jPlayerObj;

    initCrateAudioTrack(); // 创建 java 的 audioTrack 对象

    // 创建 MediaErrorListener 回调方法, 将错误信息回调给 java 层级
    jclass jPlayClass = jniEnv->GetObjectClass(jPlayerObj); // 找到监听回调的类
    jPlayerErrorMid = jniEnv->GetMethodID(jPlayClass, "onError", "(ILjava/lang/String;)V");
}

// 析构函数
MyJNICall::~MyJNICall() {
    jniEnv->DeleteLocalRef(jAudioTrackObj);
}

void MyJNICall::initCrateAudioTrack() {
    // 反射创建对象
    jclass jAudioTrackClass = jniEnv->FindClass("android/media/AudioTrack");
    jmethodID jAudioTackCMid = jniEnv->GetMethodID(jAudioTrackClass, "<init>", "(IIIIII)V");

    int streamType = 3;
    int sampleRateInHz = AUDIO_SAMPLE_RATE;
    int channelConfig = (0x4 | 0x8);
    int audioFormat = 2;
    int mode = 1;

    jmethodID getMinBufferSizeMid = jniEnv->GetStaticMethodID(jAudioTrackClass, "getMinBufferSize", "(III)I");
    // 调用方法 (getMinBufferSize() 接口,字面意思是返回最小数据缓冲区的大小,它是声音能正常播放的最低保障)
    int bufferSizeInBytes = jniEnv->CallStaticIntMethod(jAudioTrackClass, getMinBufferSizeMid,
                                                        sampleRateInHz, channelConfig, audioFormat);

    // 创建对象
    jAudioTrackObj = jniEnv->NewObject(jAudioTrackClass, jAudioTackCMid, streamType,
                                       sampleRateInHz, channelConfig, audioFormat, bufferSizeInBytes, mode);

    // play 播放
    jmethodID playMid = jniEnv->GetMethodID(jAudioTrackClass, "play", "()V");
    jniEnv->CallVoidMethod(jAudioTrackObj, playMid);

    // write method
    jAudioTrackWriteMid = jniEnv->GetMethodID(jAudioTrackClass, "write", "([BII)I");
}

void MyJNICall::callAudioTrackWrite(jbyteArray audioData, int offsetInBytes, int sizeInBytes) const {
    jniEnv->CallIntMethod(jAudioTrackObj, jAudioTrackWriteMid, audioData, offsetInBytes, sizeInBytes);
}

这里的逻辑就是反射创建 AudioTrack 对象,然后将上面解码好的数据通过调用 callAudioTrackWrite ,即可完成播放