Android 音视频播放器 Demo(二)—— 音频解码与音视频同步

音视频编解码系列目录:

Android 音视频基础知识
Android 音视频播放器 Demo(一)------ 视频解码与渲染
Android 音视频播放器 Demo(二)------ 音频解码与音视频同步
RTMP 直播推流 Demo(一)------ 项目配置与视频预览

RTMP 直播推流 Demo(二)------ 音频推流与视频推流

本篇会介绍音频的解码与渲染、音视频同步以及进度条的实现。

1、音频解码与渲染

Android 播放音频通常有三种方式:

  1. Media Player
  2. Audio Track
  3. OpenSL ES

前两种都是在上层,而我们要采用的 OpenSL ES 是在 Native 层。原因如下:

  • OpenSL ES 是 C 语言的库,在 NDK 下开发能更好地集成到 Native 应用中
  • 在 Native 层播放音频速度快,延时低,非常适合音视频同步中以音频为准的情况
  • 相比于使用上层 API 的方式,可以减少 Java/Kotlin 频繁的反射调用,比如 Audio Track 播放音频需要解码为 PCM 数据再反射 Java/Kotlin,增加开销

OpenSL ES(Open Sound Library for Embedded Systems)是无授权费、跨平台、针对嵌入式系统精心优化的硬件音频加速 API,是一套针对移动式平台的音频标准。该库都允许使用 C 或 C ++ 来实现高性能,低延迟的音频操作。Android 的 OpenSL ES 库位于 NDK 的 platforms 文件夹内,它的使用通常可以分为如下几步:

  1. 创建引擎并获取引擎接口
  2. 设置混音器
  3. 创建播放器
  4. 设置播放回调函数
  5. 设置播放器状态为播放状态
  6. 手动激活回调函数
  7. 释放

前面在讲解初始化解码器时,实际上就同时创建了 VideoChannel 和 AudioChannel,现在来看 AudioChannel 的初始化:

cpp 复制代码
/**
 * 初始化音频数据,主要是计算 PCM 大小的三要素:
 * 1.采样率,如 44100,48000
 * 2.位深/采样格式大小,使用 16 位数据表示每个样本数据
 * 3.声道数
 *
 * 由于压缩的 AAC 数据是 44100、32 位双声道,而安卓手机的音频参数是
 * 16 位的,因此需要进行重采样。AAC 用 32 位是因为算法效率高,但是硬
 * 件设备,诸如安卓手机通常采用 16 位,声卡最大也只采用了 24 位
 */
AudioChannel::AudioChannel(int stream_index, AVCodecContext *avCodecContext)
        : BaseChannel(stream_index, avCodecContext) {
    // 采样率
    out_sample_rate = 44100;
    // 位深,每个 sample 两个字节,16 位
    out_sample_size = av_get_bytes_per_sample(AV_SAMPLE_FMT_S16);
    // 双声道:AV_CH_LAYOUT_STEREO = AV_CH_FRONT_LEFT|AV_CH_FRONT_RIGHT
    nb_out_channels = av_get_channel_layout_nb_channels(AV_CH_LAYOUT_STEREO);
    // 输出缓冲区大小 = 采样率 * 位深 * 声道数 = 44100 * 2 * 2
    out_buffer_size = out_sample_rate * out_sample_size * nb_out_channels;
    // 为输出缓冲区开辟空间
    out_buffer = static_cast<uint8_t *>(malloc(out_buffer_size));
    // 重采样上下文
    swrContext = swr_alloc_set_opts(nullptr,
                                    AV_CH_LAYOUT_STEREO, // 输出的声道类型
                                    AV_SAMPLE_FMT_S16, // 输出采样大小
                                    out_sample_rate, // 输出采样率
                                    avCodecContext->channel_layout, // 输入的声道布局
                                    avCodecContext->sample_fmt, // 输入的采样格式
                                    avCodecContext->sample_rate, // 输入的采样率
                                    0, nullptr);
    // 初始化重采样上下文
    swr_init(swrContext);
}

AudioChannel::~AudioChannel() {
    if (swrContext) {
        swr_free(&swrContext);
    }

    DELETE(out_buffer);
}

然后就是开始播放的时候,在 VideoPlayer 中让 AudioChannel 开始工作:

cpp 复制代码
void VideoPlayer::start() {
    isPlaying = true;

    if (videoChannel) {
        videoChannel->start();
    }
	// 音频通道也开始解码和播放
    if (audioChannel) {
        audioChannel->start();
    }

    pthread_create(&pid_start, nullptr, task_start, this);
}

与 VideoChannel 类似,AudioChannel 也是开启两个线程分别解码和播放:

cpp 复制代码
/**
 * 开启解码和播放线程
 */
