精确的音视频同步是媒体播放的关键体验之一。通常来说,在录制音频和视频时需要做到同步,并且录制后的视频在播放设备(例如手机、电视、媒体播放器)上播放时也需要做到同步。
根据当前HarmonyOS APP开发过程中遇到的音画同步业务场景,总结出如下两种典型场景:
从上述两种典型场景出发,本文旨在指导第三方视频播放应用正确获取并使用音频和视频相关信息来保证播放时的音视频同步。
如果开发者使用自研播放器引擎而非AVPlayer,也可以参考该解决方案思路实现优化。
音视频数据的最小处理单元称为帧。音频流和视频流都被分割成帧,所有帧都被标记为需要按特定的时间戳显示。音频和视频可以独立下载和解码,具有匹配时间戳的音频和视频帧应同时呈现,以达到音画同步的效果。
| 帧 | 解释 |
|---|---|
| I帧(I-frame) | 帧内编码帧(intra picture)或关键帧(key frame)。I帧是一个独立编码的帧,可以独立解码,不依赖其他帧数据 |
| P帧(P-frame) | 前向预测编码帧(predictive-frame)。P帧是前向预测帧,编码过程依赖了前序帧进行预测,解码过程依赖前序帧才能正确解码重建 |
| B帧(B-frame) | 双向预测编码帧(bi-directional prediction frame)。B帧也是一个压缩帧,编码过程参考了前向和后向的帧进行预测 |
时间差 = 视频播放时间 - 音频播放时间
| 时间差范围 | 主观体验 |
|---|---|
| [-80ms, 25ms] | 无法察觉 |
| [-125ms, -80ms], [25ms, 45ms] | 能够察觉 |
| [-185ms, -125ms], [45ms, 90ms] | 无法接受 |
理论上,因为音频通路存在时延,要保证播放时的音视频同步,有三种解决方案可用:
三种方案的优缺点对比如下:
| 方案名称 | 优点 | 缺点 |
|---|---|---|
| 连续播放音频帧 | 1. 用户肉眼的敏感度较弱,不易察觉视频微小的调整。 2. 视频刷新时间的调整相对便捷。 | 如果视频帧率不稳定或渲染延迟大,可能导致视频卡顿或跳帧。 |
| 使用系统时间作为参考 | 可以最大限度的保证音频和视频都不发生跳帧行为。 | 1. 需要额外依赖系统时钟,增加了系统复杂性和维护成本。 2. 系统时钟的准确性对同步效果影响较大,如果系统时钟不准确,可能导致同步效果大打折扣。 |
| 使用视频播放位置作为参考 | 音频可根据视频帧进行调整,减少音频跳帧的情况。 | 1. 音频播放可能会出现等待或加速的情况,相较于视频会对用户的影响更为严重和明显。 2. 如果视频帧率不稳定,可能导致音频同步困难。 |
第一个方案是具有连续音频数据流的选项,其没有对音频帧的显示时间、播放速度或持续时间进行任何调整。这些参数的任何调整都很容易被人耳注意到,并导致音频干扰故障。处理这些故障需要对音频重新采样,然而重新采样会导致音调的改变。
因此,一般的多媒体应用多使用音频播放位置作为主时间参考。以下章节主要以此解决方案进行说明(其他两个选项不在本文档的范围内)。
该方案使用音频播放位置作为主时间参考,并将视频播放位置与其匹配,使音画同步指标达到用户无法察觉的[-80ms, 25ms]范围。
解决方案使用:
最终实现音画同步[-80ms, 25ms]的效果。
连续播放音帧方案示意图

