当 MPEG-TS 声明有音频,但实际没有音频数据:从设置 hasAudio 后卡住到播放状态观测
项目地址:xqq/mpegts.js
直播流播放故障里,有一种现象很容易让人误判为浏览器、MSE 或网络问题:视频编码参数看起来正常,连接也一直在收数据,但播放器始终没有进入可播放状态,MEDIA_INFO 事件也迟迟不来。
问题往往不在视频,而在"有音频"这一判断本身。业务可能为流源设置 hasAudio: true;对 MPEG-TS 而言,播放器也会从 PMT 中得出同样的结论:该节目有音频。可实际传输流里却可能始终没有对应的音频 PES 数据。本文从原始播放链路出发,设计一套最小改造,将这个原本不可见的状态暴露给应用层。
本文围绕这个场景说明问题如何发生、播放状态观测能力应如何设计,以及应用侧应如何安全地识别它。
hasAudio: true 不等于已经收到音频
hasAudio 表示播放器应按"存在音频轨"处理,而不是"已经收到了可播放的音频"。这是播放卡住的关键误解。
在 FLV 路径中,MediaDataSource 的 hasAudio: true 可作为显式轨道声明;在 MPEG-TS 路径中,音频轨通常由 PMT 的 stream_type 和 PID 声明决定。来源不同,但只要播放器据此等待音频 init segment,且真实音频数据永远不到,就可能使播放无法启动。
js
// 此声明不能凭空产生音频帧。
const dataSource = {
type: 'flv',
url: 'https://example.com/live.flv',
hasAudio: true
};
若上游实际只输出视频,上述声明会使播放器等待不会到来的音频初始化信息。对 TS,表现相同的前提通常不是业务代码设置 hasAudio: true,而是 PMT 仍声明了一个音频 PID。
再区分两件事:声明和事实
MPEG-TS 中的 PAT/PMT 用来描述节目和轨道。mpegts.js 解析 PMT 时,只要发现 AAC、AC-3、MP3 等音频 stream_type,就会把内部标志 has_audio_ 设为 true。
这表示的是:PMT 声明存在音频 PID。它并不表示播放器已经收到了一帧可解析的音频数据。
真正收到并解析到音频帧时,库才会填充 MediaInfo.hasAudio、audioCodec、采样率和声道数。这是另一层、更接近事实的状态。
text
PMT 声明 AAC 音频 PID
│
▼
has_audio_ = true 只是声明
│
▼
等待该 PID 的音频 PES / ADTS 帧
│
├─ 收到并解析成功 ──> audio init segment 已下发
│
└─ 永远收不到 ──────> 声明与事实不一致
常见根因包括:编码器关闭了音频但没有更新 PMT、切流后 PMT 残留旧音频 PID,或者上游复用器只输出了视频包。
为什么一个不存在的音频会让视频也不能播放
在 TS demuxer 中,音视频同时被 PMT 声明时,初始化片段必须双双就绪:
ts
// mpegts.js-master/src/demux/ts-demuxer.ts
private isInitSegmentDispatched(): boolean {
if (this.has_video_ && this.has_audio_) {
return this.video_init_segment_dispatched_
&& this.audio_init_segment_dispatched_;
}
if (this.has_video_ && !this.has_audio_) {
return this.video_init_segment_dispatched_;
}
if (!this.has_video_ && this.has_audio_) {
return this.audio_init_segment_dispatched_;
}
return false;
}
因此,视频 init segment 即使已经生成,音频 init segment 只要永远到不了,条件就永远不成立。后续视频媒体片段也会被这道门拦住。
最终表现为:
MEDIA_INFO不完整或不触发;- 视频媒体片段不能继续下发到 MSE;
- 页面表现为加载中、首帧迟迟不出或长时间卡住。
这不是"播放器没发现音频",而是播放器过于相信了 PMT 的声明。
从零设计播放状态观测能力
原始 mpegts.js 已在 demuxer 内部维护了 PMT 解析结果和 init segment 下发状态,但应用层无法读取它们。改造不应贸然改变播放门控条件,也不应静默丢弃一条声明的音频轨;更稳妥的第一步是增加一条播放状态观测通道,把内部状态暴露到应用层:
text
TSDemuxer / FLVDemuxer
│ 采集 PMT/FLV Header 声明、init segment 下发状态
▼
TransmuxingController → Transmuxer → PlayerEngine → MSEPlayer
▼
player.diagnosisInfo
应用可读取的核心字段如下:
| 字段 | 含义 |
|---|---|
pmtHasAudio |
TS PMT(FLV 时为文件头)是否声明音频 |
pmtHasVideo |
是否声明视频 |
audioInitDispatched |
是否已成功下发音频 init segment |
videoInitDispatched |
是否已成功下发视频 init segment |
timestamp |
最近一次状态更新时间 |
streamType |
mpegts 或 flv |
实现上,demuxer 负责采集状态,TransmuxingController 和 Transmuxer 负责转发,PlayerEngine 与 MSEPlayer 最终以只读的 player.diagnosisInfo getter 提供给业务代码。
状态观测 Demo:不要立刻把慢音频判成坏音频
下面的示例只演示诊断策略:视频已就绪、PMT 仍宣称有音频、且等待超过一个宽限时间,才报告疑似错误。宽限时间应根据协议、GOP、网络抖动和上游行为调整。
js
const AUDIO_INIT_GRACE_PERIOD_MS = 5_000;
function watchAudioDeclaration(player, onSuspectedMismatch) {
let videoReadyAt = 0;
let reported = false;
const timer = window.setInterval(() => {
const info = player.diagnosisInfo;
if (!info || reported) return;
// PMT 没有声明音频,或视频尚未就绪,都不是本问题的判定条件。
if (!info.pmtHasAudio || !info.videoInitDispatched) return;
if (!videoReadyAt) videoReadyAt = Date.now();
if (info.audioInitDispatched) {
window.clearInterval(timer);
return;
}
const audioWaitMs = Date.now() - videoReadyAt;
if (audioWaitMs < AUDIO_INIT_GRACE_PERIOD_MS) return;
reported = true;
window.clearInterval(timer);
onSuspectedMismatch({ ...info, audioWaitMs });
}, 500);
return () => window.clearInterval(timer);
}
const stopWatching = watchAudioDeclaration(player, (info) => {
console.error('疑似 PMT 音频声明与实际数据不一致', info);
// 在这里记录诊断信息、提示用户或切换到已验证的备用播放地址。
});
该判断比等待 MEDIA_INFO 更有价值:在故障场景中,正是 MEDIA_INFO 等待音频信息而无法到来;而 diagnosisInfo 在 PMT 被解析、视频 init segment 下发后就可以提供证据。
诊断改造解决了什么,没有解决什么
这项改造解决了可观测性问题 。以前应用只能看到"设置 hasAudio: true 后播放没开始",却无法区分是网络慢、编码不支持,还是音频声明与实际 PES 数据不一致;现在可以在播放早期拿到明确状态并做监控、告警或切换策略。
它没有解决自动恢复问题 。例如,若实现中的 hasAudio: false 覆盖只传给了 FLV demuxer,而 MPEG-TS 路径没有对应覆盖,那么对问题 TS 流简单地销毁后用 { hasAudio: false } 重建,并不能保证跳过 PMT 音频声明。
要真正让该 TS 流降级为纯视频,需要在库层实现"应用显式覆盖 PMT 音频声明"的能力,或从源头修正 PMT / 复用器配置。诊断 API 仍然是这类修复的前提:先确认故障类型,再选择正确的恢复手段。
应用层检测到异常后如何处理
状态观测信息只能说明"疑似音频声明与实际数据不一致",不能代替恢复策略。应用层可按下面的顺序处理。
1. 先等待一个宽限期
不要因为第一次读取到 pmtHasAudio: true 和 audioInitDispatched: false 就立即判定故障。应当以视频 init segment 已下发作为起点,等待 3 到 5 秒或符合业务网络特征的更长时间。
js
const isAudioDeclarationMismatch =
info.pmtHasAudio &&
info.videoInitDispatched &&
!info.audioInitDispatched &&
audioWaitMs >= AUDIO_INIT_GRACE_PERIOD_MS;
这样可以避免把正常的音频晚到、首个音频关键帧延迟或短暂网络抖动误判为异常。
2. 固化故障证据
达到阈值后,记录或上报流地址、diagnosisInfo、实际等待时间、播放器版本和浏览器信息。上游排查时尤其需要确认:PMT 是否声明了音频 PID,以及该 PID 上是否真的存在 PES 包。
3. 彻底释放当前播放器
确认不再等待后,停止状态观测定时器,解除媒体元素关联并销毁播放器,避免旧的网络连接、SourceBuffer 和事件监听继续占用资源。
js
function disposePlayer(player, stopWatching) {
stopWatching();
player.unload();
player.detachMediaElement();
player.destroy();
}
实际代码中应保证该函数只执行一次,并将持有的 player 引用设为 null。
4. 选择可恢复的分支
优先级从高到低如下:
- 切换到上游提供的纯视频流或已验证的备用流。
- 修复源端复用器,让 PMT 不再声明没有数据的音频 PID。
- 在库的 MPEG-TS demuxer 中实现"应用显式覆盖 PMT 音频声明"的能力,然后再以纯视频模式创建播放器。
- 若没有备用流也无法修改库或源端,向用户展示明确错误状态,停止无限重连。
不要把"销毁后传入 { hasAudio: false } 重建"当成通用解法。只有配置确实被当前容器的 demuxer 消费时,它才会生效;前文所述的 TS 路径需要额外实现覆盖能力。
小结
PMT 是节目描述,不是音频数据到达的证明。当播放器把"声明有音频"作为"必须等到音频"的条件时,一个空音频 PID 就可能连带阻塞视频播放。
通过 player.diagnosisInfo 将 PMT 声明和 init segment 实际状态暴露给应用层,可以先解决故障无法归因的问题。生产环境应使用带宽限时间的检测,避免误伤慢到达音频;若要恢复播放,则应修复上游 PMT,或补齐 TS 路径的音频声明覆盖机制。