void AudioChannel::start() {
    isPlaying = true;

    packets.setEnable(true);
    frames.setEnable(true);

    pthread_create(&pid_decode, nullptr, task_decode_audio, this);
    pthread_create(&pid_play, nullptr, task_play_audio, this);
}

1.1 音频解码

音频解码的代码实际上是跟视频解码几乎一致的,只不过解码出来的 AVFrame 是存放在 AudioChannel 的 AVFrame 队列中:

cpp 复制代码
void *task_decode_audio(void *args) {
    auto audioChannel = static_cast<AudioChannel *>(args);
    audioChannel->decode();
    return nullptr;
}

void AudioChannel::decode() {
    // 由于从队列中取出的 AVPacket 在使用完后直接
    // 就释放了,因此可以放在 while 外复用
    AVPacket *packet = nullptr;
    int result;
    while (isPlaying) {
        // 由于解码速度要快于音视频的渲染/播放速度,因此需要控制
        // frames 队列的入队速度,以防队列过大而撑爆内存
        if (isPlaying && frames.size() > 100) {
            av_usleep(10 * 1000);
            continue;
        }
        // 从队列中取出一个 AVPacket
        result = packets.get(packet);

        // 如果此时已经设置停止播放,则跳出循环
        if (!isPlaying) {
            break;
        }
        // 如果取 AVPacket 失败,可能是因为队列中尚未有
        // AVPacket,继续循环等待 AVPacket 被读取到队列中
        if (!result) {
            continue;
        }

        // 将 AVPacket 发送给解码器
        result = avcodec_send_packet(avCodecContext, packet);
        if (result != 0) {
            break;
        }

        // 从解码器中获取解码后的 AVFrame 存入 frames 队列中,av_frame_alloc()
        // 会在堆区开辟内存空间,使用完毕需要回收
        AVFrame *frame = av_frame_alloc();
        result = avcodec_receive_frame(avCodecContext, frame);
        LOGD("音频解码结果:%d", result);
        if (!result) {
            frames.put(frame);
            // 每当调用 av_read_frame() 时就会对相应的 AVPacket 引用计数加一,
            // 对 AVPacket 的 *data 指向的内存区域的引用计数减 1,减到 0 时会回收
            av_packet_unref(packet);
            // 回收 AVPacket 指针本身
            releaseAVPacket(&packet);
        } else if (result == AVERROR(EAGAIN)) {
            continue;
        } else {
            // 解码失败,但是 AVFrame 有值,需要释放
            if (frame) {
                releaseAVFrame(&frame);
            }
            break;
        }
        LOGD("音频解码,frames 中完成解码的帧数:%d", frames.size());
    }
    av_packet_unref(packet);
    // 对于从 while 循环 break 出来的情况还要再回收一次 AVPacket
    releaseAVPacket(&packet);
}

1.2 音频播放

使用 OpenSL ES 播放音频,先按照如下步骤设置:

cpp 复制代码
/**
 * 播放线程的任务,是配置 OpenSL ES 的引擎与播放器,并在最后触发
 * 回调接口。回调接口触发后会不断地自我回调,每次回调都从 mFrames
 * 队列中取出 AVFrame 将音频数据以及大小存入 OpenSL ES 的播放队列
 */
