一、引言

本文记录一次 LTC 时间码播放系统的完整稳定性优化:不仅要让音视频跟随前跳、回退和暂停,还要解决开启 WebRTC 后“明明没有跳转,音视频却偶发卡顿”的问题。

结论:LTC 卡顿并不只是“性能不够”。真实跳转时,核心是正确处理管线、seek、base-time 和 HDMI/DEP 同步;没有人工跳转时,核心则是不能把单个 LTC 误码当成真跳转。

二、背景:LTC 是怎样控制播放的

LTC(Linear Time Code,线性时间码)是一种“用声音波形传递时间”的信号。它听起来像噪声,但解码后会得到类似 12:00:10:00 的时间:

HH : MM : SS : FF
时    分    秒    帧

本项目固定使用 25 fps、非 Drop Frame,所以每帧相隔 40 ms,帧号 FF 的范围是 00~24。例如节目单写了:

triggerTimecode = 12:00:10:00
inPointMs       = 5000
outPointMs      = 10000

它的意思不是“播放 12 点的第 5 秒”,而是:当 LTC 走到 12:00:10:00 时,从素材内部第 5000 ms 开始,播到第 10000 ms 结束。

三、整体架构与三条时间线

rk3588_gst_player 是运行在 RK3588 板端的 GStreamer 播放器。它不仅要显示 HDMI,还要输出 DEP 音频,并在用户开启实时流时产生 WebRTC 音视频。

这里其实有三种“时间”:LTC 决定节目应该播到哪里;GStreamer clock/base-time 决定帧在什么时候渲染;板端系统时间只用于日志、HTTP datetime 和监控。切换到 LTC 不应该修改板子的系统时间。

四、先定义正确的 LTC 语义

如果没有先定义“时间码怎样控制播放”,后面的修复很容易互相冲突。本系统最终采用下列规则:

LTC 状态HDMI 画面DEP 音频调度行为
连续正常前进正常播放正常播放按 triggerTimecode 和素材窗口计算位置
保持不动暂停并保持最后一帧停止输出不切换素材,不偷偷改用系统时间
前跳且继续前进定位到新位置后继续按新位置恢复确认新时间线后 seek
回退后保持保持回退前的画面停止输出新位置只记为待应用,不立即改画面
回退后继续前进定位到新位置后继续按新位置恢复连续前进成立后才应用新位置
信号丢失保持当前画面停止输出等待重新锁定,不回退系统时间

插播是例外:playlistCutIn action=true 仍然立即触发,不等 LTC cue。这样紧急插播不会被停止的时间码阻塞。

五、我们经历了哪些问题

故障不是一次出现的。每修好一层,下一层才更清楚:

六、怎样确认真正瓶颈

(一)先排除“算力不够”

开启 WebRTC 后更容易卡,最直觉的猜测是 CPU、内存或编码器过载。但现场数据不支持这个结论:

  • 8 核系统负载约 0.44,不是持续满载。
  • 可用内存约 15 GB,没有内存压力证据。
  • WebRTC 稳定阶段视频编码约 30 fps,队列通常为 0,没有 overrun。
  • 音频 RTP 没有连续丢包或时间戳倒退。

这说明 WebRTC 更像“放大了时序问题”,而不是单纯把芯片跑满。

(二)用分阶段日志代替感觉

“卡了几秒”无法回答是检测慢、管线创建慢、seek 慢,还是 sink 等时间戳。因此为以下节点加入单调时钟日志:

其中最有价值的是“请求 PLAYING”和“首帧真正输出”的间隔。如果前者很快、后者很慢,问题往往不在 HTTP 或节目单,而在 GStreamer 时间线。

(三)检查 LTC 帧号和到达间隔

一开始只记录 deltaFrames,后来加入 actualDeltaMs 和 expectedDeltaMs。这一步很关键:帧号一次增加很多,可能是真跳转,也可能是 Dante/DEP/libltc 把积压的帧批量交付。

[LTC-Timing] discontinuity
oldFrame=1082637
newFrame=1112639
deltaFrames=30002
actualDeltaMs=80
expectedDeltaMs=1200080
timecode=12:21:45:14

这条真实日志表明:仅 80 ms 后帧号却前进了 30002 帧,按 25 fps 等价于约 20 分钟。用户没有做这个跳转,所以这是“格式合法但内容错误”的 LTC 解码帧。