音频和视频的管道必须同时以相同的时间戳呈现每帧数据。将音频播放位置用作主时间参考,而视频管道只输出与最新渲染音频帧匹配的视频帧。对于所有可能的实现,精确计算最后一次呈现的音频时间戳是至关重要的。OH_AudioRenderer_GetTimestamp() 接口用以查询音频管道各个阶段的音频时间戳和延迟信息,此信息可用于控制视频管道,使视频帧与音频帧匹配。
基于以上示意图,具体来说,在监听到视频帧的时候,首先去获取当前音频渲染位置,在获取成功的情况下计算该视频帧PTS与当前音频渲染位置的延迟时间,对延迟时间进行如下判断确定送显策略。
系统中提供了PhotoViewPicker()接口用于获取本地视频资源。三方视频播放应用可以使用OH_AVSource_CreateWithFD()接口对本地视频资源进行解封装,获取和使用对应的音频和视频信息后,使用连续播放音频帧方案进行音频和视频的播放同步。具体开发步骤如下所示。
使用PhotoViewPicker()获取本地视频资源。
static async selectFileFromLocal(): Promise<string | undefined> {
try {
let result = await FileUtil.getPhotoViewPicker().select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE,
maxSelectNumber: 1
})
let selectFilePath = result.photoUris[0];
if (!selectFilePath) {
return undefined
}
return selectFilePath;
} catch (e) {
return undefined;
}
} 使用OH_AVSource_CreateWithFD()对本地视频资源进行解封装处理,获取压缩的音频码流和视频码流。
int32_t Demuxer::Create(SampleInfo &info) {
if (info.isLocal) {
source = OH_AVSource_CreateWithFD(info.inputFd, info.inputFileOffset, info.inputFileSize);
} else {
source = OH_AVSource_CreateWithURI(info.networkUri);
}
CHECK_AND_RETURN_RET_LOG(source != nullptr, AVCODEC_SAMPLE_ERR_ERROR,
"Create demuxer source failed, fd: %{public}d, offset: %{public}" PRId64
", file size: %{public}" PRId64,
info.inputFd, info.inputFileOffset, info.inputFileSize);
demuxer = OH_AVDemuxer_CreateWithSource(source);
// ...
} 进行音视频解码,将压缩数据转换为可播放的原始数据。
收到视频帧的时候,通过调用OH_AudioRenderer_GetTimestamp()接口获取音频渲染位置等信息。
int64_t framePosition = 0; int64_t timeStamp = 0; int32_t ret = OH_AudioRenderer_GetTimestamp(audioRenderer, CLOCK_MONOTONIC, &framePosition, &timeStamp); audioTimeStamp = timeStamp;
音频启动前,音频播放时间timestamp和音频播放位置framePosition返回结果为0。为避免出现卡顿等问题,暂不同步,视频帧直接送显。
if (ret != AUDIOSTREAM_SUCCESS || (timeStamp == 0) || (framePosition == 0)) {
// first frame, render without wait
videoDecoder->FreeOutputBuffer(bufferInfo.bufferIndex, true);
std::this_thread::sleep_until(lastPushTime + std::chrono::microseconds(sampleInfo.frameInterval));
lastPushTime = std::chrono::system_clock::now();
continue;
} 根据视频帧PTS和音频渲染位置计算延迟。
int64_t latency =
(audioDecContext->frameWrittenForSpeed - framePosition) * 1000 * 1000 / sampleInfo.audioSampleRate / speed;
AVCODEC_SAMPLE_LOGI("VD latency: %{public}ld writtenSampleCnt: %{public}ld", latency, writtenSampleCnt);
nowTimeStamp = GetCurrentTime();
int64_t anchorDiff = (nowTimeStamp - audioTimeStamp) / 1000;
int64_t audioPlayedTime = audioDecContext->currentPosAudioBufferPts - latency + anchorDiff;
int64_t videoPlayedTime = bufferInfo.attr.pts;
int64_t waitTimeUs = videoPlayedTime - audioPlayedTime; 根据业务延迟做音画同步策略。
if (waitTimeUs < WAIT_TIME_US_THRESHOLD_WARNING) {
dropFrame = true;
AVCODEC_SAMPLE_LOGE("VD buffer is too late");
} else {
AVCODEC_SAMPLE_LOGE("VD buffer is too early waitTimeUs: %{public}ld", waitTimeUs);
if (waitTimeUs > WAIT_TIME_US_THRESHOLD) {
waitTimeUs = WAIT_TIME_US_THRESHOLD;
}
if (waitTimeUs > sampleInfo.frameInterval + PER_SINK_TIME_THRESHOLD) {
waitTimeUs = sampleInfo.frameInterval + PER_SINK_TIME_THRESHOLD;
AVCODEC_SAMPLE_LOGE("VD buffer is too early and reduced 33ms, waitTimeUs: %{public}ld", waitTimeUs);
}
} 进行音画渐进同步。视频帧较早时,等待一段时间送显。
if (waitTimeUs > 0) {
std::this_thread::sleep_for(std::chrono::microseconds(waitTimeUs));
}
lastPushTime = std::chrono::system_clock::now();
ret = videoDecoder->FreeOutputBuffer(bufferInfo.bufferIndex, !dropFrame);
系统提供了OH_AVSource_CreateWithURI()接口,可以对网络视频资源直接进行解封装并获取对应的音频和视频信息,然后使用连续播放音频帧方案进行音频和视频的播放同步。具体开发步骤如下所示。
使用OH_AVSource_CreateWithURI()接口对网络资源进行解封装处理,获取音频和视频信息。
int32_t Demuxer::Create(SampleInfo &info) {
if (info.isLocal) {
source = OH_AVSource_CreateWithFD(info.inputFd, info.inputFileOffset, info.inputFileSize);
} else {
source = OH_AVSource_CreateWithURI(info.networkUri);
}
CHECK_AND_RETURN_RET_LOG(source != nullptr, AVCODEC_SAMPLE_ERR_ERROR,
"Create demuxer source failed, fd: %{public}d, offset: %{public}" PRId64
", file size: %{public}" PRId64,
info.inputFd, info.inputFileOffset, info.inputFileSize);
demuxer = OH_AVDemuxer_CreateWithSource(source);
// ...
} 解封资源得到压缩的音频码流和视频码流后,参考音频解码、视频解码进行解码,再根据本章本地视频的音画同步的开发步骤中第3~7步进行音画同步播放。