void AudioChannel::play() {
    SLresult result;
    /*
     * 1.创建引擎对象并获取引擎接口
     */
    // 1.1 创建引擎对象
    result = slCreateEngine(&engineObject, 0, nullptr, 0, nullptr, nullptr);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("创建引擎失败 slCreateEngine error");
        return;
    }
    // 1.2 初始化引擎
    result = (*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("初始化引擎失败 Realize error");
        return;
    }
    // 1.3 获取引擎接口
    result = (*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineInterface);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("创建引擎接口失败 Realize error");
        return;
    }

    if (engineInterface) {
        LOGD("创建引擎接口 create success");
    } else {
        LOGE("创建引擎接口 create error");
        return;
    }

    /*
     * 2.设置混音器
     */
    // 2.1 创建混音器
    result = (*engineInterface)->CreateOutputMix(engineInterface, &outputMixObject,
                                                 0, nullptr, nullptr);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("创建混音器失败 CreateOutputMix failed");
        return;
    }
    // 2.2 初始化混音器
    result = (*engineObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("初始化混音器失败 (*outputMixObject)->Realize failed");
        return;
    }
    // 2.3 设置混音器接口,这步可选
    result = (*outputMixObject)->GetInterface(outputMixObject, SL_IID_ENVIRONMENTALREVERB,
                                              &environmentalReverb);
    if (SL_RESULT_SUCCESS == result) {
        (*environmentalReverb)->SetEnvironmentalReverbProperties(environmentalReverb, &settings);
    }

    /*
     * 3.创建播放器
     */
    // 3.1 配置输入声音的信息
    // 创建两个缓冲队列
    SLDataLocator_AndroidSimpleBufferQueue loc_buf_queue = {SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE,
                                                            10};
    // 设置 PCM 数据格式
    SLDataFormat_PCM format_pcm = {SL_DATAFORMAT_PCM, 2, // PCM 格式,双声道
                                   SL_SAMPLINGRATE_44_1, // 采样率为 44100
                                   SL_PCMSAMPLEFORMAT_FIXED_16, // 采样格式为 16 位
                                   SL_PCMSAMPLEFORMAT_FIXED_16, // 数据大小为 16 位
                                   SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT, // 左右声道
                                   SL_BYTEORDER_LITTLEENDIAN}; // 小端模式
    // 将以上配置信息存入数据源以便后续使用
    SLDataSource audioSrc = {&loc_buf_queue, &format_pcm};
    // 3.2 配置音轨
    // 设置混音器,SL_DATALOCATOR_OUTPUTMIX 为混音器类型
    SLDataLocator_OutputMix loc_outMix = {SL_DATALOCATOR_OUTPUTMIX, outputMixObject};
    SLDataSink audioSink = {&loc_outMix, nullptr};
    // 操作队列的接口
    const SLInterfaceID ids[1] = {SL_IID_BUFFERQUEUE};
    const SLboolean req[1] = {SL_BOOLEAN_TRUE};
    // 3.3 创建播放器
    result = (*engineInterface)->CreateAudioPlayer(engineInterface, // 引擎接口
                                                   &bqPlayerObject, // 播放器
                                                   &audioSrc, // 音频配置信息
                                                   &audioSink, // 混音器
                                                   1, // 回调接口个数为 1
                                                   ids, // 播放队列 ID
                                                   req); // 使用内置播放
    if (SL_RESULT_SUCCESS != result) {
        LOGE("创建播放器失败 CreateAudioPlayer failed!");
        return;
    }
    // 3.4 初始化播放器
    result = (*bqPlayerObject)->Realize(bqPlayerObject, SL_BOOLEAN_FALSE);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("实例化播放器失败 CreateAudioPlayer failed!");
        return;
    }
    // 3.5 获取播放器接口
    result = (*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_PLAY, &bqPlayerPlay);
    if (SL_RESULT_SUCCESS != result) {
        LOGE("获取播放接口失败 GetInterface SL_IID_PLAY failed!");
        return;
    }

    /*
     * 4.设置播放回调函数
     */
    // 4.1 获取播放器队列接口
    result = (*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_BUFFERQUEUE,
                                             &bqPlayerBufferQueue);
    if (result != SL_RESULT_SUCCESS) {
        LOGE("获取播放队列 GetInterface SL_IID_BUFFERQUEUE failed!");
        return;
    }
    // 4.2 设置回调
    (*bqPlayerBufferQueue)->RegisterCallback(bqPlayerBufferQueue, bqPlayerCallback, this);
    LOGD("播放器回调函数设置成功");

    /*
     * 5.设置播放器为播放状态
     */
    (*bqPlayerPlay)->SetPlayState(bqPlayerPlay, SL_PLAYSTATE_PLAYING);

    /*
     * 6.手动激活回调函数
     */
    bqPlayerCallback(bqPlayerBufferQueue, this);
    LOGD("音频播放器创建成功!");
}

4.2 步骤设置了播放器回调 bqPlayerCallback,在第 6 步中又手动激活这个回调,它需要不断将音频数据添加到 OpenSL 的队列中:

cpp 复制代码
/**
 * 回调函数,将每一帧音频数据存入 bq 队列中
 */
void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *args) {
    auto audioChannel = static_cast<AudioChannel *>(args);
    // 获取 PCM 格式数据的大小
    int pcm_size = audioChannel->getPCMSize();
    // 添加 PCM 数据到播放器队列中
    (*bq)->Enqueue(bq, audioChannel->out_buffer, pcm_size);
}

将音频帧存入队列时,需要计算 PCM 的大小并传入:

cpp 复制代码
/**
 * 从 AVFrame 队列中取出一个 AVFrame,对其进行重采样并将数据
 * 存入 out_buffer,同时获取重采样后的数据大小
 */