七、真实跳转的修复

问题 1LTC 停住了,素材还在继续播

早期代码检测到 hold 后停止管线,但调度线程又按同一 LTC 位置创建了新管线。管线内部使用自己的 GStreamer clock 继续前进,所以看起来像 LTC 没有真正控制播放。

解法:将 LTC 看成外部 transport。时间码停止时把活动管线保持在 PAUSED;回退后如果发生器仍暂停,只保存待应用位置,不改画面;连续收到 3 个正向样本后才恢复。

问题 2同一素材内跳转,HDMI 短暂黑屏

跳转本质只是换播放位置,但早期实现执行了 Stop + Cleanup + CreatePipeline。这会销毁 kmssink 和 DRM 显示链路,新管线第一帧到达前屏幕只能黑一下。开启 WebRTC 后管线还有 tee 和编码分支,重建成本更高。

解法:同一素材的 LTC 跳转复用原管线,执行 PAUSED → FLUSH/ACCURATE seek → PLAYING。只有跳出当前素材窗口时,才回退到重新选素材和建管线。

同时在每个新播放入口清除遗留的 stopRequested。否则上一次 Stop 的标记会让之后的原管线 seek 永远被拒绝,又回到闪黑的重建路径。

问题 3只前跳 2 秒,画面也会冻结超过 5 秒

这个问题经历了两层定位。第一层是:共享 HDMI/WebRTC 管线进入 PAUSED 时,WebRTC 分支可能需要几秒才完成状态收敛。如果同步等待 PAUSED,屏幕就一直停在旧帧。

第二层更隐蔽:首次同步起播时管线设置了 base-time。seek 后如果继续沿用旧 base-time,新媒体 PTS 可能仍在管线 running-time 的“未来”。即使状态已是 PLAYING,kmssink 也会等时间追上后才显示。

解法:LTC 重定位不再同步等待整条管线进入 PAUSED;发出 PAUSED 请求后直接执行 flush/accurate seek。seek 成功后重置 base-time,使用当前共享 clock 加约 30 ms 作为新起点,然后请求 PLAYING。

到这里的小结:跳转后的“卡”不一定是 seek 没执行。它也可能是状态切换在等 WebRTC 分支,或者 seek 后 PTS 仍然在旧 base-time 建立的未来时间线上。

八、准时起播与预卷

(一)先消除调度轮询延迟

原来播放线程轮询时钟,LTC 已经到触发点后,最多还要等近 100 ms 才被发现。修复后,每个解码帧直接通知 HDMI 和 DEP 调度线程,轮询只作为保底。LTC 模式的同步起播保护也从 120 ms 单独降到 30 ms,系统时间模式不受影响。

(二)提前预卷,到点只做 PLAYING

现场要求时间码到点就出画面,但解封装、创建解码器、寻找关键帧和 seek 都要时间。因此在触发前约 1 秒创建管线,seek 到 inPointMs 并停在 PAUSED;到点时复用已预卷管线,只设置共享起播时钟并切换 PLAYING。

(三)为什么预卷后反而提前 1 秒出画面

GStreamer sink 默认可以在 PAUSED 阶段显示 preroll 的第一帧。所以预期 12:00:10:00 开始,管线在 12:00:09:00 预卷时就把画面送上了 HDMI。

解法:只在 LTC 模式为 kmssink 设置 show-preroll-frame=false。这保留了提前解码的优势,同时确保只有真正进入 PLAYING 后才显示。

九、开启实时流后的联动问题

WebRTC 并不只是“多发一份网络数据”。它会给 HDMI 播放管线增加分支、队列、H.264/Opus 编码和 publisher 生命周期。因此不开实时流时看不出的时序缺陷,开启后会变得明显。

(一)同一素材跳转后,DEP 为什么先无声 1~2 秒

修复黑屏后,HDMI 会复用原管线原地 seek,不再加入新的起播同步组;DEP 音频管线则会重建。旧代码还让 DEP 等待“HDMI + DEP”两个参与者,但 HDMI 永远不会来报到,导致两次约 300 ms 的等待,再加解码和预缓冲,现象就是 1~2 秒无声。

解法:记住跳转前的 playlistId/itemIndex。如果恢复后仍是同一素材,DEP 不再等待缺席的 HDMI;多个 DEP 输出之间的同步仍然保留。跨素材和首次起播仍走完整同步流程。

