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 的初始化:

/**
 * 初始化音频数据,主要是计算 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 开始工作:

void VideoPlayer::start() {
    isPlaying = true;

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

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

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

/**
 * 开启解码和播放线程
 */
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 队列中:

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 播放音频,先按照如下步骤设置:

/**
 * 播放线程的任务,是配置 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 的队列中:

/**
 * 回调函数,将每一帧音频数据存入 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 的大小并传入:

/**
 * 从 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 就会不同:

2024-1-7.GOP与DTS与PTS

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

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

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

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

/**
 * 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 微秒 。

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

AVRational time_base = stream->time_base;

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

// 获取平均帧率
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 时将时间基传入。视频通道还要追加帧率,这些都是音视频同步所需的参数:

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 以获取音频播放的时间戳:

void VideoPlayer::start() {
    ...

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

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

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

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

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

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 交给外界定制的:

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 两个队列设置了回调:

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 方法查询视频的时长,成功获取后才显示进度条:

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 层查询到的视频时长:

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

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

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

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

Native 回调上层还是借助 JNICallbackHelper:

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 将时间戳回调给上层:

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 中:

	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 更新进度条和时间:

	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 层:

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 函数:

	fun seek(progress: Long) {
        nativeSeek(progress)
    }

	private external fun nativeSeek(progress: Long)

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

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 队列清空再重新接收解码帧:

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);
}

演示效果图如下:

2024-4-21.视频播放器效果1

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/585833.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

使 Elasticsearch 和 Lucene 成为最佳向量数据库:速度提高 8 倍,效率提高 32 倍

作者&#xff1a;来自 Elastic Mayya Sharipova, Benjamin Trent, Jim Ferenczi Elasticsearch 和 Lucene 成绩单&#xff1a;值得注意的速度和效率投资 我们 Elastic 的使命是将 Apache Lucene 打造成最佳的向量数据库&#xff0c;并继续提升 Elasticsearch 作为搜索和 RAG&a…

Jenkins自动化搭建记录

每一份努力都是有一份期盼&#xff0c;每一份付出都是为了有更多的收获。 本文记录一次搭建Jenkins自动参数化打包APK的实现过程和碰到的问题&#xff0c;实现了在Windows和Mac系统下的自动化打包流程。 因为Jenkins的安装过程在网上的教程很多&#xff0c;这里就不在赘述。 …

使用 LlamaIndex 和 Llama 2-Chat 构建知识驱动的对话应用程序

文章目录 使用 LlamaIndex 和 Llama 2-Chat 构建知识驱动的对话应用程序Llama 2-70B-聊天LlamaIndex 解决方案概述先决条件使用 SageMaker JumpStart 部署 GPT-J 嵌入模型使用 SageMaker Python SDK 进行部署在 SageMaker Studio 中使用 SageMaker JumpStart 进行部署使用 Sage…

Dashboard 介绍

Dashboard 介绍 一、K8S Dashboard简介 简单的说&#xff0c;K8S Dashboard是官方的一个基于WEB的用户界面&#xff0c;专门用来管理K8S集群&#xff0c;并可展示集群的状态。K8S集群安装好后默认没有包含Dashboard&#xff0c;我们需要额外创建它 二、RABC简介 还是那句话&a…

关于下载上传的sheetjs

一、背景 需要讲后端返回来的表格数据通过前端设置导出其中某些字段&#xff0c;而且得是xlsx格式的。 那就考虑使用控件SheetJS。如果是几年前&#xff0c;一般来说&#xff0c;保存excel的文件都是后端去处理&#xff0c;处理完成给前端一个接口&#xff0c;前端调用了打开…

SQLite的扩展函数Carray()表值函数(三十八)

返回&#xff1a;SQLite—系列文章目录 上一篇:SQLite如何处理CSV 虚拟表 下一篇&#xff1a;SQLite—系列文章目录 ​ 1. 概述 Carray()是一个具有单列的表值函数(名为 “value”)和零行或多行。 carray() 中每一行的“值”取自 C 语言数组 由应用程序通过参数绑定提…

如何进行面向对象分析、面向对象设计和面向对象编程

目录 1.引言 2.案例介绍和难点剖析 3.如何进行面向对象分析 4.如何进行面向对象设计 5.如何进行面向对象编程 6.总结 1.引言 面向对象分析(OOA)、面向对象设计(00D)和面向对象编程(OOP)是面向对象开发的3个主要环节。 在以往的工作中&#xff0c;作者发现&#xff0c;很多…

JavaScript原型链深度剖析

目录 前言 一、原型链 1.原型链的主要组成 原型&#xff08;Prototype&#xff09; 构造函数&#xff08;Constructor&#xff09; 实例&#xff08;Instance&#xff09; 2.原型链的工作原理 前言 在JavaScript的世界中&#xff0c;原型链&#xff08;Prototype Chain&…

Amazon云计算AWS之[4]非关系型数据库服务SimpleDB和DynamoDB

文章目录 简介非关系型VS关系数据库SimpleDB域条目属性值SimpleDB的使用 DynamoDBSimpleDB VS DynamoDB 简介 非关系型数据库服务主要用于存储结构化的数据&#xff0c;并为这些数据提供查找、删除等基本的数据库功能。AWS中提供的非关系型数据库主要包括SimpleDB和DynamoDB …

聚醚醚酮(Polyether Ether Ketone)PEEK在粘接使用时可以使用UV胶水吗?要注意哪些事项?

一般情况下&#xff0c;聚醚醚酮&#xff08;Polyether Ether Ketone&#xff0c;PEEK&#xff09;是一种难以黏附的高性能工程塑料&#xff0c;而UV胶水通常不是与PEEK进行粘接的首选方法。PEEK表面的化学性质和高温性能使得它对常规胶水的附着性较低。然而&#xff0c;有一些…

(成品论文22页)24深圳杯数学建模A题1-4问完整代码+参考论文重磅更新!!!!

论文如下&#xff1a; 基于三球定位的多个火箭残骸的准确定位 针对问题一&#xff1a;为了进行单个残骸的精确定位&#xff0c;确定单个火箭残骸发生音爆 时的精确位置和时间&#xff0c;本文基于三球定位模型&#xff0c;考虑到解的存在性和唯一性&#xff0c; 选取了四个监测…

用HTML5实现播放gif文件

用HTML5实现播放gif文件 在HTML5中&#xff0c;你可以使用<img>标签来播放GIF文件。GIF文件本质上是一种图像格式&#xff0c;它支持动画效果&#xff0c;因此当在网页上加载时&#xff0c;它会自动播放动画。先看一个简单的示例&#xff1a; <!DOCTYPE html> &l…

清华同方电脑文件删除怎么恢复

在日常使用清华同方电脑的过程中&#xff0c;我们难免会遇到误删重要文件的情况。文件丢失不仅可能导致数据损失&#xff0c;还可能影响到我们的工作、学习甚至是生活。那么&#xff0c;当在清华同方电脑上删除了重要文件后&#xff0c;我们应该如何恢复呢&#xff1f;本文将为…

Linux服务器安全基础 - 查看入侵痕迹

1. 常见系统日志 /var/log/cron 记录了系统定时任务相关的日志 /var/log/dmesg 记录了系统在开机时内核自检的信息&#xff0c;也可以使用dmesg命令直接查看内核自检信息 /var/log/secure:记录登录系统存取数据的文件;例如:pop3,ssh,telnet,ftp等都会记录在此. /var/log/btmp:记…

服务器被攻击,为什么后台任务管理器无法打开?

在服务器遭受DDoS攻击后&#xff0c;当后台任务管理器由于系统资源耗尽无法打开时&#xff0c;管理员需要依赖间接手段来进行攻击类型的判断和解决措施的实施。由于涉及真实代码可能涉及到敏感操作&#xff0c;这里将以概念性伪代码和示例指令的方式来说明。 判断攻击类型 步…

C#---使用Coravel实现定时任务

Coravel是一款框架轻&#xff0c;使用简单&#xff0c;支持秒级定时任务。 1.添加NuGet引用 2.定义自己的工作任务 using Coravel.Invocable; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Thread…

【Java笔记】JVM:对象在内存中是什么样的?如何计算对象占用的内存大小?

文章目录 Java对象的内存布局计算对象占用的内存大小Openjdk jol来算几个Object o new Object() 该对象在内存中占用多少字节&#xff1f;基本数据类型作为成员变量的对象有实例对象作为成员变量的对象 Java对象的内存布局 Java中&#xff0c;一个实例对象在内存中的组成主要包…

国家开放大学2024年春《Matlab语言及其应用》实验五Simulink系统 建模与仿真参考答案

答案&#xff1a;更多答案&#xff0c;请关注【电大搜题】微信公众号 答案&#xff1a;更多答案&#xff0c;请关注【电大搜题】微信公众号 答案&#xff1a;更多答案&#xff0c;请关注【电大搜题】微信公众号 实验报告 姓名&#xff1a; 学号&#xff1a; 实验五名称…

【JAVA进阶篇教学】第九篇:MyBatis-Plus用法介绍

博主打算从0-1讲解下java进阶篇教学&#xff0c;今天教学第九篇&#xff1a;MyBatis-Plus用法介绍。 在 MyBatis-Plus 3.5.0 中&#xff0c;LambdaQueryWrapper支持多种条件构造方式&#xff0c;除了等于&#xff08;eq&#xff09;、不等于&#xff08;ne&#xff09;、大于&a…

大模型公开课-大模型的语言解码游戏学习总结

在当今快速发展的人工智能领域&#xff0c;深度学习作为其中的一项关键技术&#xff0c;正引领着科技的新潮流。而对于初学者来说&#xff0c;了解大型语言模型的解码游戏&#xff0c;对于理解深度学习的基本概念至关重要。本篇博客将对一次关于大型语言模型解码游戏的视频教学…