int AudioChannel::getPCMSize() {
    int pcm_data_size = 0;
    AVFrame *frame = nullptr;
    bool result;
    while (isPlaying) {
        result = frames.get(frame);
        if (!isPlaying) {
            break;
        }
        if (!result) {
            continue;
        }

        // 重采样,因为输入的音频频率可能有多种,如 48000,而输出音频需要统一为 44100,
        // 假如输入的是 10 个 48000,那么输出为 44100 时就需要 10 * 48000 / 44100 = 10.88 ≈ 11
        int dst_nb_samples = av_rescale_rnd(
                swr_get_delay(swrContext, frame->sample_rate) + frame->nb_samples,
                frame->sample_rate, // 输入采样率
                out_sample_rate, // 输出采样率
                AV_ROUND_UP); // 向上取整
        int samples_per_channel = swr_convert(swrContext,
                                              &out_buffer, // 重采样后的输出数据保存在 out_buffer 中
                                              dst_nb_samples, // 输出的单通道样本数
                                              (const uint8_t **) (frame->data), // 输入的未重采样的 PCM 数据
                                              frame->nb_samples); // 输入的样本数

        // 计算重采样后的 PCM 数据大小
        pcm_data_size = out_sample_size * samples_per_channel * nb_out_channels;
        break;
    }
    av_frame_unref(frame);
    releaseAVFrame(&frame);
    return pcm_data_size;
}

2、音视频同步

现在是音频和视频各自播放各自的,从解码后的 AVFrame 队列中取出一帧就渲染了,因此出现音画不同步的情况很正常,现在需要想个办法让音视频同步。

音视频同步解决方案:

  1. 以音频为准:音频只管正常播放,视频通过延时或丢帧等方式与音频同步
  2. 以视频为准:视频画面每次循环不变,音频根据视频来延迟或等待
  3. 自定义时间为准:设定开始加载视频的时间为 0,后续的音频帧与视频帧依赖自定义时间进行同步

2.1 FFmpeg 的时间戳

FFmpeg 中有两个时间戳概念 DTS 与 PTS:

  • DTS(Decoding Time Stamp)解码时间戳,告诉解码器 AVPacket 的解码顺序
  • PTS(Presentation Time Stamp)显示时间戳,指示 AVPacket 中解码出来的数据显示的顺序

在没有 B 帧的情况下,DTS 和 PTS 的输出顺序是一样的。因为 B 帧打乱了解码和显示的顺序(要解码 B 帧需要先解码后面的 P 帧),所以一旦存在 B 帧,PTS 和 DTS 就会不同:

也就是说,在音频中,DTS 与 PTS 相同。但是在视频中,由于可能存在 B 帧,所以 DTS 与 PTS 不一定相同。比如在一个帧序列中,先解码出 I 帧,然后解码出 P 帧,最后才解码出多个 B 帧。但是在显示时,顺序必须是 I、B、B、P 才是(B 是双向预测帧,参考前面的 I 帧与后面的 I/P 帧),这样 DTS 与 PTS 就不同。

实现音视频同步要基于 PTS,PTS 中有一个时间基 time_base 的概念,实际上是一个时间刻度,定义在 AVStream -> AVCodecContext 中:

cpp 复制代码
	/**
     * 以秒为单位表示帧时间戳的基本时间单位
     * 对于固定帧速率的内容,时间基应为 1/帧速率,时间戳增量应为 1。
     * 对于视频来说,这通常是帧速率或场率的倒数,但并不总是如此。
     * 如果帧速率不是恒定的,1/time_base 不是平均帧速率。
     * - 编码:必须由用户设置。
     * - 解码:不推荐将此字段用于解码。请使用帧速率(framerate)代替。
     */
    AVRational time_base;

AVRational 表示音视频相关的有理数,num 字段是分子,den 字段是分母:

cpp 复制代码
/**
 * Rational number (pair of numerator and denominator).
 */
typedef struct AVRational{
    int num; ///< Numerator
    int den; ///< Denominator
} AVRational;

AVRational 可以表示帧率,num 表示每秒的帧数,den 表示帧率的分母,25 就表示 25 帧/秒,那么对应的时间基就是 1/25 = 0.04 秒。

AVRational 还可以表示音频采样率,num 表示每秒的采样数,den 表示采样率的分母,44100 表示 44.1 kHz 的采样率,那么对应的时间基就是 1/44100 ≈ 22.68 微秒 。

当你获取到媒体流对象后,可以直接获取其时间基:

cpp 复制代码
AVRational time_base = stream->time_base;

也可以通过平均帧率计算出时间基:

cpp 复制代码
// 获取平均帧率
AVRational frame_rate = stream->avg_frame_rate;
// 计算帧率
int fps = frame_rate.num / frame_rate.den;
// 或者也可以使用现成的方法,一样的
fps = av_q2d(frame_rate);
// 帧率取倒数就是帧率的时间基
float time_base = 1.0 / fps;

2.2 代码实现

在 Native 的 VideoPlayer 执行准备工作时,打开流的解码器之后,就可以获取这个流的时间基,在创建 Channel 时将时间基传入。视频通道还要追加帧率,这些都是音视频同步所需的参数:

cpp 复制代码
void VideoPlayer::prepareInChildThread() {
    ...
    /*
     * 3.打开解码器,对音视频流分别创建对应的处理通道
     */
    // 编解码器上下文
    AVCodecContext *avCodecContext = nullptr;
    for (int i = 0; i < avFormatContext->nb_streams; ++i) {
        ...
    	// 获取流的时间基,音视频同步需要
        AVRational time_base = stream->time_base;
        // 3.5 根据媒体流的类型创建对应的处理通道
        if (codecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
            // 有的视频类型只有一帧封面图片,这种情况需要跳过
            if (stream->disposition & AV_DISPOSITION_ATTACHED_PIC) {
                continue;
            }
            // 计算帧率供音视频同步使用
            AVRational avg_frame_rate = stream->avg_frame_rate;
            // 将 AVRational 换算为 double 再取整数
            int fps = av_q2d(avg_frame_rate);
            // 创建视频通道
            videoChannel = new VideoChannel(i, avCodecContext, time_base, fps);
            videoChannel->setRenderCallback(renderCallback);
        } else if (codecParameters->codec_type == AVMEDIA_TYPE_AUDIO) {
            // 创建音频通道
            audioChannel = new AudioChannel(i, avCodecContext, time_base);
        } else if (codecParameters->codec_type == AVMEDIA_TYPE_SUBTITLE) {
            // 创建字幕通道...省略
        }
    }
    ...
}

由于我们采用以音频为准的同步方案,因此 VideoChannel 需要持有 AudioChannel 以获取音频播放的时间戳:

cpp 复制代码
void VideoPlayer::start() {
    ...

    // 如果音视频流都存在,就让视频通道持有音频通道,以便做音频为准的音视频同步
    if (videoChannel && audioChannel) {
        videoChannel->setAudioChannel(audioChannel);
    }

    pthread_create(&pid_start, nullptr, task_start, this);
}

现在我们拿到音频播放的时间戳了,要考虑一些同步的实现细节:

  • 如果视频播放进度比音频快,需要让视频延时,时长是每一帧的时长加上解码这一帧所需要的时间,手机配置好解码时间就短一些
  • 如果视频播放进度比音频慢,视频需要追赶,如果差距过大就什么都不做,让其自然追赶是最快的方式;如果差距不大,可以考虑以丢帧的方式进行追赶
  • 丢帧的时候要考虑是丢弃 AVPacket 还是 AVFrame,因为 AVPacket 是区分关键帧和非关键帧的,我们丢帧只能丢非关键帧。AVFrame 则没有关键帧的问题,它每一帧都是一个完整的画面

在 VideoChannel 的 play() 中,拿到 AVFrame 的图像数据回调给渲染接口之前,要比对音视频播放时间戳,如果视频比音频播的快就延时一段时间再做渲染回调。反之,视频播放比音频慢,如果差距不大可以采用丢帧的方式让视频追赶音频播放,否则不加干涉让视频自然追赶音频:

cpp 复制代码
void VideoChannel::play() {
    ...
    AVFrame *frame = nullptr;
    int result;
    while (isPlaying) {
        result = frames.get(frame);
        if (!isPlaying) {
            break;
        }
        if (!result) {
            continue;
        }

        // 执行 YUV -> RGBA 转换,转换后的数据保存在 dst_data 和 dst_lineSize 中
        sws_scale(swsContext, frame->data, frame->linesize, 0,
                  avCodecContext->height, dst_data, dst_lineSize);

        /*
         * 音视频同步,计算每一帧视频应该延时还是丢帧
         */
        // 先计算每一帧的延时时间
        double fps_delay = 1.0 / fps;
        // 再计算每一帧解码所耗费的时间,repeat_pict 是编码器指定的
        double extra_delay = frame->repeat_pict / (2 * fps);
        double delay = fps_delay + extra_delay;

        // 获取音视频的时间戳并进行比较
        double video_time = frame->pts * av_q2d(time_base);
        double audio_time = audioChannel->audio_time;
        double time_diff = video_time - audio_time;

        // 根据 time_diff 决定视频应该延时还是丢帧
        if (time_diff > 0) {
            // 视频超前,需要给视频设置延时
            if (time_diff > 1) {
                // 如果视频远远快于音频,你不能也睡眠相应的时间,否则视频可能会"定格",
                // 最长的睡眠时间设置为 delay 的 2 倍
                av_usleep(delay * 2 * 1000000);
            } else {
                // 差距在 0 ~ 1 之间,可以根据实际差值睡眠
                av_usleep((delay + time_diff) * 1000000);
            }
        } else if (time_diff < 0) {
            // 视频播放慢于音频,需要丢帧以追赶音频。根据经验值,当差值绝对值在 0.05
            // 以内时,丢包不会对视频播放产生严重影响(人对视频没有音频敏感)
            if (fabs(time_diff) <= 0.05) {
                // 同步丢包
                frames.sync();
                // 丢完取下一个包
                continue;
            }
        } else {
            LOGD("同步:当前处于完全同步状态");
        }

        renderCallback(dst_data[0], avCodecContext->width, avCodecContext->height, dst_lineSize[0]);
        // 释放 AVFrame
        av_frame_unref(frame);
        releaseAVFrame(&frame);
    }
    ...
}