(二)音频先恢复、视频后恢复

不同输出线程到达同步屏障的时间并不稳定。系统时间模式原有的迟到参与者等待上限是 5 秒,对 LTC 定位来说太长。最终仅对 LTC 模式缩短为 300 ms,并把 discontinuity generation 加入同步 key,防止新跳转误用上一次的屏障状态。

(三)already publishing 不代表真的流可用

LTC 预卷会比触发时间更早创建媒体管线。如果这时实时流 bridge 还没完成配置,管线创建失败可能留下旧 ZLM publisher。立即重试时,ZLMediaKit 只看到同名发布者还在,因此返回 already publishing,但这个残留 publisher 未必有有效媒体轨道。

解法:任何 LTC 预卷管线创建前先完成 ConfigureStreamBridge()。如启动失败,先主动确认 ZLM 已释放旧 publisher,或等待释放完成,再发起下一次。


 

十、没跳转也卡:真正根因是“假时间码”

在所有真实跳转问题修完后,最后一个现象很反直觉:发生器正常连续运行,用户没有前跳或回退,但开启实时流后 HDMI 和 DEP 仍偶尔一起卡顿。

(一)日志证明系统“以为用户跳转了”

同一次播放中观察到多个异常帧差:+19、-499、-60、+30002、-85、+59140、+59247。它们都是 libltc 可以解析的数字,但不符合当时用户操作。

旧逻辑收到一帧异常值就立即:

更新全局 LTC 位置
        |
        +-- discontinuity generation + 1
        +-- HDMI 进入 PAUSED / seek
        +-- DEP 停止并重建
        +-- WebRTC 分支重新处理时间线

所以从播放器的视角看,“没有人工跳转时的卡顿”其实仍然是跳转重定位,只是跳转由一个错误 LTC 帧触发。WebRTC 的额外线程调度和管线活动增加了 Dante 环形缓冲/libltc 偶发抖动暴露的机会,但并没有证据表明是 CPU 持续过载。

(二)为什么“只看帧差”不够

第一轮修复将帧差和实际到达间隔结合:如果帧号前进很多,但实际也过去了对应时间,它可能只是批量交付,不应当作跳转。同一次跳转产生的多个 discontinuity 也被合并,避免反复暂停和重建。

但这仍不够。一个彻底错误的单帧仍可以同时满足“帧差大”和“到达很快”,于是被当成真跳转。

(三)最终解法:跳转候选区 + 连续 3 样本确认

最终实现不再让一帧 LTC 直接改写播放时钟。正常连续帧仍然立即接受;只有看起来像跳转的帧才进入候选区:

  • 新时间线连续 3 个样本成立:确认真跳转。25 fps 下从第一个候选到第三个样本约 80 ms。
  • 一个异常帧后回到原时间线:丢弃候选,不增加 discontinuity generation,不暂停 HDMI,不重建 DEP。
  • 定位到新位置后一直输出同一帧:连续 3 个相同样本确认 hold,保持当前画面;新 LTC 开始连续前进后再应用位置。

这个方案的重点是:它只给“疑似跳转”加约 80 ms 确认,不给正常连续播放每帧加延迟。

# 单帧误码被拦截
[LTC-Timing] discontinuity-candidate-rejected \
candidateFrame=... recoveredFrame=... samples=1

# 连续样本确认为真跳转
[LTC-Timing] discontinuity ... confirmationSamples=3
[LTC-Timing] transport-resumed consecutiveFrames=3 recoverDelayMs=...

不要轻易归咎 LTC 发生器。测试中曾使用 GinormoTime 3.5.8,一度因某次 recoverDelayMs 约 23.5 秒而怀疑发生器跳转后输出不稳。但后续前跳 2 秒也能稳定复现,而日志又显示板端偶发错误解码帧,所以没有证据说发生器本身存在缺陷。

十一、独立因素:DEP 试用版重启

现场使用的 DEP 试用版约每 15 分钟重启一次。这会让 DanteEP 共享内存消失,因而同时影响 LTC 输入和 DEP 音频输出。它与播放器误判时间码是两个独立问题,不能用修改 seek 逻辑解决。

