Parse ID3 sample timestamp for HLS audio chunks

Pending improvement:

* Peek just the required priv frame. Avoid decoding all id3 information.
* Sniff the used container format instead of using the extension.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=141181781
This commit is contained in:
aquilescanta 2016-12-06 08:33:30 -08:00 committed by Oliver Woodman
parent e5bc00ea94
commit a66b4a9bad
2 changed files with 184 additions and 92 deletions

View File

@ -278,10 +278,9 @@ import java.util.Locale;
DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength, DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength,
null); null);
out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex], out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex],
trackSelection.getSelectionReason(), trackSelection.getSelectionData(), trackSelection.getSelectionReason(), trackSelection.getSelectionData(), startTimeUs,
startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence, startTimeUs + segment.durationUs, chunkMediaSequence, segment.discontinuitySequenceNumber,
segment.discontinuitySequenceNumber, isTimestampMaster, timestampAdjuster, previous, isTimestampMaster, timestampAdjuster, previous, encryptionKey, encryptionIv);
encryptionKey, encryptionIv);
} }
/** /**

View File

@ -16,6 +16,8 @@
package com.google.android.exoplayer2.source.hls; package com.google.android.exoplayer2.source.hls;
import android.text.TextUtils; import android.text.TextUtils;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.DefaultExtractorInput;
import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.Extractor;
import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorInput;
@ -26,11 +28,15 @@ import com.google.android.exoplayer2.extractor.ts.Ac3Extractor;
import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.AdtsExtractor;
import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
import com.google.android.exoplayer2.metadata.id3.PrivFrame;
import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunk;
import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl;
import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.Util;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@ -42,6 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger;
private static final AtomicInteger UID_SOURCE = new AtomicInteger(); private static final AtomicInteger UID_SOURCE = new AtomicInteger();
private static final String PRIV_TIMESTAMP_FRAME_OWNER =
"com.apple.streaming.transportStreamTimestamp";
private static final String AAC_FILE_EXTENSION = ".aac"; private static final String AAC_FILE_EXTENSION = ".aac";
private static final String AC3_FILE_EXTENSION = ".ac3"; private static final String AC3_FILE_EXTENSION = ".ac3";
private static final String EC3_FILE_EXTENSION = ".ec3"; private static final String EC3_FILE_EXTENSION = ".ec3";
@ -71,6 +80,11 @@ import java.util.concurrent.atomic.AtomicInteger;
private final boolean isMasterTimestampSource; private final boolean isMasterTimestampSource;
private final TimestampAdjuster timestampAdjuster; private final TimestampAdjuster timestampAdjuster;
private final HlsMediaChunk previousChunk; private final HlsMediaChunk previousChunk;
private final String lastPathSegment;
private final boolean isPackedAudio;
private final Id3Decoder id3Decoder;
private final ParsableByteArray id3Data;
private Extractor extractor; private Extractor extractor;
private int initSegmentBytesLoaded; private int initSegmentBytesLoaded;
@ -113,6 +127,19 @@ import java.util.concurrent.atomic.AtomicInteger;
this.previousChunk = previousChunk; this.previousChunk = previousChunk;
// Note: this.dataSource and dataSource may be different. // Note: this.dataSource and dataSource may be different.
this.isEncrypted = this.dataSource instanceof Aes128DataSource; this.isEncrypted = this.dataSource instanceof Aes128DataSource;
lastPathSegment = dataSpec.uri.getLastPathSegment();
isPackedAudio = lastPathSegment.endsWith(AAC_FILE_EXTENSION)
|| lastPathSegment.endsWith(AC3_FILE_EXTENSION)
|| lastPathSegment.endsWith(EC3_FILE_EXTENSION)
|| lastPathSegment.endsWith(MP3_FILE_EXTENSION);
if (isPackedAudio) {
id3Decoder = previousChunk != null ? previousChunk.id3Decoder : new Id3Decoder();
id3Data = previousChunk != null ? previousChunk.id3Data
: new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH);
} else {
id3Decoder = null;
id3Data = null;
}
initDataSource = dataSource; initDataSource = dataSource;
adjustedEndTimeUs = endTimeUs; adjustedEndTimeUs = endTimeUs;
uid = UID_SOURCE.getAndIncrement(); uid = UID_SOURCE.getAndIncrement();
@ -167,8 +194,9 @@ import java.util.concurrent.atomic.AtomicInteger;
@Override @Override
public void load() throws IOException, InterruptedException { public void load() throws IOException, InterruptedException {
if (extractor == null) { if (extractor == null && !isPackedAudio) {
extractor = buildExtractor(); // See HLS spec, version 20, Section 3.4 for more information on packed audio extraction.
extractor = buildExtractorByExtension();
} }
maybeLoadInitData(); maybeLoadInitData();
if (!loadCanceled) { if (!loadCanceled) {
@ -176,27 +204,152 @@ import java.util.concurrent.atomic.AtomicInteger;
} }
} }
// Private methods. // Internal loading methods.
private Extractor buildExtractor() { private void maybeLoadInitData() throws IOException, InterruptedException {
if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted
|| initDataSpec == null) {
return;
}
DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded);
try {
ExtractorInput input = new DefaultExtractorInput(initDataSource,
initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec));
try {
int result = Extractor.RESULT_CONTINUE;
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
result = extractor.read(input, null);
}
} finally {
initSegmentBytesLoaded = (int) (input.getPosition() - initDataSpec.absoluteStreamPosition);
}
} finally {
Util.closeQuietly(dataSource);
}
initLoadCompleted = true;
}
private void loadMedia() throws IOException, InterruptedException {
// If we previously fed part of this chunk to the extractor, we need to skip it this time. For
// encrypted content we need to skip the data by reading it through the source, so as to ensure
// correct decryption of the remainder of the chunk. For clear content, we can request the
// remainder of the chunk directly.
DataSpec loadDataSpec;
boolean skipLoadedBytes;
if (isEncrypted) {
loadDataSpec = dataSpec;
skipLoadedBytes = bytesLoaded != 0;
} else {
loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded);
skipLoadedBytes = false;
}
if (!isMasterTimestampSource) {
timestampAdjuster.waitUntilInitialized();
}
try {
ExtractorInput input = new DefaultExtractorInput(dataSource,
loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec));
if (extractor == null) {
// Media segment format is packed audio.
long id3Timestamp = peekId3PrivTimestamp(input);
if (id3Timestamp == C.TIME_UNSET) {
throw new ParserException("ID3 PRIV timestamp missing.");
}
extractor = buildPackedAudioExtractor(timestampAdjuster.adjustTsTimestamp(id3Timestamp));
}
if (skipLoadedBytes) {
input.skipFully(bytesLoaded);
}
try {
int result = Extractor.RESULT_CONTINUE;
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
result = extractor.read(input, null);
}
long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs();
if (adjustedEndTimeUs != Long.MIN_VALUE) {
this.adjustedEndTimeUs = adjustedEndTimeUs;
}
} finally {
bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
}
} finally {
Util.closeQuietly(dataSource);
}
loadCompleted = true;
}
/**
* Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined
* in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not
* found. This method only modifies the peek position.
*
* @param input The {@link ExtractorInput} to obtain the PRIV frame from.
* @return The parsed, adjusted timestamp in microseconds
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException {
input.resetPeekPosition();
if (!input.peekFully(id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH, true)) {
return C.TIME_UNSET;
}
id3Data.reset(Id3Decoder.ID3_HEADER_LENGTH);
int id = id3Data.readUnsignedInt24();
if (id != Id3Decoder.ID3_TAG) {
return C.TIME_UNSET;
}
id3Data.skipBytes(3); // version(2), flags(1).
int id3Size = id3Data.readSynchSafeInt();
int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH;
if (requiredCapacity > id3Data.capacity()) {
byte[] data = id3Data.data;
id3Data.reset(requiredCapacity);
System.arraycopy(data, 0, id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH);
}
if (!input.peekFully(id3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size, true)) {
return C.TIME_UNSET;
}
Metadata metadata = id3Decoder.decode(id3Data.data, id3Size);
if (metadata == null) {
return C.TIME_UNSET;
}
int metadataLength = metadata.length();
for (int i = 0; i < metadataLength; i++) {
Metadata.Entry frame = metadata.get(i);
if (frame instanceof PrivFrame) {
PrivFrame privFrame = (PrivFrame) frame;
if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) {
System.arraycopy(privFrame.privateData, 0, id3Data.data, 0, 8 /* timestamp size */);
id3Data.reset(8);
return id3Data.readLong();
}
}
}
return C.TIME_UNSET;
}
// Internal factory methods.
/**
* If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in
* order to decrypt the loaded data. Else returns the original.
*/
private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey,
byte[] encryptionIv) {
if (encryptionKey == null || encryptionIv == null) {
return dataSource;
}
return new Aes128DataSource(dataSource, encryptionKey, encryptionIv);
}
private Extractor buildExtractorByExtension() {
// Set the extractor that will read the chunk. // Set the extractor that will read the chunk.
Extractor extractor; Extractor extractor;
boolean needNewExtractor = previousChunk == null boolean needNewExtractor = previousChunk == null
|| previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber || previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber
|| trackFormat != previousChunk.trackFormat; || trackFormat != previousChunk.trackFormat;
boolean usingNewExtractor = true; boolean usingNewExtractor = true;
String lastPathSegment = dataSpec.uri.getLastPathSegment(); if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION)
if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) {
// TODO: Inject a timestamp adjuster and use it along with ID3 PRIV tag values with owner
// identifier com.apple.streaming.transportStreamTimestamp. This may also apply to the MP3
// case below.
extractor = new AdtsExtractor(startTimeUs);
} else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION)
|| lastPathSegment.endsWith(EC3_FILE_EXTENSION)) {
extractor = new Ac3Extractor(startTimeUs);
} else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) {
extractor = new Mp3Extractor(startTimeUs);
} else if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION)
|| lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) {
extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster); extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster);
} else if (!needNewExtractor) { } else if (!needNewExtractor) {
@ -231,80 +384,20 @@ import java.util.concurrent.atomic.AtomicInteger;
return extractor; return extractor;
} }
private void maybeLoadInitData() throws IOException, InterruptedException { private Extractor buildPackedAudioExtractor(long startTimeUs) {
if ((previousChunk != null && previousChunk.extractor == extractor) Extractor extractor;
|| initLoadCompleted || initDataSpec == null) { if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) {
return; extractor = new AdtsExtractor(startTimeUs);
} } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION)
DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) {
try { extractor = new Ac3Extractor(startTimeUs);
ExtractorInput input = new DefaultExtractorInput(initDataSource, } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) {
initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec)); extractor = new Mp3Extractor(startTimeUs);
try {
int result = Extractor.RESULT_CONTINUE;
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
result = extractor.read(input, null);
}
} finally {
initSegmentBytesLoaded += (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
}
} finally {
Util.closeQuietly(dataSource);
}
initLoadCompleted = true;
}
private void loadMedia() throws IOException, InterruptedException {
// If we previously fed part of this chunk to the extractor, we need to skip it this time. For
// encrypted content we need to skip the data by reading it through the source, so as to ensure
// correct decryption of the remainder of the chunk. For clear content, we can request the
// remainder of the chunk directly.
DataSpec loadDataSpec;
boolean skipLoadedBytes;
if (isEncrypted) {
loadDataSpec = dataSpec;
skipLoadedBytes = bytesLoaded != 0;
} else { } else {
loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded); throw new IllegalArgumentException("Unkown extension for audio file: " + lastPathSegment);
skipLoadedBytes = false;
} }
try { extractor.init(extractorOutput);
ExtractorInput input = new DefaultExtractorInput(dataSource, return extractor;
loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec));
if (skipLoadedBytes) {
input.skipFully(bytesLoaded);
}
try {
int result = Extractor.RESULT_CONTINUE;
if (!isMasterTimestampSource && timestampAdjuster != null) {
timestampAdjuster.waitUntilInitialized();
}
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
result = extractor.read(input, null);
}
long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs();
if (adjustedEndTimeUs != Long.MIN_VALUE) {
this.adjustedEndTimeUs = adjustedEndTimeUs;
}
} finally {
bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
}
} finally {
Util.closeQuietly(dataSource);
}
loadCompleted = true;
}
/**
* If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in
* order to decrypt the loaded data. Else returns the original.
*/
private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey,
byte[] encryptionIv) {
if (encryptionKey == null || encryptionIv == null) {
return dataSource;
}
return new Aes128DataSource(dataSource, encryptionKey, encryptionIv);
} }
} }