丢帧策略是 SafeQueue 交给外界定制的:

cpp 复制代码
template<class T>
class SafeQueue {
    // 同步时丢弃队列中视频包的回调
    typedef void (*SyncCallback)(std::queue<T> &);
public:
	void sync() {
        pthread_mutex_lock(&mutex);
        syncCallback(queue);
        pthread_mutex_unlock(&mutex);
    }
    
    void setSyncCallback(SyncCallback callback) {
        syncCallback = callback;
    }
}

VideoChannel 在初始化时对 packets 和 frames 两个队列设置了回调:

cpp 复制代码
void dropAVPackets(std::queue<AVPacket *> &packets) {
    while (!packets.empty()) {
        AVPacket *packet = packets.front();
        if (packet->flags != AV_PKT_FLAG_KEY) {
            BaseChannel::releaseAVPacket(&packet);
            packets.pop();
        } else {
            break;
        }
    }
}

void dropAVFrames(std::queue<AVFrame *> &frames) {
    if (!frames.empty()) {
        AVFrame *frame = frames.front();
        BaseChannel::releaseAVFrame(&frame);
        frames.pop();
    }
}

VideoChannel::VideoChannel(int stream_index, AVCodecContext *avCodecContext, AVRational time_base,
                           int fps)
        : BaseChannel(stream_index, avCodecContext, time_base), fps(fps) {
    packets.setSyncCallback(dropAVPackets);
    frames.setSyncCallback(dropAVFrames);
}

当然你可以看到,两个队列的丢帧策略并不相同,因为 AVPacket 是区分关键帧与非关键帧的,丢帧时不能丢关键帧,否则画面渲染会受到影响。而 AVFrame 是解码后完整的一帧画面,没有关键帧与非关键帧之说,可以直接丢弃。

3、进度条

分两步,首先要正常显示进度,其次拖动进度条要跳转到相应的时间戳上。

3.1 显示进度

想让界面显示进度条,必须先从上层调用 Native 方法查询视频的时长,成功获取后才显示进度条:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    private var duration = 0
    private var isTouch = false
        
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        videoPlayer.setOnPreparedListener(object : VideoPlayer.OnPreparedListener {
            override fun onPrepared() {
                duration = videoPlayer.nativeGetDuration()
                runOnUiThread {
                    if (duration > 0) {
                        setProgressTime(0, duration)
                        binding.seekBar.visibility = View.VISIBLE
                        binding.tvTime.visibility = View.VISIBLE
                    }
                    Toast.makeText(this@MainActivity, "准备就绪", Toast.LENGTH_LONG).show()
                }
                videoPlayer.start()
            }
        })
        ...
    }
    
    private fun setProgressTime(progress: Int, duration: Int) {
        binding.tvTime.text =
            String.format("%s / %s", getTimeString(progress), getTimeString(duration))
    }

    private fun getTimeString(timeInSecond: Int): String {
        val second = timeInSecond % 60
        val minute = timeInSecond / 60
        val hour = timeInSecond / 3600
        return if (hour > 1) {
            String.format("%d:%02d:%02d", hour, minute, second)
        } else {
            String.format("%02d:%02d", minute, second)
        }
    }
}

VideoPlayer 的 nativeGetDuration() 去获取 Native 层查询到的视频时长:

cpp 复制代码
void VideoPlayer::prepareInChildThread() {
    ...
    /*
     * 2.查找媒体中的音视频流信息存入 AVFormatContext
     */
    ...

    // 获取视频的时长信息
    duration = avFormatContext->duration / AV_TIME_BASE;
    ...
}

int VideoPlayer::getDuration() {
    return duration;
}

这样可以显示出进度条和总时间,但是进度条与当前时长不会更新。要实现这个需要在 AudioChannel 的 getPCMSize() 取出 AVFrame 时计算出音频的时间戳并回调给上层。

Native 回调上层还是借助 JNICallbackHelper:

cpp 复制代码
JNICallbackHelper::JNICallbackHelper(JavaVM *jvm, JNIEnv *jEnv, jobject jObj) {
    javaVM = jvm;
    jniEnv = jEnv;
    // jobject 默认不能跨越线程和函数,必须声明为全局引用才可以
    jObject = jEnv->NewGlobalRef(jObj);
    // 反射获取上层方法对象需要方法所在的类对象
    jclass clazz = jEnv->GetObjectClass(jObject);
    // 获取要反射的方法 ID
    ...
    onProgressId = jEnv->GetMethodID(clazz, "onProgress", "(I)V");
}

void JNICallbackHelper::onProgress(int thread_mode, int progress) {
    if (thread_mode == MAIN_THREAD) {
        // 在主线程中,可以直接使用主线程的 JNIEnv 调用上层方法
        jniEnv->CallVoidMethod(jObject, onProgressId, progress);
    } else {
        // 在子线程中,需要先获取子线程的 JNIEnv 再调用上层方法
        JNIEnv *childEnv;
        javaVM->AttachCurrentThread(&childEnv, nullptr);
        childEnv->CallVoidMethod(jObject, onProgressId, progress);
        javaVM->DetachCurrentThread();
    }
}

AudioChannel 通过 JNICallbackHelper 将时间戳回调给上层:

cpp 复制代码
int AudioChannel::getPCMSize() {
    int pcm_data_size = 0;
    AVFrame *frame = nullptr;
    bool result;
    while (isPlaying) {
        result = frames.get(frame);
        if (!isPlaying) {
            break;
        }
        if (!result) {
            continue;
        }

        // 重采样,因为输入的音频频率可能有多种,如 48000,而输出音频需要统一为 44100,
        // 假如输入的是 10 个 48000,那么输出为 44100 时就需要 10 * 48000 / 44100 = 10.88 ≈ 11
        int dst_nb_samples = av_rescale_rnd(
                swr_get_delay(swrContext, frame->sample_rate) + frame->nb_samples,
                frame->sample_rate, // 输入采样率
                out_sample_rate, // 输出采样率
                AV_ROUND_UP); // 向上取整
        int samples_per_channel = swr_convert(swrContext,
                                              &out_buffer, // 重采样后的输出数据保存在 out_buffer 中
                                              dst_nb_samples, // 输出的单通道样本数
                                              (const uint8_t **) (frame->data), // 输入的未重采样的 PCM 数据
                                              frame->nb_samples); // 输入的样本数

        // 计算重采样后的 PCM 数据大小
        pcm_data_size = out_sample_size * samples_per_channel * nb_out_channels;

        // 计算准确的音频时间戳,如果编码器不能提供准确时间戳,就采用 frame->best_effort_timestamp 计算
        audio_time = frame->pts * av_q2d(time_base);
        if (jniCallbackHelper) {
            jniCallbackHelper->onProgress(CHILD_THREAD, audio_time);
        }
        break;
    }
    av_frame_unref(frame);
    releaseAVFrame(&frame);
    return pcm_data_size;
}

上层的接收方法还是在 VideoPlayer 中:

kotlin 复制代码
	private var onProgressListener: OnProgressListener? = null

	fun onProgress(progress: Int) {
        onProgressListener?.onProgress(progress)
    }

	fun setOnProgressListener(onProgressListener: OnProgressListener) {
        this.onProgressListener = onProgressListener
    }

	interface OnProgressListener {
        fun onProgress(progress: Int)
    }

UI 设置 OnProgressListener 更新进度条和时间:

kotlin 复制代码
	override fun onCreate(savedInstanceState: Bundle?) {
        ...
        videoPlayer.setOnProgressListener(object : VideoPlayer.OnProgressListener {
            override fun onProgress(progress: Int) {
                if (!isTouch) {
                    runOnUiThread {
                        binding.seekBar.progress = progress * 100 / duration
                        setProgressTime(progress, duration)
                    }
                }
            }
        })
        ...
    }

3.2 拖拽进度条

拖动进度条将视频跳转到对应的时间戳上,首先为 SeekBar 设置监听,在发生拖动事件时将进度传递给 Native 层:

kotlin 复制代码
class MainActivity : AppCompatActivity(), SeekBar.OnSeekBarChangeListener {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        binding.seekBar.setOnSeekBarChangeListener(this)
        ...
    }
    
    // SeekBar.OnSeekBarChangeListener start
    override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
        if (fromUser) {
            setProgressTime(progress * duration / 100, duration)
        }
    }

    override fun onStartTrackingTouch(seekBar: SeekBar?) {
        isTouch = true
    }

    override fun onStopTrackingTouch(seekBar: SeekBar?) {
        isTouch = false
        seekBar?.let {
            videoPlayer.seek(it.progress * duration / 100L)
        }
    }
    // SeekBar.OnSeekBarChangeListener end
}

VideoPlayer 执行 Native 的 seek 函数:

kotlin 复制代码
	fun seek(progress: Long) {
        nativeSeek(progress)
    }

	private external fun nativeSeek(progress: Long)

