1. 从一次真实的“音画不同步”说起——时间戳到底管什么
有一次我排查一个直播App的卡顿问题,音频比画面稳定快个几百毫秒,连着看了两天日志才确认,问题不在网络也不在解码器,而在推流端一个看起来毫不起眼的字段:时间戳。这让我意识到,很多人(包括当时的我)嘴上说懂音视频,其实对时间戳的理解只停留在“给数据标个时间”这个层面。
如果你正在做音视频开发、直播推流、播放器、视频剪辑相关的工作,或者准备面试音视频岗位,这篇内容都值得仔细看看。时间戳是音视频系统的“地基”,PTS、DTS、PCR这些名词背后是一整套同步机制。地基没打牢,后面所有层都会出问题,而且问题表现五花八门:画面卡顿、音频超前、花屏、直播断流后无法续播、转封装后播放器不认,最终都可能追溯到时间戳。
这篇文章不打算摆一堆教科书定义,我只想用一个又一个实际场景,把时间戳的来龙去脉讲透:它包含哪些字段,每个字段解决什么问题,在各个平台和容器里又该怎么换算,最后再给出我这些年踩坑总结出来的排查思路。内容偏底层,但都是实打实能用的经验。
1.1 我当时踩过的一个真坑
先说那个直播App的问题。现象是:主播用某台Android设备推流的时候,观众端每隔几分钟就会出现一次“音频比画面快几百毫秒”的情况,持续十几秒又自己恢复。换成另一台设备推流,问题完全消失。
我的第一反应是弱网或者丢包,于是抓包、看网络抖动曲线,折腾了大半天,网络状况干净得不像话。后来我把推流端的音视频帧PTS全部打出来,写了个脚本拉时间线,才发现一个关键细节:音频编码器输出的每帧AAC数据是1024个采样点,采样率48000Hz,所以每帧的真实时长应该是 1024 ÷ 48000 ≈ 21.33ms,但推流端打PTS的时候,用的是“毫秒取整”,每帧按21ms累加,而且取整方式不是四舍五入,是直接丢小数。
别小看这0.33ms的误差,一帧只差0.33ms,一秒钟有大约47帧音频,每秒误差就是15ms,一分钟误差接近1秒。所以观众端听到的音频会越来越快,当累积误差超过播放器容错阈值时,播放器会做一次同步校正,于是又恢复正常,过几分钟再次偏离。整个过程看起来就像“周期性抽风”。
后来我把音频PTS改成按采样点计数累加,也就是每一帧直接加1024,时间基保持48000Hz不换算,这个问题彻底消失。
这个案例特别典型,因为它不是某个复杂协议造成的,就是最基础的“时间戳推进方式错了”。实际上音视频开发里大量的诡异问题,根子上都是这种不起眼的小错误。时间戳这东西,只有真正被坑过,才会认识到它的分量。
1.2 时间戳不是“时间数字”,而是“消费节奏表”
很多人以为时间戳就是“数据产生时的墙上时间”,比如2025年6月1日12:00:00。但在音视频世界里,大部分时间戳根本不是这个意思,它是一个“计数”。
打个比方:你在电影院看电影,电影胶片上每一帧画面右侧都印着一个序号,放映机只需要按照序号从小到大播放,就能还原完整动作。时间戳就是那个“序号”,只不过它的单位不是“帧”,而是“时钟tick”。
为什么要用tick而不是真实时间?因为音视频数据在采集、编码、传输、解码的每个环节,都运行在不同设备上,设备之间的系统时钟是不可信的。北京服务器的时钟和用户手机的时钟可能差几秒甚至几分钟,如果直接用墙上时间同步,画面永远对不上。所以业内约定:大家不谈“几点了”,只谈“按某个参考频率走了多少个tick”。这个tick的频率,就是时间基(timescale),也就是“每秒钟分多少份”。
理解了这一点,再看“音频快几百毫秒”的问题就会清晰很多:推流端给音频数据标记的tick推进速度和实际播放消耗的速度不一致,消费端按照tick来安排播放,自然就快起来了。时间戳真正要告诉我们的是:这份数据应该“在哪个节奏点”被消费,而不是“在哪个时刻”被生产。
2. 拆开时间戳:PTS、DTS、PCR三兄弟的分工与换算逻辑
音视频时间戳体系里,最常念叨的三个英文缩写是PTS、DTS、PCR。很多人能背出全称——Presentation Time Stamp、Decode Time Stamp、Program Clock Reference——但一到实际项目里就分不清谁管什么。我换个角度解释。
2.1 PTS:什么时候“展示”
PTS是Presentation Time Stamp,展示时间戳,决定一帧画面什么时候渲染到屏幕上、一段音频什么时候从扬声器里播出来。它是给“最终消费者”看的。
假设你有一段电影片段,画面显示顺序是第一幕、第二幕、第三幕,那么PTS就必须是递增的,播放器按照PTS从小到大的顺序输出画面,观众看到的就是正确剧情。如果PTS乱序,最直接的表现就是画面来回跳、声音忽前忽后。
关键点在于:PTS不是“数据原始顺序”,而是“展示顺序”。如果编码的时候用了B帧,那数据在文件里的物理存储顺序和解码输出顺序并不是一回事,但PTS一定代表最终的展示顺序。
在拿到一个流时,我会先用一张表大概建立概念:
| 名词 | 全称 | 作用阶段 | 简单理解 |
|---|---|---|---|
| PTS | Presentation Time Stamp | 解码完成后、渲染/播放前 | 什么时候给用户看/听 |
| DTS | Decode Time Stamp | 解码前 | 什么时候把数据喂给解码器 |
| PCR | Program Clock Reference | 传输/解复用 | 整个系统的参考心跳 |
2.2 DTS:什么时候“喂给解码器”
DTS是Decode Time Stamp,解码时间戳,决定压缩后的数据包“哪一刻进入解码器输入队列”。
为什么PTS和DTS会不一样?核心原因是B帧的存在。B帧(双向预测帧)在解码时依赖前后的帧,所以它的实际解码顺序和显示顺序不一样。
举个例子:假设一个GOP的显示顺序是 I、B、B、P,播放给用户看的时候,当然先显示I帧,然后显示第一个B帧、第二个B帧,最后是P帧。但是解码器拿到P帧的时候,P帧里包含了对未来B帧的预测参考,所以P帧必须先于B帧解码。真实解码顺序其实变成了 I、P、B、B。
这时候,如果封装格式里只存PTS不存DTS,解码器按照PTS的顺序把数据送进解码器,就会发生“P帧还没解出来,后面的B帧先到了”的尴尬。轻则丢帧,重则解码器直接报错。
所以,在存在B帧的编码流里,DTS必须小于等于PTS,而且DTS的递增顺序应该是解码器真正需要的输入顺序。FFmpeg里AVPacket的dts和pts字段,就是用来分别承载这两个值的。封装成TS流的时候,两者都会写进对应的字段;封装成MP4的时候,存储顺序也强制要求按照DTS递增排列,而不是PTS。
2.3 PCR:整个系统的“心跳基准”
PCR和PTS、DTS不太一样,它不是给单帧数据用的,而是给“整个系统”用的。
PCR全称Program Clock Reference,节目时钟基准。在MPEG-TS这种传输流里,编码器会周期性地往流里插入PCR值,这个值表示编码器本地参考时钟当前处于什么位置。解码端收到PCR后,会用它来校准自己的本地时钟,确保解码器和编码器的节奏一致。
你可以把PCR理解成乐队指挥手里的节拍器:每个乐手(解码器、渲染器)都必须听节拍器的指挥,才能保证演奏同步。如果没有PCR,哪怕每一帧的PTS算得再准,发送端和接收端的时钟频率只要有一点点偏差(晶振精度足够好也会有几十ppm的偏差),长时间传输后依然会累积出可见的音画漂移。
PCR的时钟单位很特殊,它的底层参考频率是27MHz,而PTS/DTS的参考频率是90kHz。为什么是90kHz?因为27MHz除以300,刚好等于90kHz,既能让PCR用MHz级别的精度做时钟校准,又能让PTS/DTS用90kHz做精度足够高的帧级时间标记,一个出于工程历史选择,一个出于精度与数据量的平衡。
2.4 单位怎么换算
既然PTS、DTS用90kHz,播放器内部又经常用毫秒、微秒,那换算绕不开。
核心公式只有一个:
实际时间(秒) = PTS值 / timescale反过来,从秒数生成时间戳:
PTS值 = 秒数 × timescale这里最容易翻车的是“约等”。举个例子,29.97fps的NTSC视频,每帧间隔约33.3667ms,在90kHz时间基下,每帧PTS增量大约是 90000 ÷ 29.97 ≈ 3003.003,但是实际封装时你不可能填小数,只能填3003。
所以业界有个默认做法:不是每一帧都重新计算,而是累加。第一帧PTS=0,第二帧PTS=3003,第三帧PTS=6006……这样每帧相对误差保持在极小范围,不会累积。
如果把90000换算成毫秒(timescale=1000),每帧PTS阈值变成 1000 ÷ 29.97 ≈ 33.3667,取整后就是33或者34。你要是每帧都取33,实际帧率就变成了30.30fps;每帧都取34,又是29.41fps,时间戳推进速度和真实帧率不一致,播放器会慢慢发现“这视频的实际节奏和自己预期对不上”。做播放器的人可能更敏感:当视频源的PTS增量偏离了容器里申明的帧率较多时,同步模块会一直试图纠正,反而造成肉眼可见的抖动。这也是我后来在工程里很少把时间基转成毫秒的原因。
3. 时间戳的完整旅程:采集、编码、封装、传输、解码、渲染各环节的流转
理解了PTS、DTS、PCR各自的职责,再沿着音视频数据的完整生命周期走一遍,你就能看到时间戳在每一个环节是怎么被“接力”的。
3.1 采集端:时间戳是在这里“出生”的
摄像头和麦克风的采样并不保证精确均匀,尤其是摄像头,在弱光环境下帧率可能从30fps掉到28fps。所以采集端打时间戳的第一原则是:一定要用采样实际发生的时间,而不是“我以为应该的时间”。
iOS上,AVCaptureOutput回调的CMSampleBuffer里自带PTS信息,它是基于设备时钟的CMTime,直接拿来用就行。Android上,SurfaceTexture的getTimestamp()返回的是纳秒级时间戳,MediaCodec送入解码器时用的presentationTimeUs则是微秒级别。这些平台时间戳虽然单位不同,但都代表“这一帧是什么时候被采集到的”,是后续所有时间计算的起点。
这里有个典型的坑:不要自己用System.currentTimeMillis()去给采集帧打时间戳。系统调用有调度延迟,回调过来的时候数据可能已经在缓冲区里等了几十毫秒,强行打“当前时间”会让PTS序列看起来像是带抖动的随机数,播放器那边就会出现无法解释的卡顿。
音频采集端稍微特殊一点。麦克风每一帧的样本数实际上是固定的(比如每帧1024个采样点),所以音频PTS最稳妥的做法是直接用“已采集的采样点总数”,拿采样率做时间基来标记。换句话说,第一帧PTS=0,第二帧PTS=1024,第三帧PTS=2048……这样音频的PTS推进和样本数量严格对应,播放器一眼就能判断出该以什么节奏播放。
3.2 编码与封装:时间戳怎么被写进容器
编码阶段,编码器会改变数据顺序,特别是开了B帧之后。所以采集阶段的时间戳不能简单当作最终PTS用,具体要看编码器的输出回调。
多数硬编码器(如VideoToolbox、MediaCodec)会在输出buffer里附带PTS字段,但是否支持DTS字段就各不相同了。在iOS上,VideoToolbox的输出顺序默认是解码顺序,CMSampleBuffer的decodeTimeStamp如果为kCMTimeInvalid,说明编码器没有显式提供DTS,这时你就需要用PTS和编码帧的显示顺序自己推算出DTS。
封装阶段,不同的容器对时间戳的处理方式不同。以MP4为例,它把每个sample(帧)的时长存在stts box里,而不是直接存“这一帧的PTS是多少”。播放器要算出某一帧的PTS,需要从第0帧开始把每帧时长累加起来。所以封装MP4的时候,除了绝对PTS,还要正确填写每个sample的duration。很多转封装工具做出来文件播放器不认,排查到最后往往是sample duration填错了。
TS流则更直白,每个PES包头里直接写PTS和DTS,90kHz的单位写死,不存在“每个文件自己定义时间基”的自由度。
3.3 传输与解码渲染:播放器如何用时间戳把音画对齐
传输阶段,如果走RTP,时间戳会放在RTP包头里。RTP时间戳的单位不固定,由payload类型的采样率决定:视频普遍固定使用90000Hz,音频则跟随音频采样率,比如8000、44100、48000。播放器收到RTP包后,需要从RTCP的SR包里找到NTP时间和RTP时间戳的映射关系,才能算出这段媒体对应到现实世界的时间。
解码渲染阶段,播放器的A/V同步策略通常是:以音频为“主时钟”,视频去追赶或等待音频。播放器每渲染一帧视频前,会计算当前音频播放位置,然后对比视频帧的PTS,如果视频帧PTS比音频位置早,就尽快渲染;如果视频帧PTS比音频位置晚,就继续等待,直到音频追上来。这套策略能不能稳定工作,完全取决于解码器输出的PTS是否连续、精确。如果上游PTS乱跳,播放器再怎么优化算法也救不回来。
4. 各种平台和容器都在用哪套时间基?一份换算对照表
在跨平台音视频开发里,最烦人的事情之一就是各平台时间基不一致。一会儿纳秒,一会儿微秒,一会儿又是90000。下面是我整理的常用时间基对照,建议收藏。
4.1 各平台/容器的时基参数对照
| 场景 | 时间基/单位 | 典型值 | 说明 |
|---|---|---|---|
| MP4容器 | timescale,由mvhd/tkhd定义 | 600、1000、90000不等 | MP4允许每个track自定义timescale,解析时必须读取box里的值 |
| MPEG-TS | PTS/DTS固定90000Hz,SCR为27MHz | 90000 | PTS范围33位,约26.5小时回绕 |
| 直播RTMP/FLV | 毫秒(ms) | 1000 | FLV的时间戳单位固定为毫秒,且只有PTS没有DTS,等于0则默认无B帧 |
| WebRTC音视频 | RTP timestamp,音频用采样率,视频用90000Hz | 音频8k/44.1k/48k;视频90000 | 时间戳单调递增,不表示绝对时间 |
| Android MediaCodec | 微秒(us) | 1_000_000 | presentationTimeUs是微秒 |
| Android SurfaceTexture | 纳秒(ns) | 1_000_000_000 | getTimestamp返回纳秒 |
| iOS CoreMedia | CMTime,value/timescale组合 | timescale常为600、1000、90000 | 比较时间用CMTimeCompare |
| FFmpeg内部 | AV_TIME_BASE | 1_000_000 | 表示微秒,常用于转换和比较 |
看到没有,Unity客运单位完全不一样。所以跨端开发时,第一个动作就是确定“当前模块要求的时间单位是什么”,不要在模块里YY“我觉得应该是毫秒”。
4.2 换算公式与“精度截断”陷阱
通用换算公式就两个,单位换过去换回来:
目标PTS = 源PTS × 目标timescale ÷ 源timescale 源时间(秒) = 源PTS ÷ 源timescale换算本身不难,难在“取整”两个字。
前面说过29.97fps的例子,直接把90000换成毫秒,一旦每帧取整就不精确。更麻烦的是,如果整个工程里流转的是浮点秒数,在打印日志、序列化、跨语言传输时,一旦被截断,时间信息就丢了。
我后来在工程里定了个规矩:内部计算尽量用整数PTS加timescale的组合,也就是保留“有理数”结构,不做浮点秒交换。如果一定要用秒做中间量,就用高精度整数纳秒,不要用float或double随便存。比如C++里可以用int64_t纳秒,这样从微秒到90kHz再到RTP时间戳切换时才不会丢失精度。
另外还有一个常见问题:有时候听到“PTS超时了”“PTS回绕了”,那是另一个坑。TS流里PTS字段只有33位,最大值约8589934591,除以90000约等于26.5小时,所以一个持续超过26.5小时的直播流,PTS必然会回绕。处理回绕的常见做法是检测到PTS值突然变小且超过阈值(比如上一帧增量很大,这一帧突然变成几千),就判断为回绕,再对这个流做逻辑上的“累加偏移补偿”,不能直接把PTS当成单调递增的普通数值看。
5. 时间戳三大典型故障的排查链路:漂移、跳变、乱序
讲理论和换算都不如实战排查有说服力。我整理了三类我在实际项目里反复遇到的时间戳故障,每一类都给出完整排查链路,不是直接给结论,而是让你知道我一步步是怎么定位的。
5.1 音画不同步:整体偏移和逐渐漂移是两条不同的路
遇到音画不同步,千万别上来就改播放器的同步策略。先确定它是“整体偏移”还是“逐渐漂移”,这两者的排查方向完全不同。
整体偏移:从视频开头就存在,比如声音总是比画面快500ms,且这个偏差基本恒定。这种情况一般是起始基差问题。常见原因包括:播放器首帧渲染策略不同(音频先出、视频等关键帧)、缓冲时长不一致、转封装时时间戳偏移量没对齐。处理方法是在播放器层做一个固定偏移修正,或者调整起始缓冲策略。
逐渐漂移:开头正常,播放几分钟后偏差越来越大,这种一定和时间戳推进速率有关。排查链路如下:
- 抓取音频包和视频包各200帧的PTS和dts,导出为文本。
- 写个简单脚本,分别计算相邻帧的PTS差值,以及这个差值对应的实际时长。
prev_pts = None for pts_raw, timescale in packets: if prev_pts is not None: delta = pts_raw - prev_pts sec = delta / timescale print(f"delta_pts={delta}, delta_sec={sec:.6f}") prev_pts = pts_raw- 看视频帧间隔是否和实际帧率匹配。如果PTS增量换算出来是40ms,但视频实际是30fps(理想33.3ms),说明发送端每帧PTS加多了,视频会“慢放”。
- 看音频帧间隔是否和帧长匹配。AAC-LC每帧1024个采样点,48000Hz采样率下每帧间隔约21.33ms。如果PTS增量是20ms,音频就会越来越快,直到播放器纠偏,然后又继续漂。
我那次直播App的问题就是这么定位出来的,音频PTS增量是21ms,而不是21.33ms,虽然只是一个肉眼几乎看不出的差别,但一分钟累加到30ms,三分钟就达到人耳可感知的延时了。
5.2 PTS跳变:花屏、卡顿、切片中断都可能是它
PTS跳变指的是PTS序列在某一个点突然往前跳了一大段,比如上一帧还是10000,下一帧突然变成30000,中间少了20000tick,对应约200多毫秒。
这种跳变在播放器端会导致花屏、画面突然快进、HLS切片无法连续播放等问题。因为播放器可能试图追赶上跳变后的时间,丢弃大量帧,或者解码器内部buffer被突然的空洞打断,产生解码错误。
最常见触发场景有三个:
- 采集端切换摄像头/音源时,没有平滑处理新设备的时间戳基线。
- 断流重连后,编码器重新从0开始计PTS。
- 多路流源混流的场景,不同源的时钟不同步,合流时又没有做偏移校正。
排查链路:
- 用ffprobe查看流信息,直接打印所有包的PTS:
ffprobe -select_streams v -show_entries packet=pts_time,dts_time -of csv=p=0 input.ts | head -50- 写个检测脚本,计算相邻帧PTS差,如果某次差值超过正常帧间隔的3倍以上,基本就是跳变点。
- 跳变点定位后,去上游找是谁在那一刻重置了PTS。如果是摄像头切换导致的,就在切换瞬间给新的时间戳序列加上“原序列最后一个正常值 + 一帧间隔”,让PTS保持连续;如果是编码器重启导致的,则需要在播放端处理时检测到跳变后做一次flush和状态重置,而不是继续当正常流解。
这里特别强调:日常开发里,编码器重启、摄像头切换这类事情很少在开发期被发现,因为测试环境不会频繁触发。一旦上生产,用户设备五花八门,各种生命周期切换都会导致PTS不连续,所以这个排查链路值得好好留着。
5.3 DTS乱序与时间戳回退:解码器为什么崩溃
DTS乱序和PTS跳变是两类不同的问题。PTS跳变是时间戳本身不连续,DTS乱序是数据包的物理顺序违背了解码器的预期。
典型的错误场景是:转封装时,只写PTS不写DTS,或者DTS写得和PTS一样。对于不含B帧的流(比如纯I帧P帧),PTS等于DTS,没毛病。一旦包含B帧,DTS必须小于等于PTS,且按解码顺序递增。如果封装时DTS没有遵循这个规则,解码器按DTS排序并送入解码时,会碰到“还没有解码的参考帧”这种错误,直接报错或者丢帧。
我之前帮人排查过一个Mp4转TS后无法在机顶盒上播放的问题,症状是前几秒正常,之后画面碎裂,最后播放器卡死。ffprobe看原始MP4一切都正常,转成TS后,用ffprobe打印packet顺序,发现dts在某些包上小于前一包的dts。一查代码,原来转封装的时候,为了保持原始文件的显示顺序,把sample的排列顺序按PTS重排了,而MP4要求存储顺序按DTS递增,这俩一冲突,DTS自然乱序。
修复方案是:从MP4里读sample时,不要自己重排,按sample table里已有的顺序直接写TS。或者反过来,如果是在FFmpeg框架里,要确保AVPacket的pos和dts字段正确传递,不要只拿AVFrame的pts去写容器。
排查这种问题,一条命令就够了:
ffprobe -show_packets output.ts | grep dts= | awk -F'dts=' '{ if ($2 < prev) print NR ": dts_decreased from " prev " to " $2; prev=$2 }'如果输出里有dts_decreased的行,那铁定是封装时DTS排错了。
6. 多年实践踩坑总结:时间戳使用守则与调试小工具
最后分享一些我长期实践里沉淀下来的规矩和调试方法。不是教科书总结,就是这些年被坑出来的个人经验。
第一,全链路尽量统一用“单调时钟”或“采样计数”作为时间戳基准,不要用墙上时间。墙上时间会受NTP校准、用户改时间、时区切换影响,一旦往回跳,时间戳序列直接炸掉。音视频领域几乎都推荐使用单调时钟(monotonic clock),比如clock_gettime(CLOCK_MONOTONIC)。
第二,音频时间戳请用采样点计数。AAC一帧1024个采样,采样率固定,PTS就按照1024递增,时间基用采样率。别转换成毫秒再取整,那个案例我在这篇文章开头已经讲过了,代价是踩坑。
第三,视频时间戳按照帧率推,不要按实际到队时间打。实时采集场景,用采集回调的真实时间没问题;但是在离线处理、转封装场景里,更稳的是根据帧率和帧序号来推PTS,比如30fps视频每帧加3000(90kHz单位),除非源流明确告诉你有变量帧率。
第四,做时间基转换时,用有理数结构保留精度。FFmpeg的AVRational,iOS的CMTime,都是这个思路。别看到double精度高就拿来随便干,跨语言序列化一次就可能丢精度。
第五,永远不要在流中间随意重置PTS。编码器重启、断流重连,这些操作可以重新开始,但PTS基线必须平移,让新旧数据在时间轴上无缝衔接。如果做不到连续,至少发一个关键帧,并在播放端预期到PTS不连续时做flush。
第六,封装MP4时一定要确认sample按DTS递增排列。很多转封装工具都能自动做到,但你手动写muxer的时候,这是最容易犯错的地方。写完文件后用ffprobe检查一下所有包的dts是否单调递增。
第七,用完FFmpeg不要只盯AVPacket。AVPacket里有pts和dts,AVFrame里也有pts,两者含义不同。处理滤镜链的时候,很容易拿AVFrame的pts去写容器,但是滤镜可能重排或插入帧,导致pts不再是容器格式能接受的时序,这样出来的文件是坏的。
第八,调试工具准备好。我常用一行Python脚本直接读音视频PTS序列,任何一个项目的调试阶段,我都会把它加进去。最基本的输出格式类似于前文那个delta脚本,稍微扩展一下,加上“偏离期望间隔超过X%就标红”的逻辑,对排查漂移类问题效率极高。
第九,遇到“偶尔不同步”先怀疑时间戳,别一上来就查网络。说实话,我见过太多人花两三天查网络、查解码器、查渲染队列,最后发现只是某个字段的取整方式不对。音视频链路很长,但时间戳是贯穿始终的锚点,它出问题,表现就是各种“莫名其妙”。
最后再分享一个小技巧:开发期随手开一个“时间戳健康检查”。每隔几百帧,就自动统计一下音视频PTS的单调性、帧间隔方差、DTS与PTS差值范围,任何异常当场输出告警。这一招实际作用很大,它能让你在用户发现问题之前,先把潜在的时间戳隐患暴露出来。我后来参与的每个音视频模块评审,都会强制过一遍时间戳健康检查,效果比反复review代码可靠得多。