1002 lines
30 KiB
TypeScript
1002 lines
30 KiB
TypeScript
/**
|
|
* highly optimized TS demuxer:
|
|
* parse PAT, PMT
|
|
* extract PES packet from audio and video PIDs
|
|
* extract AVC/H264 NAL units and AAC/ADTS samples from PES packet
|
|
* trigger the remuxer upon parsing completion
|
|
* it also tries to workaround as best as it can audio codec switch (HE-AAC to AAC and vice versa), without having to restart the MediaSource.
|
|
* it also controls the remuxing process :
|
|
* upon discontinuity or level switch detection, it will also notifies the remuxer so that it can reset its state.
|
|
*/
|
|
|
|
import * as ADTS from './audio/adts';
|
|
import * as MpegAudio from './audio/mpegaudio';
|
|
import * as AC3 from './audio/ac3-demuxer';
|
|
import AvcVideoParser from './video/avc-video-parser';
|
|
import SampleAesDecrypter from './sample-aes';
|
|
import { Events } from '../events';
|
|
import { appendUint8Array, RemuxerTrackIdConfig } from '../utils/mp4-tools';
|
|
import { logger } from '../utils/logger';
|
|
import { ErrorTypes, ErrorDetails } from '../errors';
|
|
import type { HlsConfig } from '../config';
|
|
import type { HlsEventEmitter } from '../events';
|
|
import {
|
|
DemuxedVideoTrack,
|
|
DemuxedAudioTrack,
|
|
DemuxedTrack,
|
|
Demuxer,
|
|
DemuxerResult,
|
|
VideoSample,
|
|
DemuxedMetadataTrack,
|
|
DemuxedUserdataTrack,
|
|
ElementaryStreamData,
|
|
KeyData,
|
|
MetadataSchema,
|
|
} from '../types/demuxer';
|
|
import { AudioFrame } from '../types/demuxer';
|
|
|
|
export type ParsedTimestamp = {
|
|
pts?: number;
|
|
dts?: number;
|
|
};
|
|
|
|
export type PES = ParsedTimestamp & {
|
|
data: Uint8Array;
|
|
len: number;
|
|
};
|
|
|
|
export type ParsedVideoSample = ParsedTimestamp &
|
|
Omit<VideoSample, 'pts' | 'dts'>;
|
|
|
|
export interface TypeSupported {
|
|
mpeg: boolean;
|
|
mp3: boolean;
|
|
ac3: boolean;
|
|
}
|
|
|
|
const PACKET_LENGTH = 188;
|
|
|
|
class TSDemuxer implements Demuxer {
|
|
private readonly observer: HlsEventEmitter;
|
|
private readonly config: HlsConfig;
|
|
private typeSupported: TypeSupported;
|
|
|
|
private sampleAes: SampleAesDecrypter | null = null;
|
|
private pmtParsed: boolean = false;
|
|
private audioCodec?: string;
|
|
private videoCodec?: string;
|
|
private _duration: number = 0;
|
|
private _pmtId: number = -1;
|
|
|
|
private _videoTrack?: DemuxedVideoTrack;
|
|
private _audioTrack?: DemuxedAudioTrack;
|
|
private _id3Track?: DemuxedMetadataTrack;
|
|
private _txtTrack?: DemuxedUserdataTrack;
|
|
private aacOverFlow: AudioFrame | null = null;
|
|
private remainderData: Uint8Array | null = null;
|
|
private videoParser: AvcVideoParser;
|
|
|
|
constructor(
|
|
observer: HlsEventEmitter,
|
|
config: HlsConfig,
|
|
typeSupported: TypeSupported,
|
|
) {
|
|
this.observer = observer;
|
|
this.config = config;
|
|
this.typeSupported = typeSupported;
|
|
this.videoParser = new AvcVideoParser();
|
|
}
|
|
|
|
static probe(data: Uint8Array) {
|
|
const syncOffset = TSDemuxer.syncOffset(data);
|
|
if (syncOffset > 0) {
|
|
logger.warn(
|
|
`MPEG2-TS detected but first sync word found @ offset ${syncOffset}`,
|
|
);
|
|
}
|
|
return syncOffset !== -1;
|
|
}
|
|
|
|
static syncOffset(data: Uint8Array): number {
|
|
const length = data.length;
|
|
let scanwindow = Math.min(PACKET_LENGTH * 5, length - PACKET_LENGTH) + 1;
|
|
let i = 0;
|
|
while (i < scanwindow) {
|
|
// a TS init segment should contain at least 2 TS packets: PAT and PMT, each starting with 0x47
|
|
let foundPat = false;
|
|
let packetStart = -1;
|
|
let tsPackets = 0;
|
|
for (let j = i; j < length; j += PACKET_LENGTH) {
|
|
if (
|
|
data[j] === 0x47 &&
|
|
(length - j === PACKET_LENGTH || data[j + PACKET_LENGTH] === 0x47)
|
|
) {
|
|
tsPackets++;
|
|
if (packetStart === -1) {
|
|
packetStart = j;
|
|
// First sync word found at offset, increase scan length (#5251)
|
|
if (packetStart !== 0) {
|
|
scanwindow =
|
|
Math.min(
|
|
packetStart + PACKET_LENGTH * 99,
|
|
data.length - PACKET_LENGTH,
|
|
) + 1;
|
|
}
|
|
}
|
|
if (!foundPat) {
|
|
foundPat = parsePID(data, j) === 0;
|
|
}
|
|
// Sync word found at 0 with 3 packets, or found at offset least 2 packets up to scanwindow (#5501)
|
|
if (
|
|
foundPat &&
|
|
tsPackets > 1 &&
|
|
((packetStart === 0 && tsPackets > 2) ||
|
|
j + PACKET_LENGTH > scanwindow)
|
|
) {
|
|
return packetStart;
|
|
}
|
|
} else if (tsPackets) {
|
|
// Exit if sync word found, but does not contain contiguous packets
|
|
return -1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Creates a track model internal to demuxer used to drive remuxing input
|
|
*/
|
|
static createTrack(
|
|
type: 'audio' | 'video' | 'id3' | 'text',
|
|
duration?: number,
|
|
): DemuxedTrack {
|
|
return {
|
|
container:
|
|
type === 'video' || type === 'audio' ? 'video/mp2t' : undefined,
|
|
type,
|
|
id: RemuxerTrackIdConfig[type],
|
|
pid: -1,
|
|
inputTimeScale: 90000,
|
|
sequenceNumber: 0,
|
|
samples: [],
|
|
dropped: 0,
|
|
duration: type === 'audio' ? duration : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initializes a new init segment on the demuxer/remuxer interface. Needed for discontinuities/track-switches (or at stream start)
|
|
* Resets all internal track instances of the demuxer.
|
|
*/
|
|
public resetInitSegment(
|
|
initSegment: Uint8Array | undefined,
|
|
audioCodec: string,
|
|
videoCodec: string,
|
|
trackDuration: number,
|
|
) {
|
|
this.pmtParsed = false;
|
|
this._pmtId = -1;
|
|
|
|
this._videoTrack = TSDemuxer.createTrack('video') as DemuxedVideoTrack;
|
|
this._audioTrack = TSDemuxer.createTrack(
|
|
'audio',
|
|
trackDuration,
|
|
) as DemuxedAudioTrack;
|
|
this._id3Track = TSDemuxer.createTrack('id3') as DemuxedMetadataTrack;
|
|
this._txtTrack = TSDemuxer.createTrack('text') as DemuxedUserdataTrack;
|
|
this._audioTrack.segmentCodec = 'aac';
|
|
|
|
// flush any partial content
|
|
this.aacOverFlow = null;
|
|
this.remainderData = null;
|
|
this.audioCodec = audioCodec;
|
|
this.videoCodec = videoCodec;
|
|
this._duration = trackDuration;
|
|
}
|
|
|
|
public resetTimeStamp() {}
|
|
|
|
public resetContiguity(): void {
|
|
const { _audioTrack, _videoTrack, _id3Track } = this;
|
|
if (_audioTrack) {
|
|
_audioTrack.pesData = null;
|
|
}
|
|
if (_videoTrack) {
|
|
_videoTrack.pesData = null;
|
|
}
|
|
if (_id3Track) {
|
|
_id3Track.pesData = null;
|
|
}
|
|
this.aacOverFlow = null;
|
|
this.remainderData = null;
|
|
}
|
|
|
|
public demux(
|
|
data: Uint8Array,
|
|
timeOffset: number,
|
|
isSampleAes = false,
|
|
flush = false,
|
|
): DemuxerResult {
|
|
if (!isSampleAes) {
|
|
this.sampleAes = null;
|
|
}
|
|
|
|
let pes: PES | null;
|
|
|
|
const videoTrack = this._videoTrack as DemuxedVideoTrack;
|
|
const audioTrack = this._audioTrack as DemuxedAudioTrack;
|
|
const id3Track = this._id3Track as DemuxedMetadataTrack;
|
|
const textTrack = this._txtTrack as DemuxedUserdataTrack;
|
|
|
|
let videoPid = videoTrack.pid;
|
|
let videoData = videoTrack.pesData;
|
|
let audioPid = audioTrack.pid;
|
|
let id3Pid = id3Track.pid;
|
|
let audioData = audioTrack.pesData;
|
|
let id3Data = id3Track.pesData;
|
|
let unknownPID: number | null = null;
|
|
let pmtParsed = this.pmtParsed;
|
|
let pmtId = this._pmtId;
|
|
|
|
let len = data.length;
|
|
if (this.remainderData) {
|
|
data = appendUint8Array(this.remainderData, data);
|
|
len = data.length;
|
|
this.remainderData = null;
|
|
}
|
|
|
|
if (len < PACKET_LENGTH && !flush) {
|
|
this.remainderData = data;
|
|
return {
|
|
audioTrack,
|
|
videoTrack,
|
|
id3Track,
|
|
textTrack,
|
|
};
|
|
}
|
|
|
|
const syncOffset = Math.max(0, TSDemuxer.syncOffset(data));
|
|
len -= (len - syncOffset) % PACKET_LENGTH;
|
|
if (len < data.byteLength && !flush) {
|
|
this.remainderData = new Uint8Array(
|
|
data.buffer,
|
|
len,
|
|
data.buffer.byteLength - len,
|
|
);
|
|
}
|
|
|
|
// loop through TS packets
|
|
let tsPacketErrors = 0;
|
|
for (let start = syncOffset; start < len; start += PACKET_LENGTH) {
|
|
if (data[start] === 0x47) {
|
|
const stt = !!(data[start + 1] & 0x40);
|
|
const pid = parsePID(data, start);
|
|
const atf = (data[start + 3] & 0x30) >> 4;
|
|
|
|
// if an adaption field is present, its length is specified by the fifth byte of the TS packet header.
|
|
let offset: number;
|
|
if (atf > 1) {
|
|
offset = start + 5 + data[start + 4];
|
|
// continue if there is only adaptation field
|
|
if (offset === start + PACKET_LENGTH) {
|
|
continue;
|
|
}
|
|
} else {
|
|
offset = start + 4;
|
|
}
|
|
switch (pid) {
|
|
case videoPid:
|
|
if (stt) {
|
|
if (videoData && (pes = parsePES(videoData))) {
|
|
this.videoParser.parseAVCPES(
|
|
videoTrack,
|
|
textTrack,
|
|
pes,
|
|
false,
|
|
this._duration,
|
|
);
|
|
}
|
|
|
|
videoData = { data: [], size: 0 };
|
|
}
|
|
if (videoData) {
|
|
videoData.data.push(data.subarray(offset, start + PACKET_LENGTH));
|
|
videoData.size += start + PACKET_LENGTH - offset;
|
|
}
|
|
break;
|
|
case audioPid:
|
|
if (stt) {
|
|
if (audioData && (pes = parsePES(audioData))) {
|
|
switch (audioTrack.segmentCodec) {
|
|
case 'aac':
|
|
this.parseAACPES(audioTrack, pes);
|
|
break;
|
|
case 'mp3':
|
|
this.parseMPEGPES(audioTrack, pes);
|
|
break;
|
|
case 'ac3':
|
|
if (__USE_M2TS_ADVANCED_CODECS__) {
|
|
this.parseAC3PES(audioTrack, pes);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
audioData = { data: [], size: 0 };
|
|
}
|
|
if (audioData) {
|
|
audioData.data.push(data.subarray(offset, start + PACKET_LENGTH));
|
|
audioData.size += start + PACKET_LENGTH - offset;
|
|
}
|
|
break;
|
|
case id3Pid:
|
|
if (stt) {
|
|
if (id3Data && (pes = parsePES(id3Data))) {
|
|
this.parseID3PES(id3Track, pes);
|
|
}
|
|
|
|
id3Data = { data: [], size: 0 };
|
|
}
|
|
if (id3Data) {
|
|
id3Data.data.push(data.subarray(offset, start + PACKET_LENGTH));
|
|
id3Data.size += start + PACKET_LENGTH - offset;
|
|
}
|
|
break;
|
|
case 0:
|
|
if (stt) {
|
|
offset += data[offset] + 1;
|
|
}
|
|
|
|
pmtId = this._pmtId = parsePAT(data, offset);
|
|
// logger.log('PMT PID:' + this._pmtId);
|
|
break;
|
|
case pmtId: {
|
|
if (stt) {
|
|
offset += data[offset] + 1;
|
|
}
|
|
|
|
const parsedPIDs = parsePMT(
|
|
data,
|
|
offset,
|
|
this.typeSupported,
|
|
isSampleAes,
|
|
this.observer,
|
|
);
|
|
|
|
// only update track id if track PID found while parsing PMT
|
|
// this is to avoid resetting the PID to -1 in case
|
|
// track PID transiently disappears from the stream
|
|
// this could happen in case of transient missing audio samples for example
|
|
// NOTE this is only the PID of the track as found in TS,
|
|
// but we are not using this for MP4 track IDs.
|
|
videoPid = parsedPIDs.videoPid;
|
|
if (videoPid > 0) {
|
|
videoTrack.pid = videoPid;
|
|
videoTrack.segmentCodec = parsedPIDs.segmentVideoCodec;
|
|
}
|
|
|
|
audioPid = parsedPIDs.audioPid;
|
|
if (audioPid > 0) {
|
|
audioTrack.pid = audioPid;
|
|
audioTrack.segmentCodec = parsedPIDs.segmentAudioCodec;
|
|
}
|
|
id3Pid = parsedPIDs.id3Pid;
|
|
if (id3Pid > 0) {
|
|
id3Track.pid = id3Pid;
|
|
}
|
|
|
|
if (unknownPID !== null && !pmtParsed) {
|
|
logger.warn(
|
|
`MPEG-TS PMT found at ${start} after unknown PID '${unknownPID}'. Backtracking to sync byte @${syncOffset} to parse all TS packets.`,
|
|
);
|
|
unknownPID = null;
|
|
// we set it to -188, the += 188 in the for loop will reset start to 0
|
|
start = syncOffset - 188;
|
|
}
|
|
pmtParsed = this.pmtParsed = true;
|
|
break;
|
|
}
|
|
case 0x11:
|
|
case 0x1fff:
|
|
break;
|
|
default:
|
|
unknownPID = pid;
|
|
break;
|
|
}
|
|
} else {
|
|
tsPacketErrors++;
|
|
}
|
|
}
|
|
|
|
if (tsPacketErrors > 0) {
|
|
emitParsingError(
|
|
this.observer,
|
|
new Error(
|
|
`Found ${tsPacketErrors} TS packet/s that do not start with 0x47`,
|
|
),
|
|
);
|
|
}
|
|
|
|
videoTrack.pesData = videoData;
|
|
audioTrack.pesData = audioData;
|
|
id3Track.pesData = id3Data;
|
|
|
|
const demuxResult: DemuxerResult = {
|
|
audioTrack,
|
|
videoTrack,
|
|
id3Track,
|
|
textTrack,
|
|
};
|
|
|
|
if (flush) {
|
|
this.extractRemainingSamples(demuxResult);
|
|
}
|
|
|
|
return demuxResult;
|
|
}
|
|
|
|
public flush(): DemuxerResult | Promise<DemuxerResult> {
|
|
const { remainderData } = this;
|
|
this.remainderData = null;
|
|
let result: DemuxerResult;
|
|
if (remainderData) {
|
|
result = this.demux(remainderData, -1, false, true);
|
|
} else {
|
|
result = {
|
|
videoTrack: this._videoTrack as DemuxedVideoTrack,
|
|
audioTrack: this._audioTrack as DemuxedAudioTrack,
|
|
id3Track: this._id3Track as DemuxedMetadataTrack,
|
|
textTrack: this._txtTrack as DemuxedUserdataTrack,
|
|
};
|
|
}
|
|
this.extractRemainingSamples(result);
|
|
if (this.sampleAes) {
|
|
return this.decrypt(result, this.sampleAes);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private extractRemainingSamples(demuxResult: DemuxerResult) {
|
|
const { audioTrack, videoTrack, id3Track, textTrack } = demuxResult;
|
|
const videoData = videoTrack.pesData;
|
|
const audioData = audioTrack.pesData;
|
|
const id3Data = id3Track.pesData;
|
|
// try to parse last PES packets
|
|
let pes: PES | null;
|
|
if (videoData && (pes = parsePES(videoData))) {
|
|
this.videoParser.parseAVCPES(
|
|
videoTrack as DemuxedVideoTrack,
|
|
textTrack as DemuxedUserdataTrack,
|
|
pes,
|
|
true,
|
|
this._duration,
|
|
);
|
|
videoTrack.pesData = null;
|
|
} else {
|
|
// either avcData null or PES truncated, keep it for next frag parsing
|
|
videoTrack.pesData = videoData;
|
|
}
|
|
|
|
if (audioData && (pes = parsePES(audioData))) {
|
|
switch (audioTrack.segmentCodec) {
|
|
case 'aac':
|
|
this.parseAACPES(audioTrack, pes);
|
|
break;
|
|
case 'mp3':
|
|
this.parseMPEGPES(audioTrack, pes);
|
|
break;
|
|
case 'ac3':
|
|
if (__USE_M2TS_ADVANCED_CODECS__) {
|
|
this.parseAC3PES(audioTrack, pes);
|
|
}
|
|
break;
|
|
}
|
|
audioTrack.pesData = null;
|
|
} else {
|
|
if (audioData?.size) {
|
|
logger.log(
|
|
'last AAC PES packet truncated,might overlap between fragments',
|
|
);
|
|
}
|
|
|
|
// either audioData null or PES truncated, keep it for next frag parsing
|
|
audioTrack.pesData = audioData;
|
|
}
|
|
|
|
if (id3Data && (pes = parsePES(id3Data))) {
|
|
this.parseID3PES(id3Track, pes);
|
|
id3Track.pesData = null;
|
|
} else {
|
|
// either id3Data null or PES truncated, keep it for next frag parsing
|
|
id3Track.pesData = id3Data;
|
|
}
|
|
}
|
|
|
|
public demuxSampleAes(
|
|
data: Uint8Array,
|
|
keyData: KeyData,
|
|
timeOffset: number,
|
|
): Promise<DemuxerResult> {
|
|
const demuxResult = this.demux(
|
|
data,
|
|
timeOffset,
|
|
true,
|
|
!this.config.progressive,
|
|
);
|
|
const sampleAes = (this.sampleAes = new SampleAesDecrypter(
|
|
this.observer,
|
|
this.config,
|
|
keyData,
|
|
));
|
|
return this.decrypt(demuxResult, sampleAes);
|
|
}
|
|
|
|
private decrypt(
|
|
demuxResult: DemuxerResult,
|
|
sampleAes: SampleAesDecrypter,
|
|
): Promise<DemuxerResult> {
|
|
return new Promise((resolve) => {
|
|
const { audioTrack, videoTrack } = demuxResult;
|
|
if (audioTrack.samples && audioTrack.segmentCodec === 'aac') {
|
|
sampleAes.decryptAacSamples(audioTrack.samples, 0, () => {
|
|
if (videoTrack.samples) {
|
|
sampleAes.decryptAvcSamples(videoTrack.samples, 0, 0, () => {
|
|
resolve(demuxResult);
|
|
});
|
|
} else {
|
|
resolve(demuxResult);
|
|
}
|
|
});
|
|
} else if (videoTrack.samples) {
|
|
sampleAes.decryptAvcSamples(videoTrack.samples, 0, 0, () => {
|
|
resolve(demuxResult);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public destroy() {
|
|
this._duration = 0;
|
|
}
|
|
|
|
private parseAACPES(track: DemuxedAudioTrack, pes: PES) {
|
|
let startOffset = 0;
|
|
const aacOverFlow = this.aacOverFlow;
|
|
let data = pes.data;
|
|
if (aacOverFlow) {
|
|
this.aacOverFlow = null;
|
|
const frameMissingBytes = aacOverFlow.missing;
|
|
const sampleLength = aacOverFlow.sample.unit.byteLength;
|
|
// logger.log(`AAC: append overflowing ${sampleLength} bytes to beginning of new PES`);
|
|
if (frameMissingBytes === -1) {
|
|
data = appendUint8Array(aacOverFlow.sample.unit, data);
|
|
} else {
|
|
const frameOverflowBytes = sampleLength - frameMissingBytes;
|
|
aacOverFlow.sample.unit.set(
|
|
data.subarray(0, frameMissingBytes),
|
|
frameOverflowBytes,
|
|
);
|
|
track.samples.push(aacOverFlow.sample);
|
|
startOffset = aacOverFlow.missing;
|
|
}
|
|
}
|
|
// look for ADTS header (0xFFFx)
|
|
let offset: number;
|
|
let len: number;
|
|
for (offset = startOffset, len = data.length; offset < len - 1; offset++) {
|
|
if (ADTS.isHeader(data, offset)) {
|
|
break;
|
|
}
|
|
}
|
|
// if ADTS header does not start straight from the beginning of the PES payload, raise an error
|
|
if (offset !== startOffset) {
|
|
let reason: string;
|
|
const recoverable = offset < len - 1;
|
|
if (recoverable) {
|
|
reason = `AAC PES did not start with ADTS header,offset:${offset}`;
|
|
} else {
|
|
reason = 'No ADTS header found in AAC PES';
|
|
}
|
|
emitParsingError(this.observer, new Error(reason), recoverable);
|
|
if (!recoverable) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
ADTS.initTrackConfig(
|
|
track,
|
|
this.observer,
|
|
data,
|
|
offset,
|
|
this.audioCodec as string,
|
|
);
|
|
|
|
let pts: number;
|
|
if (pes.pts !== undefined) {
|
|
pts = pes.pts;
|
|
} else if (aacOverFlow) {
|
|
// if last AAC frame is overflowing, we should ensure timestamps are contiguous:
|
|
// first sample PTS should be equal to last sample PTS + frameDuration
|
|
const frameDuration = ADTS.getFrameDuration(track.samplerate as number);
|
|
pts = aacOverFlow.sample.pts + frameDuration;
|
|
} else {
|
|
logger.warn('[tsdemuxer]: AAC PES unknown PTS');
|
|
return;
|
|
}
|
|
|
|
// scan for aac samples
|
|
let frameIndex = 0;
|
|
let frame;
|
|
while (offset < len) {
|
|
frame = ADTS.appendFrame(track, data, offset, pts, frameIndex);
|
|
offset += frame.length;
|
|
if (!frame.missing) {
|
|
frameIndex++;
|
|
for (; offset < len - 1; offset++) {
|
|
if (ADTS.isHeader(data, offset)) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
this.aacOverFlow = frame;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private parseMPEGPES(track: DemuxedAudioTrack, pes: PES) {
|
|
const data = pes.data;
|
|
const length = data.length;
|
|
let frameIndex = 0;
|
|
let offset = 0;
|
|
const pts = pes.pts;
|
|
if (pts === undefined) {
|
|
logger.warn('[tsdemuxer]: MPEG PES unknown PTS');
|
|
return;
|
|
}
|
|
|
|
while (offset < length) {
|
|
if (MpegAudio.isHeader(data, offset)) {
|
|
const frame = MpegAudio.appendFrame(
|
|
track,
|
|
data,
|
|
offset,
|
|
pts,
|
|
frameIndex,
|
|
);
|
|
if (frame) {
|
|
offset += frame.length;
|
|
frameIndex++;
|
|
} else {
|
|
// logger.log('Unable to parse Mpeg audio frame');
|
|
break;
|
|
}
|
|
} else {
|
|
// nothing found, keep looking
|
|
offset++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private parseAC3PES(track: DemuxedAudioTrack, pes: PES) {
|
|
if (__USE_M2TS_ADVANCED_CODECS__) {
|
|
const data = pes.data;
|
|
const pts = pes.pts;
|
|
if (pts === undefined) {
|
|
logger.warn('[tsdemuxer]: AC3 PES unknown PTS');
|
|
return;
|
|
}
|
|
const length = data.length;
|
|
let frameIndex = 0;
|
|
let offset = 0;
|
|
let parsed;
|
|
|
|
while (
|
|
offset < length &&
|
|
(parsed = AC3.appendFrame(track, data, offset, pts, frameIndex++)) > 0
|
|
) {
|
|
offset += parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
private parseID3PES(id3Track: DemuxedMetadataTrack, pes: PES) {
|
|
if (pes.pts === undefined) {
|
|
logger.warn('[tsdemuxer]: ID3 PES unknown PTS');
|
|
return;
|
|
}
|
|
const id3Sample = Object.assign({}, pes as Required<PES>, {
|
|
type: this._videoTrack ? MetadataSchema.emsg : MetadataSchema.audioId3,
|
|
duration: Number.POSITIVE_INFINITY,
|
|
});
|
|
id3Track.samples.push(id3Sample);
|
|
}
|
|
}
|
|
|
|
function parsePID(data: Uint8Array, offset: number): number {
|
|
// pid is a 13-bit field starting at the last bit of TS[1]
|
|
return ((data[offset + 1] & 0x1f) << 8) + data[offset + 2];
|
|
}
|
|
|
|
function parsePAT(data: Uint8Array, offset: number): number {
|
|
// skip the PSI header and parse the first PMT entry
|
|
return ((data[offset + 10] & 0x1f) << 8) | data[offset + 11];
|
|
}
|
|
|
|
function parsePMT(
|
|
data: Uint8Array,
|
|
offset: number,
|
|
typeSupported: TypeSupported,
|
|
isSampleAes: boolean,
|
|
observer: HlsEventEmitter,
|
|
) {
|
|
const result = {
|
|
audioPid: -1,
|
|
videoPid: -1,
|
|
id3Pid: -1,
|
|
segmentVideoCodec: 'avc',
|
|
segmentAudioCodec: 'aac',
|
|
};
|
|
const sectionLength = ((data[offset + 1] & 0x0f) << 8) | data[offset + 2];
|
|
const tableEnd = offset + 3 + sectionLength - 4;
|
|
// to determine where the table is, we have to figure out how
|
|
// long the program info descriptors are
|
|
const programInfoLength =
|
|
((data[offset + 10] & 0x0f) << 8) | data[offset + 11];
|
|
// advance the offset to the first entry in the mapping table
|
|
offset += 12 + programInfoLength;
|
|
while (offset < tableEnd) {
|
|
const pid = parsePID(data, offset);
|
|
const esInfoLength = ((data[offset + 3] & 0x0f) << 8) | data[offset + 4];
|
|
switch (data[offset]) {
|
|
case 0xcf: // SAMPLE-AES AAC
|
|
if (!isSampleAes) {
|
|
logEncryptedSamplesFoundInUnencryptedStream('ADTS AAC');
|
|
break;
|
|
}
|
|
/* falls through */
|
|
case 0x0f: // ISO/IEC 13818-7 ADTS AAC (MPEG-2 lower bit-rate audio)
|
|
// logger.log('AAC PID:' + pid);
|
|
if (result.audioPid === -1) {
|
|
result.audioPid = pid;
|
|
}
|
|
|
|
break;
|
|
|
|
// Packetized metadata (ID3)
|
|
case 0x15:
|
|
// logger.log('ID3 PID:' + pid);
|
|
if (result.id3Pid === -1) {
|
|
result.id3Pid = pid;
|
|
}
|
|
|
|
break;
|
|
|
|
case 0xdb: // SAMPLE-AES AVC
|
|
if (!isSampleAes) {
|
|
logEncryptedSamplesFoundInUnencryptedStream('H.264');
|
|
break;
|
|
}
|
|
/* falls through */
|
|
case 0x1b: // ITU-T Rec. H.264 and ISO/IEC 14496-10 (lower bit-rate video)
|
|
// logger.log('AVC PID:' + pid);
|
|
if (result.videoPid === -1) {
|
|
result.videoPid = pid;
|
|
result.segmentVideoCodec = 'avc';
|
|
}
|
|
|
|
break;
|
|
|
|
// ISO/IEC 11172-3 (MPEG-1 audio)
|
|
// or ISO/IEC 13818-3 (MPEG-2 halved sample rate audio)
|
|
case 0x03:
|
|
case 0x04:
|
|
// logger.log('MPEG PID:' + pid);
|
|
if (!typeSupported.mpeg && !typeSupported.mp3) {
|
|
logger.log('MPEG audio found, not supported in this browser');
|
|
} else if (result.audioPid === -1) {
|
|
result.audioPid = pid;
|
|
result.segmentAudioCodec = 'mp3';
|
|
}
|
|
break;
|
|
|
|
case 0xc1: // SAMPLE-AES AC3
|
|
if (!isSampleAes) {
|
|
logEncryptedSamplesFoundInUnencryptedStream('AC-3');
|
|
break;
|
|
}
|
|
/* falls through */
|
|
case 0x81:
|
|
if (__USE_M2TS_ADVANCED_CODECS__) {
|
|
if (!typeSupported.ac3) {
|
|
logger.log('AC-3 audio found, not supported in this browser');
|
|
} else if (result.audioPid === -1) {
|
|
result.audioPid = pid;
|
|
result.segmentAudioCodec = 'ac3';
|
|
}
|
|
} else {
|
|
logger.warn('AC-3 in M2TS support not included in build');
|
|
}
|
|
break;
|
|
|
|
case 0x06:
|
|
// stream_type 6 can mean a lot of different things in case of DVB.
|
|
// We need to look at the descriptors. Right now, we're only interested
|
|
// in AC-3 audio, so we do the descriptor parsing only when we don't have
|
|
// an audio PID yet.
|
|
if (result.audioPid === -1 && esInfoLength > 0) {
|
|
let parsePos = offset + 5;
|
|
let remaining = esInfoLength;
|
|
|
|
while (remaining > 2) {
|
|
const descriptorId = data[parsePos];
|
|
|
|
switch (descriptorId) {
|
|
case 0x6a: // DVB Descriptor for AC-3
|
|
if (__USE_M2TS_ADVANCED_CODECS__) {
|
|
if (typeSupported.ac3 !== true) {
|
|
logger.log(
|
|
'AC-3 audio found, not supported in this browser for now',
|
|
);
|
|
} else {
|
|
result.audioPid = pid;
|
|
result.segmentAudioCodec = 'ac3';
|
|
}
|
|
} else {
|
|
logger.warn('AC-3 in M2TS support not included in build');
|
|
}
|
|
break;
|
|
}
|
|
|
|
const descriptorLen = data[parsePos + 1] + 2;
|
|
parsePos += descriptorLen;
|
|
remaining -= descriptorLen;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 0xc2: // SAMPLE-AES EC3
|
|
/* falls through */
|
|
case 0x87:
|
|
emitParsingError(observer, new Error('Unsupported EC-3 in M2TS found'));
|
|
return result;
|
|
|
|
case 0x24:
|
|
emitParsingError(observer, new Error('Unsupported HEVC in M2TS found'));
|
|
return result;
|
|
|
|
default:
|
|
// logger.log('unknown stream type:' + data[offset]);
|
|
break;
|
|
}
|
|
// move to the next table entry
|
|
// skip past the elementary stream descriptors, if present
|
|
offset += esInfoLength + 5;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function emitParsingError(
|
|
observer: HlsEventEmitter,
|
|
error: Error,
|
|
levelRetry?: boolean,
|
|
) {
|
|
logger.warn(`parsing error: ${error.message}`);
|
|
observer.emit(Events.ERROR, Events.ERROR, {
|
|
type: ErrorTypes.MEDIA_ERROR,
|
|
details: ErrorDetails.FRAG_PARSING_ERROR,
|
|
fatal: false,
|
|
levelRetry,
|
|
error,
|
|
reason: error.message,
|
|
});
|
|
}
|
|
|
|
function logEncryptedSamplesFoundInUnencryptedStream(type: string) {
|
|
logger.log(`${type} with AES-128-CBC encryption found in unencrypted stream`);
|
|
}
|
|
|
|
function parsePES(stream: ElementaryStreamData): PES | null {
|
|
let i = 0;
|
|
let frag: Uint8Array;
|
|
let pesLen: number;
|
|
let pesHdrLen: number;
|
|
let pesPts: number | undefined;
|
|
let pesDts: number | undefined;
|
|
const data = stream.data;
|
|
// safety check
|
|
if (!stream || stream.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
// we might need up to 19 bytes to read PES header
|
|
// if first chunk of data is less than 19 bytes, let's merge it with following ones until we get 19 bytes
|
|
// usually only one merge is needed (and this is rare ...)
|
|
while (data[0].length < 19 && data.length > 1) {
|
|
data[0] = appendUint8Array(data[0], data[1]);
|
|
data.splice(1, 1);
|
|
}
|
|
// retrieve PTS/DTS from first fragment
|
|
frag = data[0];
|
|
const pesPrefix = (frag[0] << 16) + (frag[1] << 8) + frag[2];
|
|
if (pesPrefix === 1) {
|
|
pesLen = (frag[4] << 8) + frag[5];
|
|
// if PES parsed length is not zero and greater than total received length, stop parsing. PES might be truncated
|
|
// minus 6 : PES header size
|
|
if (pesLen && pesLen > stream.size - 6) {
|
|
return null;
|
|
}
|
|
|
|
const pesFlags = frag[7];
|
|
if (pesFlags & 0xc0) {
|
|
/* PES header described here : http://dvd.sourceforge.net/dvdinfo/pes-hdr.html
|
|
as PTS / DTS is 33 bit we cannot use bitwise operator in JS,
|
|
as Bitwise operators treat their operands as a sequence of 32 bits */
|
|
pesPts =
|
|
(frag[9] & 0x0e) * 536870912 + // 1 << 29
|
|
(frag[10] & 0xff) * 4194304 + // 1 << 22
|
|
(frag[11] & 0xfe) * 16384 + // 1 << 14
|
|
(frag[12] & 0xff) * 128 + // 1 << 7
|
|
(frag[13] & 0xfe) / 2;
|
|
|
|
if (pesFlags & 0x40) {
|
|
pesDts =
|
|
(frag[14] & 0x0e) * 536870912 + // 1 << 29
|
|
(frag[15] & 0xff) * 4194304 + // 1 << 22
|
|
(frag[16] & 0xfe) * 16384 + // 1 << 14
|
|
(frag[17] & 0xff) * 128 + // 1 << 7
|
|
(frag[18] & 0xfe) / 2;
|
|
|
|
if (pesPts - pesDts > 60 * 90000) {
|
|
logger.warn(
|
|
`${Math.round(
|
|
(pesPts - pesDts) / 90000,
|
|
)}s delta between PTS and DTS, align them`,
|
|
);
|
|
pesPts = pesDts;
|
|
}
|
|
} else {
|
|
pesDts = pesPts;
|
|
}
|
|
}
|
|
pesHdrLen = frag[8];
|
|
// 9 bytes : 6 bytes for PES header + 3 bytes for PES extension
|
|
let payloadStartOffset = pesHdrLen + 9;
|
|
if (stream.size <= payloadStartOffset) {
|
|
return null;
|
|
}
|
|
stream.size -= payloadStartOffset;
|
|
// reassemble PES packet
|
|
const pesData = new Uint8Array(stream.size);
|
|
for (let j = 0, dataLen = data.length; j < dataLen; j++) {
|
|
frag = data[j];
|
|
let len = frag.byteLength;
|
|
if (payloadStartOffset) {
|
|
if (payloadStartOffset > len) {
|
|
// trim full frag if PES header bigger than frag
|
|
payloadStartOffset -= len;
|
|
continue;
|
|
} else {
|
|
// trim partial frag if PES header smaller than frag
|
|
frag = frag.subarray(payloadStartOffset);
|
|
len -= payloadStartOffset;
|
|
payloadStartOffset = 0;
|
|
}
|
|
}
|
|
pesData.set(frag, i);
|
|
i += len;
|
|
}
|
|
if (pesLen) {
|
|
// payload size : remove PES header + PES extension
|
|
pesLen -= pesHdrLen + 3;
|
|
}
|
|
return { data: pesData, pts: pesPts, dts: pesDts, len: pesLen };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default TSDemuxer;
|