native-lib 将 seek 请求交给 Native 的 VideoPlayer:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_video_player_VideoPlayer_nativeSeek(JNIEnv *env, jobject thiz, jlong progress) {
    if (videoPlayer) {
        videoPlayer->seek(progress);
    }
}

VideoPlayer 通过 FFmpeg 的 av_seek_frame() 执行 seek 操作,同时将 VideoChannel 和 AudioChannel 的 AVPacket 队列和 AVFrame 队列清空再重新接收解码帧:

cpp 复制代码
VideoPlayer::VideoPlayer(const char *data_source, JNICallbackHelper *helper) {
    // 由于参数传入的 data_source 指针在调用完当前构造函数后会被回收,
    // 为了避免 dataSource 成为悬空指针,需要对 data_source 进行深拷贝,
    // 声明 char 数组时不要忘记为 \0 预留出一个字节的空间
    dataSource = new char[strlen(data_source) + 1];
    strcpy(dataSource, data_source);

    jniCallbackHelper = helper;

    pthread_mutex_init(&seek_mutex, nullptr);
}

VideoPlayer::~VideoPlayer() {
    pthread_mutex_unlock(&seek_mutex);
}

void VideoPlayer::seek(long progress) {
    if (progress < 0 || progress > duration) {
        return;
    }
    if (!audioChannel && !videoChannel) {
        return;
    }
    if (!avFormatContext) {
        return;
    }

    pthread_mutex_lock(&seek_mutex);

    // 通过 av_seek_frame() 传入 progress 实现播放对应的帧,但是需要注意,由于该函数会修改 mAVFormatContext
    // 中的内容,而我们处于多线程环境中,需要使用同步以保证 AVFormatContext 内容的线程安全
    // 流的位置传 -1 会让 FFmpeg 自动选择是音频流还是视频流进行 seek
    // 最后一个参数是标记位,有四种选择:
    // AVSEEK_FLAG_BACKWARD(向后参考关键帧)、AVSEEK_FLAG_BYTE、AVSEEK_FLAG_ANY(精确到指定帧)、
    // AVSEEK_FLAG_FRAME(找关键帧,可能你指定的帧与关键帧相隔很多帧,单独使用可能不准确,会与其他模式配合使用)
    // 如果使用 AVSEEK_FLAG_ANY 模式,会 seek 到你指定的帧,但是由于该帧有可能不是关键帧,会出现花屏的情况
    int result = av_seek_frame(avFormatContext, -1, progress * AV_TIME_BASE,
                               AVSEEK_FLAG_BACKWARD | AVSEEK_FLAG_FRAME);
    if (result < 0) {
        LOGE("seek 失败");
        pthread_mutex_unlock(&seek_mutex);
        return;
    }

    // 清除队列中存有的帧,停止播放,跳转到新的位置后再开启
    // 队列接收新位置读取的 AVPacket 和解码后的 AVFrame
    if (audioChannel) {
        audioChannel->packets.setEnable(false);
        audioChannel->frames.setEnable(false);
        audioChannel->packets.clear();
        audioChannel->frames.clear();
        audioChannel->packets.setEnable(true);
        audioChannel->frames.setEnable(true);
    }

    if (videoChannel) {
        videoChannel->packets.setEnable(false);
        videoChannel->frames.setEnable(false);
        videoChannel->packets.clear();
        videoChannel->frames.clear();
        videoChannel->packets.setEnable(true);
        videoChannel->frames.setEnable(true);
    }

    pthread_mutex_unlock(&seek_mutex);
}

演示效果图如下:

相关推荐
Aileen_0v013 小时前
【Gemini3.0的国内use教程】
android·人工智能·算法·开源·mariadb
浩浩的代码花园13 小时前
自研端侧推理模型实测效果展示
android·深度学习·计算机视觉·端智能
踢球的打工仔19 小时前
PHP面向对象(7)
android·开发语言·php
安卓理事人20 小时前
安卓socket
android
马剑威(威哥爱编程)1 天前
鸿蒙6开发视频播放器的屏幕方向适配问题
java·音视频·harmonyos
安卓理事人1 天前
安卓LinkedBlockingQueue消息队列
android
万能的小裴同学1 天前
Android M3U8视频播放器
android·音视频
音视频牛哥1 天前
轻量级RTSP服务的工程化设计与应用:从移动端到边缘设备的实时媒体架构
人工智能·计算机视觉·音视频·音视频开发·rtsp播放器·安卓rtsp服务器·安卓实现ipc功能
❀͜͡傀儡师1 天前
Docker部署视频下载器
docker·容器·音视频
q***57741 天前
MySql的慢查询(慢日志)
android·mysql·adb