FFMPEG基于命令行方式进行RTMP或RTSP(以及最新的WHIP)推流使用很普遍,基本上在桌面或服务器环境(Linux、Windows)下,大家习惯于直接用命令行方式使用,方便移植和调整,包括许多NAS上的视频转码也大多这种方式。

问题:音视频不同步

命令行方式用的人多了,原始的C语言的API方式编码实现就变得稀罕了,基于你去市面上找不到一本正经讲这方面知识的教材或教程,网上的片段,由于FFMPEG最近版本变化非常快,很多都是过时或者“只输出代码不输出知识”——不讲原理,许多代码就这么稀里糊涂的带病上线,凑合用吧。所以许多多媒体、流媒体的应用或服务经常出现各自奇葩问题,大多是由于程序员一知半解搞出来的事。

这不最近有一个项目里,服务端是使用的ZLMmediakit,客户端是Android客户端调用FFMPEG 8.0 JNI方式,实现抓取摄像头、麦克风数据,使用h264mediacodec进行硬件编码,进行RTMP(H264+AAC)推流。

服务端声音和视频都收到了,但明显卡顿异常,用VLC直接播放,视频是实时的,但音频延时甚至达到10秒的问题,就这个问题,我们使用程序员的方式进行debug分析处理。

问题分析

  • 善用ffprobe分析

ffprobe是最适合对ffmpeg产生的视频/视频流进行分析的工具,它能准确打印ffmpeg产生的各类数据的细节,能方便对照源码。

先看下视频基本信息, 使用./ffprobe 视频url地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
./ffprobe   rtmp://172.21.132.230:1935/rtc/test            
ffprobe version 8.0 Copyright (c) 2007-2025 the FFmpeg developers
built with Apple clang version 17.0.0 (clang-1700.0.13.5)
configuration: --enable-openssl --enable-muxer=whip --enable-static --enable-libopus --enable-libx264 --enable-libx265 --enable-nonfree --enable-gpl --disable-x86asm
libavutil 60. 8.100 / 60. 8.100
libavcodec 62. 11.100 / 62. 11.100
libavformat 62. 3.100 / 62. 3.100
libavdevice 62. 1.100 / 62. 1.100
libavfilter 11. 4.100 / 11. 4.100
libswscale 9. 1.100 / 9. 1.100
libswresample 6. 1.100 / 6. 1.100
Input #0, flv, from 'rtmp://172.21.132.230:1935/rtc/test':
Metadata:
|RtmpSampleAccess: true
encoder : Lavf62.3.100
title : Streamed by ZLMediaKit(git hash:a972fa8/2025-08-06T12:02:43+08:00,branch:master,build time:2025-08-07T08:12:29)
Duration: 00:00:00.00, start: 71.000000, bitrate: N/A
Stream #0:0: Data: none
Stream #0:1: Audio: aac (LC), 44100 Hz, stereo, fltp, 128 kb/s, start 71.007000
Stream #0:2: Video: h264 (High), yuv420p(tv, bt470bg/bt470bg/smpte170m, progressive), 1280x720, 2000 kb/s, 30 fps, 30 tbr, 1k tbn, start 71.000000

一眼看出,原始推送的是h264 high profile的,1280x720分辨率,带aac立体声30帧的视频。

先看音频数据,上面的aac, 44100, stereo, fltp分别对应编码器context(相当于编码器的参数配置集)中的codec_id, sample_rate, ch_layout, sample_fmt。

1
2
3
4
5
6
7
8
9
AVCodecID aid = AV_CODEC_ID_AAC;
AVCodec *acodec = (AVCodec *)avcodec_find_encoder(aid);
LOGD("found audio encoder for '%s'", avcodec_get_name(aid));
AVCodecContext *acctx = avcodec_alloc_context3(acodec);
acctx->codec_id = aid;
acctx->codec_type = AVMEDIA_TYPE_AUDIO;
acctx->sample_fmt = AV_SAMPLE_FMT_FLTP;
acctx->ch_layout = AV_CHANNEL_LAYOUT_STEREO;
acctx->sample_rate = 44100;

再看视频, 上面的h264, yuv420p, 1280x720, 30 fps对应视频编码器的codec_id, pix_fmt, width x height,fps。