为了让两类故障可以被区分,播放器监控 DEP 共享内存与进程 PID,记录断开时刻、恢复持续时间、新旧 PID 以及疑似原因。一次实际日志为:

[DEP-Monitor] reconnect-success
sharedMemory=DanteEP
downtimeMs=63021
previousPid=12004
currentPid=13863
pidChanged=true
depRestartSuspected=true
suspectedReason=dep-process-restarted

这表明 DEP 约中断 63 秒,且 PID 已改变。如果卡顿恰好对应这类日志,应优先处理 DEP 许可和运行时稳定性,而不是再调 LTC 去抖参数。

十二、最终稳定方案

最终实现可以拆成四层,每层只负责一类问题:

层次责任关键规则
输入层从 Dante 共享内存读 PCM,libltc 解码25 fps、非 Drop Frame;500 ms 无有效帧判定 Lost
时钟层输出官方 LTC 位置和连续性状态帧差 + 到达间隔;疑似跳转需连续 3 样本确认
调度层根据时间码选素材和计算 offsethold 期间不改画面;连续前进后才应用待定位位置
媒体层GStreamer、HDMI、DEP、WebRTC 实际输出同素材原管线 seek;重置 base-time;预卷不显示;正确处理同步参与者

数据边界:本文保留了 100 ms 轮询、120 ms 到 30 ms 保护、5 秒到 300 ms 等真实参数,但没有把“现场看起来稳定”包装成长时间统计结论。上线前仍应进行长时连续跑测。

十三、复现与验收方法

(一)不要一上来就测 WebRTC

  1. 确认 DEP 进程和 DanteEP 共享内存存在。
  2. 确认 LTC 从 acquiring 进入 locked,并持续按 25 fps 前进。
  3. 不开 WebRTC,先测准时起播、hold、前跳 2 秒、大幅前跳、回退后 hold、回退后继续。
  4. 再开启实时流,完整重复上述用例。
  5. 最后保持 LTC 连续不做任何跳转,长时观察是否仍有卡顿。

(二)每个用例应该看什么

用例预期画面预期声音关键日志
准点起播触发前不出预卷帧,到点后播放与 HDMI 按共享目标时钟起播preroll-ready → trigger-detected → preroll-to-playing
回退后 hold保持回退前最后一帧停止relocate/hold,恢复前不应出现 PLAYING
同素材前跳不闪黑,很快显示新位置按新 offset 恢复reposition-seek-complete、reposition-base-time-reset、reposition-first-frame
单帧误码不停顿不重建只有 discontinuity-candidate-rejected
真跳转确认后重定位同步重定位confirmationSamples=3
DEP 进程重启可能因 LTC 丢失而 hold共享内存恢复前中断disconnect-detected 和 reconnect-success,核对 PID

(三)最小日志过滤

tail -F /root/Test/gst-test/player.log |
grep -E 'LTC-Timing|\[LTC\]|DEP-Monitor|PlaybackSync|already publishing'

BusyBox grep 不支持 GNU grep 的 --line-buffered,所以板端命令不应带这个选项。

十四、可复用经验

  1. 先定义 transport 语义。前跳、回退、hold、丢锁和恢复如果没有明确规则,代码会在“保持画面”和“跟随新位置”之间反复摇摆。
  2. 不要用销毁整条管线解决每个定位。同素材优先原地 seek,保留 sink 和实时流分支。
  3. PLAYING 不等于已出画面。必须记录首帧,同时检查 PTS、clock 和 base-time。
  4. 同步屏障的参与者必须反映真实管线。复用旧 HDMI 管线后,新 DEP 不能继续等一个永远不会到达的 HDMI 参与者。
  5. 外部时钟输入不能单样本信任。即使格式和校验都合法,也要用时间连续性确认大幅跳转。
  6. 不要把“开 WebRTC 后才出现”等同于“WebRTC 是根因”。额外分支可能只是改变了时序,暴露原本就存在的输入误码或竞态。
  7. 用 PID 和持续时间区分外部服务重启。否则 DEP 许可导致的中断会被错当成 GStreamer 或 LTC 算法回归。

最应该记住的是:一个可靠的 LTC 播放系统,既要敢于快速跟随真跳转,也要拒绝被单个假帧拖着整条音视频链路重启。输入判定和媒体重定位必须分层,否则修好一个黑屏,往往又会引入一个长时冻结。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