1
2
3
4
5
6
7
8
9
10
11
AVCodecID vid = AV_CODEC_ID_H264; 
int fps = 30;
vcodec = (AVCodec *)avcodec_find_encoder(vid);
LOGD("found encoder '%s'", avcodec_get_name(vcodec->id));
vcctx = avcodec_alloc_context3(vcodec);
vcctx->codec_id = vcodec->id;
vcctx->width = width;
vcctx->height = height;
vcctx->time_base = (AVRational){ 1, fps };
vcctx->framerate = (AVRational){ fps, 1 };
vcctx->pix_fmt = AV_PIX_FMT_YUV420P;

简单核对下上面的信息是否与预期符合,避免一些笔误的问题造成的异常,下面要进行硬核的部分,时间问题。

  • FFMPEG里关于时间的基本概念

FFMPEG里面的时间问题,各种网文讲的很玄乎,我们不讲复杂的,拿出一个尺子,上面有刻度有值,对应是真实的毫米,厘米。

而FFMPEG里,音频、视频都是随时间流逝往前递增的,也就可以有一把尺子来度量它,也就是FFMPEG里AVPacket的PTS/DTS(index 位置)、Duration(刻度大小)。

但这个刻度不是真实世界里的,并且每种视频格式也都是不一样的。

从摄像头来的原始的YUV420P视频数据(RAW视频),用录制时的帧率来当尺子,FPS为30帧的视频,即1秒分成30格的尺子。

从麦克风来的原始FLTP格式的音频数据(PCM音频),用录制时的采样率业当尺子,sample_rate为 44100Hz的音频,即1秒分成44100格的尺子。

而变成网络视频流后,它还有一个尺子,对于rtmp封装的flv格式,这把尺子是1秒分成1000格,即对应上面的1k tbn。

播放上面的rtmp视频流,如果要实时,并且声音视频“音画同步”,就要求录制时候第1秒的视频和声音,通过编码并打包成flv再通过rtmp协议发送到接收端播放时,也要在第1秒播放出来。
这就是ffmpeg中的time_base所谓时间基的概念,更加理论术语的描述可以查专业文献。

说人话就是:

音视频在不同的尺子上的进度条是要一样的,输入的声音在50%进度条,视频也需要在50%进度条,输出的当然也要在50%进度条。

上代码帮助理解:

1
2
3
4
5
6
7
8
9
10
11
12
avcodec_send_frame(vcctx, vFrame);
vindex = 0;
//...
while(avcodec_receive_packet(vcctx, avPacket) == 0){
//...
avPacket->pts = (vindex++) * (vStream->time_base.den) / ((vStream->time_base.num) * fps);
avPacket->dts = avPacket->pts;
avPacket->duration = (vStream->time_base.den) / ((vStream->time_base.num) * fps);
//...
}
//...
av_interleaved_write_frame(ofmt_ctx, avPacket);

以编码器送进一个视频AVFrame vFrame(yuv420p),得到一个AVPacket avPacket(h264)为例,如果这是第0个帧(包)vindex=0,它是(1/30)的尺子(原视频)上的第1个,那么它在(1/1000)的尺子(flv视频)上也应该是第0个,pts就是0(dts本文暂时不关注,它与更高级的编码方式有关,这里简单等同于pts)。

那第原视频第2,3,4…帧呢,公式是这样来的:

a是输入的视频帧编号vindex,需要一直++递增;b是输出的视频帧编号,avPacket->pts。

为了保持进度条一致,需要a * (1/30fps) = b * (1/1000tbn)
那么,b = a * (1000tbn/(1*30fps)) = a * 33,也就对应

1
avPacket->pts = (vindex++) * (vStream->time_base.den) / ((vStream->time_base.num) * fps);

上面是为了简化,只讲进度条进度,为了保证声音图像不异常,这一帧视频持续时间(在尺子上占的比例),在不同比例的尺子上也需要一致,这就是duration值。

原始视频的duration就是,30fps的视频,每一帧时长(1/30)秒,为了让目标flv视频每一帧也播放同样时长,需要在目标尺子上乘一个系数n才能在不同尺子上占的比例相同,公式是:

(1/30fps)秒 = n * (1/1000tbn)秒 求n值, n = 1000tbn/30fps = 33ms

则:avPacket->duration = (vStream->time_base.den) / ((vStream->time_base.num) * fps);

!!注意音频的不同

对于音频,把上面的fps概念换成音频录音里的采样率,例如44100,即,原始音频每一帧是(1/44100)秒。

但由于音频不是一帧一帧传递的,而是一个打包AVFrame里有nb_samples个音频帧,所以这里就非常容易出现由于概念理解有误,产生的异常声音图象不同步的问题了。

对照视频,我们做同样的pts的计算,上代码:

1
2
3
4
5
6
7
8
9
10
11
12
int aindex = 0;
while(avcodec_receive_packet(acctx, aPacket) == 0){
//...
aPacket->pts = av_rescale_q_rnd(
(aindex++) * aFrame->nb_samples, acctx->time_base, aStream->time_base,
(AVRounding)(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
aPacket->dts = aPacket->pts;
aPacket->duration = av_rescale_q_rnd(aFrame->nb_samples, acctx->time_base,
aStream->time_base, (AVRounding)(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
av_interleaved_write_frame(ofmt_ctx, aPacket);
//...
}

先了解下av_rescale_q_rnd,它的函数定义是:
int64_t av_rescale_q_rnd(int64_t a, AVRational bq, AVRational cq, AVRounding rnd)
即,把a对应的原始pts(时间基是bq),转到到以时间基cq为标准的新pts值,即上面我们手写公式的ffmpeg预封装实现。

由于上文我们讲了,音频的一个AVFrame、AVPacket里,包含了nb_samples个帧,所以不能简单用上文视频的方式来计算,每编码一个音频Frame,它的时间尺子前进了nb_samples格(以AAC为例,就是1024格,通过它的编码器context的frame_size来获取,取不到以默认值1024计。即:aFrame->nb_samples = acctx->frame_size? acctx->frame_size : 1024)。

所以第n个音频包,计算它的输出pts值需要用视频公式基础上,再乘以nb_samples。

a * (1/44100hz) = b * (1/1000tbn)
b = a * (1000tbn/(1*44100hz)) = a * 0.02267574
newpts = 1024 * b = a * 23

它的输出duration也一样需要乘以nb_samples。
(1/44100hz)秒 = b * (1/1000tbn)秒,
b = 1000tbn/44100hz = 0.02267574s
newdur = 1024 * b = 23ms

  • 问题定位与解决

在源码中打开日志:

1
2
3
4
5
6
7
8
9
10
11
videoTimestamp = cur_pts_v * av_q2d(vStream->time_base);
audioTimestamp = cur_pts_a * av_q2d(aStream->time_base);

LOGD("Send audio frame index:%d,pts:%lld dur:%lld %lf %lf",
aindex, (long long) aPacket->pts, aPacket->duration, videoTimestamp, audioTimestamp);

LOGD("Send video frame index:%d,pts:%lld,dts:%lld,duration:%lld",
index,
(long long) avPacket->pts,
(long long) avPacket->dts,
(long long) avPacket->duration);

重点是显示发送音频和视频时的index、pts和dur:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
12:06:19.345  D  Send audio frame index:0,pts:0  dur:23 0.033000 0.000000
12:06:19.397 D Send audio frame index:1,pts:23 dur:23 0.033000 0.000000
12:06:19.435 D Send audio frame index:2,pts:46 dur:23 0.033000 0.023000
12:06:19.437 D Send video frame index:2,pts:66,dts:66,duration:33 0.033000 0.046000
12:06:19.439 D Send video frame index:3,pts:100,dts:100,duration:33 0.033000 0.046000
12:06:19.475 D Send audio frame index:3,pts:70 dur:23 0.100000 0.046000
12:06:19.516 D Send audio frame index:4,pts:93 dur:23 0.100000 0.070000
12:06:19.556 D Send audio frame index:5,pts:116 dur:23 0.100000 0.093000
12:06:19.558 D Send video frame index:4,pts:133,dts:133,duration:33 0.100000 0.116000
12:06:19.560 D Send video frame index:5,pts:166,dts:166,duration:33 0.100000 0.116000
12:06:19.595 D Send audio frame index:6,pts:139 dur:23 0.166000 0.116000
12:06:19.635 D Send audio frame index:7,pts:163 dur:23 0.166000 0.139000
12:06:19.675 D Send audio frame index:8,pts:186 dur:23 0.166000 0.163000
12:06:19.677 D Send video frame index:6,pts:200,dts:200,duration:33 0.166000 0.186000
12:06:19.681 D Send video frame index:7,pts:233,dts:233,duration:33 0.166000 0.186000
12:06:19.716 D Send audio frame index:9,pts:209 dur:23 0.233000 0.186000
12:06:19.755 D Send audio frame index:10,pts:232 dur:23 0.233000 0.209000
12:06:19.795 D Send audio frame index:11,pts:255 dur:23 0.233000 0.232000

可以看到,audio和video的index都在正常递增(即视频的帧和音频的帧*1024);
每个视频包pts值增加33(四舍五入的),dur是33ms没有问题;
每个语音包pts什增加23,dur是22ms也没问题。

videoTimestamp和audioTimestamp是以输出flv视频(1/1000)为时间基的,即ms,表示从推流开始的真实时间值,两个值偏差不大则表示音视频是基本同步的。
为达成这个效果,一般是编码时原始视频和音频数据都要进fifo(av_fifo和av_audio_fifo),在线程中取出时,比较当前audio和video的输出的pts先后顺序,哪个落后先编码哪个。

1
2
3
4
5
6
if (av_compare_ts(cur_pts_v, vStream->time_base,
cur_pts_a, aStream->time_base) <= 0){
//encode and send video
}else{
//encode and send audio
}

看不出太大问题,再从播放端分析,再次掏出ffprobe:

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
./ffprobe  -show_frames rtmp://172.21.132.230:1935/rtc/test | grep -E '(media_type|pts|dur)'
media_type=video
pts=26000
pts_time=26.000000
duration=N/A
duration_time=N/A
media_type=audio
pts=26006
pts_time=26.006000
duration=23
duration_time=0.023000
media_type=audio
pts=26030
pts_time=26.030000
duration=23
duration_time=0.023000
media_type=video
pts=26033
pts_time=26.033000
duration=N/A
duration_time=N/A
media_type=audio
pts=26053
pts_time=26.053000
duration=23
duration_time=0.023000
media_type=video
pts=26066
pts_time=26.066000
duration=N/A
duration_time=N/A
media_type=audio
pts=26076
pts_time=26.076000
duration=23
duration_time=0.023000
media_type=audio
pts=26099
pts_time=26.099000
duration=23
duration_time=0.023000
media_type=video
pts=26100
pts_time=26.100000
duration=N/A
duration_time=N/A
media_type=audio
pts=26122
pts_time=26.122000
duration=23
duration_time=0.023000

可以看到,audio的pts递增大小是23,dur是33ms,video是33,dur是23ms。
那么问题不出在打包发送阶段,还是回到源头。

  • Android录音采集部分

看了下源码,录音部分采用的网上随处可见的代码块,采用的44100Hz采样率,立体声,更为通用的16bit打包pcm格式:

1
2
3
4
5
6
7
final int mMinBufferSize =
AudioRecord.getMinBufferSize(44100, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
AudioRecord audioRecord = new AudioRecord(
MediaRecorder.AudioSource.DEFAULT,
DEFAULT_SAMPLE_RATE, DEFAULT_CHANNEL_LAYOUT, DEFAULT_SAMPLE_FORMAT,
mMinBufferSize);
audioRecord.startRecording();

然后在线程里循环读取,调用JNI接口发送:

1
2
3
4
5
6
7
byte[] buffer = new byte[mMinBufferSize];
while(true){
int readsize = audioRecord.read(buffer, 0, mMinBufferSize);
if(readsize > 0 && isRecording){
sendAudio(buffer);
}
}

JNI中处理也很简单,读取后进audio fifo:

1
2
3
4
5
6
7
8
9
10
11
12
13
Java_cn_mbstudio_mewhip_MainActivity_sendAudio(JNIEnv *env, 
jclass clazz, jbyteArray data) {

int len = env->GetArrayLength (data);
int inputAudioSamples = len / 4; //PCM,立体声,非打包,每一个Frame中包含的单声道采样次数
unsigned char* buf = new unsigned char[len];
env->GetByteArrayRegion(data, 0, len, reinterpret_cast<jbyte*>(buf));

AVAudioFifo *aFifo = av_audio_fifo_alloc(AV_SAMPLE_FMT_S16, 2, inputAudioSamples);
av_audio_fifo_write(aFifo, (void **)&buf, inputAudioSamples);

//...
}

再看FFMPEG将上述原始PCM编码成AAC发送的部分(不再重复上面的PTS设置部分):

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
//分配待编码的AVFrame,格式就是AAC编码器context(acctx)要求的PCM参数值
AVCodecID aid = AV_CODEC_ID_AAC;
acodec = (AVCodec *)avcodec_find_encoder(aid);
acctx = avcodec_alloc_context3(acodec);
acctx->codec_id = aid;
acctx->codec_type = AVMEDIA_TYPE_AUDIO;
acctx->sample_fmt = acodec->sample_fmts?acodec->sample_fmts[0]:AV_SAMPLE_FMT_FLTP;
acctx->ch_layout = AV_CHANNEL_LAYOUT_STEREO;
acctx->sample_rate = 44100;
//...

aFrame = av_frame_alloc();
aFrame->format = acctx->sample_fmt;
aFrame->ch_layout = acctx->ch_layout;
aFrame->sample_rate = acctx->sample_rate;
aFrame->nb_samples = acctx->frame_size ? acctx->frame_size: inputAudioSamples;
av_frame_get_buffer(aFrame, 0);

SwrContext *m_swrCtx = swr_alloc();
AVChannelLayout stero = AV_CHANNEL_LAYOUT_STEREO;
swr_alloc_set_opts2(&m_swrCtx,
//aac settings
(const AVChannelLayout *)&aFrame->ch_layout, (AVSampleFormat)aFrame->format, aFrame->sample_rate,
//android pcm settings
&stero, AV_SAMPLE_FMT_S16, 44100,
0, NULL);
ret = swr_init(m_swrCtx);
if (ret < 0) {
LOGE("error init swr:%s", av_err2str(ret));
return nullptr;
}

//...

if(av_audio_fifo_size(aFifo) >= inputAudioSamples){
pthread_mutex_lock(&afLock);
av_audio_fifo_read(aFifo, (void **)&(buf), inputAudioSamples);
pthread_mutex_unlock(&afLock);

ret = av_frame_make_writable(aFrame);
if(ret < 0){
LOGE("av frame error:%s", av_err2str(ret));
break;
}

//make sure: dst_nb_samples / dest_sample = src_nb_sample / src_sample_rate
int dst_nb_samples = av_rescale_rnd(
swr_get_delay(m_swrCtx, aFrame->sample_rate) + aFrame->nb_samples,
acctx->sample_rate/*dst*/, aFrame->sample_rate/*src*/, AV_ROUND_UP);

//av_assert0(dst_nb_samples == aFrame->nb_samples);

swr_convert(m_swrCtx, aFrame->data, aFrame->nb_samples,
(const uint8_t **) &(buf), inputAudioSamples);

//avcodec_send_frame ...
//avcodec_receive_packet ...
//set pts, dts, duration ...
//av_interleaved_write_frame ...
}

由于新版本FFMPEG不再支持Android录音采集到的16BIT打包PCM,要求的是AV_SAMPLE_FMT_FLTP, 是16BIT分片PCM,所以要swr重采样转换一下。

这和Android摄像头采集的是NV21或ImageFormat.YUV_420_888,而不是FFMPEG视频编码器要求的输入数据是YUV420P(AV_PIX_FMT_YUV420P),需要libyuv转换或sws_scale转换是一个逻辑。

看到一行可疑代码:
//av_assert0(dst_nb_samples == aFrame->nb_samples);

问了原因就是这里会崩溃……所以注释掉了……

按道理,AAC编码器要求的nb_samples是1024,Android那边也应该是每次送102422 (16bit需x2, 立体声需x2,打包方式,PCM就是LRLRLRLR的数据,buffer长度简单除4就是每次读取的nb_samples)。
打印一下Android送进JNI的数据长度,发现是7104,除以4就是1776……

问就是Android开发文档要求的:

1
bufferSizeInBytes – the total size (in bytes) of the buffer where audio data is written to during the recording. New audio data can be read from this buffer in smaller chunks than this size. See getMinBufferSize(int, int, int) to determine the minimum required buffer size for the successful creation of an AudioRecord instance. Using values smaller than getMinBufferSize() will result in an initialization failure.

所以Android那边音频采集的缓冲区是7104,每次也读取了7104,然后到了JNI这边强行截取了1776的前面的1024数据进行编码……

上述程序员因为不明白FFMPEG的音频编码的参数真实含义,以及SWR的真实作用,就胡乱调试到不奔溃就行(道理是这么个道理),结果就是代码以奇怪的方式跑起来了……起来了……来了……了……

知道了原因,只需要修改Android每次发送到JNI的数据长度(即PCM帧数),让AVFrame保证每帧里面有AAC要求的1024个nb_samples,就能正常跑起来了。

1
2
3
4
5
6
7
8
int buffsize = 1024*4;    //AV_SAMPLE_FMT_S16 4096/2(stero)/2(16bit)=1024
byte[] buffer = new byte[buffsize];
while(true){
int readsize = audioRecord.read(buffer, 0, buffsize);
if(readsize > 0 && isRecording){
sendAudio(buffer);
}
}